diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 1d49b64e64ba..c8d4c92ad4b9 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -17,7 +17,7 @@ jobs: - uses: actions/checkout@v4 - uses: ruby/setup-ruby@v1 with: - ruby-version: '3.3.1' + ruby-version: '3.3.2' - uses: MeilCli/danger-action@v5 with: danger_file: 'Dangerfile' diff --git a/.ruby-version b/.ruby-version index bea438e9ade7..477254331794 100644 --- a/.ruby-version +++ b/.ruby-version @@ -1 +1 @@ -3.3.1 +3.3.2 diff --git a/Gemfile b/Gemfile index 31c8b24a8e14..11fc578e9120 100644 --- a/Gemfile +++ b/Gemfile @@ -157,9 +157,11 @@ gem "structured_warnings", "~> 0.4.0" # don't require by default, instead load on-demand when actually configured gem "airbrake", "~> 13.0.0", require: false +gem "markly", "~> 0.10" # another markdown parser like commonmarker, but with AST support used in PDF export gem "md_to_pdf", git: "https://github.com/opf/md-to-pdf", ref: "8f14736a88ad0064d2a97be108fe7061ffbcee91" gem "prawn", "~> 2.4" gem "ttfunk", "~> 1.7.0" # remove after https://github.com/prawnpdf/prawn/issues/1346 resolved. + # prawn implicitly depends on matrix gem no longer in ruby core with 3.1 gem "matrix", "~> 0.4.2" @@ -228,6 +230,8 @@ gem "turbo-rails", "~> 2.0.0" gem "httpx" +gem "gitlab_chronic_duration" + group :test do gem "launchy", "~> 3.0.0" gem "rack-test", "~> 2.1.0" @@ -384,6 +388,6 @@ gemfiles.each do |file| send(:eval_gemfile, file) if File.readable?(file) end -gem "openproject-octicons", "~>19.13.0" -gem "openproject-octicons_helper", "~>19.13.0" -gem "openproject-primer_view_components", "~>0.32.1" +gem "openproject-octicons", "~>19.14.0" +gem "openproject-octicons_helper", "~>19.14.0" +gem "openproject-primer_view_components", "~>0.33.1" diff --git a/Gemfile.lock b/Gemfile.lock index d2e977a1d98f..3263c39e6a86 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -226,35 +226,35 @@ GEM remote: https://rubygems.org/ specs: Ascii85 (1.1.1) - actioncable (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) + actioncable (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) nio4r (~> 2.0) websocket-driver (>= 0.6.1) zeitwerk (~> 2.6) - actionmailbox (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionmailbox (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (>= 2.7.1) net-imap net-pop net-smtp - actionmailer (7.1.3.3) - actionpack (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionmailer (7.1.3.4) + actionpack (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activesupport (= 7.1.3.4) mail (~> 2.5, >= 2.5.4) net-imap net-pop net-smtp rails-dom-testing (~> 2.2) - actionpack (7.1.3.3) - actionview (= 7.1.3.3) - activesupport (= 7.1.3.3) + actionpack (7.1.3.4) + actionview (= 7.1.3.4) + activesupport (= 7.1.3.4) nokogiri (>= 1.8.5) racc rack (>= 2.2.4) @@ -265,31 +265,31 @@ GEM actionpack-xml_parser (2.0.1) actionpack (>= 5.0) railties (>= 5.0) - actiontext (7.1.3.3) - actionpack (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + actiontext (7.1.3.4) + actionpack (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.6.0) nokogiri (>= 1.8.5) - actionview (7.1.3.3) - activesupport (= 7.1.3.3) + actionview (7.1.3.4) + activesupport (= 7.1.3.4) builder (~> 3.1) erubi (~> 1.11) rails-dom-testing (~> 2.2) rails-html-sanitizer (~> 1.6) - activejob (7.1.3.3) - activesupport (= 7.1.3.3) + activejob (7.1.3.4) + activesupport (= 7.1.3.4) globalid (>= 0.3.6) - activemodel (7.1.3.3) - activesupport (= 7.1.3.3) + activemodel (7.1.3.4) + activesupport (= 7.1.3.4) activemodel-serializers-xml (1.0.2) activemodel (> 5.x) activesupport (> 5.x) builder (~> 3.1) - activerecord (7.1.3.3) - activemodel (= 7.1.3.3) - activesupport (= 7.1.3.3) + activerecord (7.1.3.4) + activemodel (= 7.1.3.4) + activesupport (= 7.1.3.4) timeout (>= 0.4.0) activerecord-import (1.7.0) activerecord (>= 4.2) @@ -302,13 +302,13 @@ GEM multi_json (~> 1.11, >= 1.11.2) rack (>= 2.0.8, < 4) railties (>= 6.1) - activestorage (7.1.3.3) - actionpack (= 7.1.3.3) - activejob (= 7.1.3.3) - activerecord (= 7.1.3.3) - activesupport (= 7.1.3.3) + activestorage (7.1.3.4) + actionpack (= 7.1.3.4) + activejob (= 7.1.3.4) + activerecord (= 7.1.3.4) + activesupport (= 7.1.3.4) marcel (~> 1.0) - activesupport (7.1.3.3) + activesupport (7.1.3.4) base64 bigdecimal concurrent-ruby (~> 1.0, >= 1.0.2) @@ -420,11 +420,11 @@ GEM descendants_tracker (~> 0.0.1) color_conversion (0.1.1) colored2 (4.0.0) - commonmarker (1.1.3) + commonmarker (1.1.4) rb_sys (~> 0.9) compare-xml (0.66) nokogiri (~> 1.8) - concurrent-ruby (1.2.3) + concurrent-ruby (1.3.1) connection_pool (2.4.1) cookiejar (0.3.4) cose (1.3.0) @@ -529,7 +529,7 @@ GEM concurrent-ruby (~> 1.1) webrick (~> 1.7) websocket-driver (>= 0.6, < 0.8) - ffi (1.16.3) + ffi (1.17.0) flamegraph (0.9.5) fog-aws (3.22.0) fog-core (~> 2.1) @@ -556,6 +556,8 @@ GEM fuubar (2.5.1) rspec-core (~> 3.0) ruby-progressbar (~> 1.4) + gitlab_chronic_duration (0.12.0) + numerizer (~> 0.2) glob (0.4.0) globalid (1.2.1) activesupport (>= 6.1) @@ -670,7 +672,7 @@ GEM launchy (3.0.1) addressable (~> 2.8) childprocess (~> 5.0) - lefthook (1.6.14) + lefthook (1.6.15) letter_opener (1.10.0) launchy (>= 2.2, < 4) letter_opener_web (3.0.0) @@ -722,7 +724,7 @@ GEM mime-types-data (3.2024.0507) mini_magick (4.12.0) mini_mime (1.1.5) - mini_portile2 (2.8.6) + mini_portile2 (2.8.7) minitest (5.23.1) msgpack (1.7.2) multi_json (1.15.0) @@ -733,7 +735,7 @@ GEM mutex_m (0.2.0) net-http (0.4.1) uri - net-imap (0.4.11) + net-imap (0.4.12) date net-protocol net-ldap (0.19.0) @@ -747,6 +749,7 @@ GEM nokogiri (1.16.5) mini_portile2 (~> 2.8.2) racc (~> 1.4) + numerizer (0.2.0) oj (3.16.3) bigdecimal (>= 3.0) okcomputer (1.18.5) @@ -767,12 +770,12 @@ GEM validate_email validate_url webfinger (~> 2.0) - openproject-octicons (19.13.0) - openproject-octicons_helper (19.13.0) + openproject-octicons (19.14.0) + openproject-octicons_helper (19.14.0) actionview - openproject-octicons (= 19.13.0) + openproject-octicons (= 19.14.0) railties - openproject-primer_view_components (0.32.1) + openproject-primer_view_components (0.33.1) actionview (>= 5.0.0) activesupport (>= 5.0.0) openproject-octicons (>= 19.12.0) @@ -873,20 +876,20 @@ GEM rackup (1.0.0) rack (< 3) webrick - rails (7.1.3.3) - actioncable (= 7.1.3.3) - actionmailbox (= 7.1.3.3) - actionmailer (= 7.1.3.3) - actionpack (= 7.1.3.3) - actiontext (= 7.1.3.3) - actionview (= 7.1.3.3) - activejob (= 7.1.3.3) - activemodel (= 7.1.3.3) - activerecord (= 7.1.3.3) - activestorage (= 7.1.3.3) - activesupport (= 7.1.3.3) + rails (7.1.3.4) + actioncable (= 7.1.3.4) + actionmailbox (= 7.1.3.4) + actionmailer (= 7.1.3.4) + actionpack (= 7.1.3.4) + actiontext (= 7.1.3.4) + actionview (= 7.1.3.4) + activejob (= 7.1.3.4) + activemodel (= 7.1.3.4) + activerecord (= 7.1.3.4) + activestorage (= 7.1.3.4) + activesupport (= 7.1.3.4) bundler (>= 1.15.0) - railties (= 7.1.3.3) + railties (= 7.1.3.4) rails-controller-testing (1.0.5) actionpack (>= 5.0.1.rc1) actionview (>= 5.0.1.rc1) @@ -901,9 +904,9 @@ GEM rails-i18n (7.0.9) i18n (>= 0.7, < 2) railties (>= 6.0.0, < 8) - railties (7.1.3.3) - actionpack (= 7.1.3.3) - activesupport (= 7.1.3.3) + railties (7.1.3.4) + actionpack (= 7.1.3.4) + activesupport (= 7.1.3.4) irb rackup (>= 1.0.0) rake (>= 12.2) @@ -925,7 +928,7 @@ GEM redis-client (0.22.2) connection_pool regexp_parser (2.9.2) - reline (0.5.7) + reline (0.5.8) io-console (~> 0.5) representable (3.2.0) declarative (< 0.1.0) @@ -996,7 +999,7 @@ GEM rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) rubocop-ast (>= 1.31.1, < 2.0) - rubocop-rspec (2.29.2) + rubocop-rspec (2.30.0) rubocop (~> 1.40) rubocop-capybara (~> 2.17) rubocop-factory_bot (~> 2.22) @@ -1081,7 +1084,7 @@ GEM text-hyphen (1.5.0) thor (1.3.1) thread_safe (0.3.6) - timecop (0.9.8) + timecop (0.9.9) timeout (0.4.1) tpm-key_attestation (0.12.0) bindata (~> 2.4) @@ -1150,7 +1153,7 @@ GEM xpath (3.2.0) nokogiri (~> 1.8) yard (0.9.36) - zeitwerk (2.6.14) + zeitwerk (2.6.15) PLATFORMS ruby @@ -1211,6 +1214,7 @@ DEPENDENCIES fog-aws friendly_id (~> 5.5.0) fuubar (~> 2.5.0) + gitlab_chronic_duration gon (~> 6.4.0) good_job (= 3.26.2) google-apis-gmail_v1 @@ -1233,6 +1237,7 @@ DEPENDENCIES lograge (~> 0.14.0) lookbook (~> 2.3.0) mail (= 2.8.1) + markly (~> 0.10) matrix (~> 0.4.2) md_to_pdf! meta-tags (~> 2.21.0) @@ -1262,10 +1267,10 @@ DEPENDENCIES openproject-job_status! openproject-ldap_groups! openproject-meeting! - openproject-octicons (~> 19.13.0) - openproject-octicons_helper (~> 19.13.0) + openproject-octicons (~> 19.14.0) + openproject-octicons_helper (~> 19.14.0) openproject-openid_connect! - openproject-primer_view_components (~> 0.32.1) + openproject-primer_view_components (~> 0.33.1) openproject-recaptcha! openproject-reporting! openproject-storages! @@ -1355,7 +1360,7 @@ DEPENDENCIES with_advisory_lock (~> 5.1.0) RUBY VERSION - ruby 3.3.1p55 + ruby 3.3.2p78 BUNDLED WITH - 2.5.10 + 2.5.11 diff --git a/app/assets/images/logo-black-bg-ua.png b/app/assets/images/logo-black-bg-ua.png new file mode 100644 index 000000000000..176f3cf46da3 Binary files /dev/null and b/app/assets/images/logo-black-bg-ua.png differ diff --git a/app/assets/images/logo-white-bg-ua.png b/app/assets/images/logo-white-bg-ua.png new file mode 100644 index 000000000000..7278fdcf6d2a Binary files /dev/null and b/app/assets/images/logo-white-bg-ua.png differ diff --git a/app/components/_index.sass b/app/components/_index.sass index df5d51df72f6..f6f4e66f16df 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,6 +2,7 @@ @import "work_packages/share/invite_user_form_component" @import "work_packages/progress/modal_body_component" @import "open_project/common/attribute_component" -@import "filters_component" +@import "filter/filters_component" @import "projects/settings/project_custom_field_sections/index_component" @import "projects/row_component" +@import "settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component" diff --git a/app/components/filter/filter_button_component.html.erb b/app/components/filter/filter_button_component.html.erb new file mode 100644 index 000000000000..9f36eb0ba0ee --- /dev/null +++ b/app/components/filter/filter_button_component.html.erb @@ -0,0 +1,8 @@ +<%= render(Primer::Beta::Button.new(scheme: :secondary, + disabled:, + data: { "filters-target": "filterFormToggle", + action: "filters#toggleDisplayFilters" }, + test_selector: "filter-component-toggle")) do |button| %> + <% button.with_trailing_visual_counter(count: filters_count, test_selector: "filters-button-counter") %> + <%= t(:label_filter) %> +<% end %> diff --git a/app/components/filter/filter_button_component.rb b/app/components/filter/filter_button_component.rb new file mode 100644 index 000000000000..e884b254dcb9 --- /dev/null +++ b/app/components/filter/filter_button_component.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 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 Filter + # rubocop:disable OpenProject/AddPreviewForViewComponent + class FilterButtonComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + options :query + options :disabled + + def filters_count + @filters_count ||= query.filters.count + end + end +end diff --git a/app/components/filter/filter_component.html.erb b/app/components/filter/filter_component.html.erb new file mode 100644 index 000000000000..5507942ea86c --- /dev/null +++ b/app/components/filter/filter_component.html.erb @@ -0,0 +1,127 @@ +<%= form_tag({}, + method: :get, + class: "op-filters-form op-filters-form_top-margin #{show_filters_section? ? "-expanded" : ""}", + data: { + 'filters-target': 'filterForm', + action: 'submit->filters#sendForm:prevent' + }) do %> + <% operators_without_values = %w[* !* t w] %> +
+ + <%= t(:label_filter_plural) %> + + <% unless EnterpriseToken.allows_to?(:custom_fields_in_projects_list)%> + <%= + helpers.angular_component_tag 'op-enterprise-banner', + inputs: { + collapsible: true, + textMessage: t('ee.upsale.project_filters.description_html'), + moreInfoLink: OpenProject::Static::Links.links[:enterprise_docs][:custom_field_projects][:href], + } + %> + <% end %> +
+<% end %> diff --git a/app/components/filter/filter_component.rb b/app/components/filter/filter_component.rb new file mode 100644 index 000000000000..ea313e4d1975 --- /dev/null +++ b/app/components/filter/filter_component.rb @@ -0,0 +1,83 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 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 Filter + # rubocop:disable OpenProject/AddPreviewForViewComponent + class FilterComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + options :query + options always_visible: false + + def show_filters_section? + always_visible || params[:filters].present? + end + + # Returns filters, active and inactive. + # In case a filter is active, the active one will be preferred over the inactive one. + def each_filter + allowed_filters.each do |filter| + active_filter = query.find_active_filter(filter.name) + additional_attributes = additional_filter_attributes(filter) + + yield active_filter.presence || filter, active_filter.present?, additional_attributes + end + end + + def allowed_filters + query + .available_filters + end + + protected + + # With this method we can pass additional options for each type of filter into the frontend. This is especially + # useful when we want to pass options for the autocompleter components. + # + # When the method is overwritten in a subclass, the subclass should call super(filter) to get the default attributes. + # + # @param filter [QueryFilter] the filter for which we want to pass additional attributes + # @return [Hash] the additional attributes for the filter, that will be yielded in the each_filter method + def additional_filter_attributes(filter) + case filter + when Queries::Filters::Shared::ProjectFilter + { + autocomplete_options: { + component: "opce-project-autocompleter", + resource: "projects", + filters: [ + { name: "active", operator: "=", values: ["t"] } + ] + } + } + else + {} + end + end + end +end diff --git a/app/components/filters_component.sass b/app/components/filter/filters_component.sass similarity index 88% rename from app/components/filters_component.sass rename to app/components/filter/filters_component.sass index b2d7bf64a8b0..6e7856e6301f 100644 --- a/app/components/filters_component.sass +++ b/app/components/filter/filters_component.sass @@ -43,10 +43,5 @@ .advanced-filters--controls margin-top: 1rem - &-header - display: flex - justify-content: space-between - margin-bottom: 1rem - - &-actions - display: flex \ No newline at end of file + &_top-margin + margin-top: 1rem diff --git a/app/components/filters_component.html.erb b/app/components/filters_component.html.erb deleted file mode 100644 index 58b2547b1357..000000000000 --- a/app/components/filters_component.html.erb +++ /dev/null @@ -1,153 +0,0 @@ -
- -
- <%= render(Primer::Beta::Button.new( - scheme: :secondary, - disabled:, - data: { 'filters-target': 'filterFormToggle', - 'action': 'filters#toggleDisplayFilters', - 'test-selector': 'filter-component-toggle' })) do %> - <%= t(:label_filter) %> - <%= render(Primer::Beta::Counter.new(count: filters_count, - round: true, - hide_if_zero: false, - scheme: :default, - test_selector: "filters-button-counter" )) %> - <% end %> -
- <% buttons.each do |button| %> - <%= button %> - <% end %> -
-
- <%= form_tag({}, - method: :get, - class: "op-filters-form", - data: { - 'filters-target': 'filterForm', - action: 'submit->filters#sendForm:prevent' - }) do %> - <% operators_without_values = %w[* !* t w] %> -
- - <%= t(:label_filter_plural) %> - - <% unless EnterpriseToken.allows_to?(:custom_fields_in_projects_list)%> - <%= - helpers.angular_component_tag 'op-enterprise-banner', - inputs: { - collapsible: true, - textMessage: t('ee.upsale.project_filters.description_html'), - moreInfoLink: OpenProject::Static::Links.links[:enterprise_docs][:custom_field_projects][:href], - } - %> - <% end %> -
- <% end %> -
diff --git a/app/components/filters_component.rb b/app/components/filters_component.rb deleted file mode 100644 index c4e4404cb8a2..000000000000 --- a/app/components/filters_component.rb +++ /dev/null @@ -1,90 +0,0 @@ -# frozen_string_literal: true - -# -- copyright -# OpenProject is an open source project management software. -# Copyright (C) 2010-2023 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. -# ++ - -class FiltersComponent < ApplicationComponent - options :query - options :disabled - options output_format: "params" - - renders_many :buttons, lambda { |**system_arguments| - system_arguments[:ml] ||= 2 - Primer::Beta::Button.new(**system_arguments) - } - - def show_filters_section? - params[:filters].present? && !params.key?(:hide_filters_section) - end - - # Returns filters, active and inactive. - # In case a filter is active, the active one will be preferred over the inactive one. - def each_filter - allowed_filters.each do |filter| - active_filter = query.find_active_filter(filter.name) - additional_attributes = additional_filter_attributes(filter) - - yield active_filter.presence || filter, active_filter.present?, additional_attributes - end - end - - def allowed_filters - query - .available_filters - end - - def filters_count - @filters_count ||= query.filters.count - end - - protected - - # With this method we can pass additional options for each type of filter into the frontend. This is especially - # useful when we want to pass options for the autocompleter components. - # - # When the method is overwritten in a subclass, the subclass should call super(filter) to get the default attributes. - # - # @param filter [QueryFilter] the filter for which we want to pass additional attributes - # @return [Hash] the additional attributes for the filter, that will be yielded in the each_filter method - def additional_filter_attributes(filter) - case filter - when Queries::Filters::Shared::ProjectFilter - { - autocomplete_options: { - component: "opce-project-autocompleter", - resource: "projects", - filters: [ - { name: "active", operator: "=", values: ["t"] } - ] - } - } - else - {} - end - end -end diff --git a/app/components/members/index_page_header_component.html.erb b/app/components/members/index_page_header_component.html.erb index d8742fbaff84..f86519661c10 100644 --- a/app/components/members/index_page_header_component.html.erb +++ b/app/components/members/index_page_header_component.html.erb @@ -2,26 +2,5 @@ render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { page_title } header.with_breadcrumbs(breadcrumb_items, selected_item_font_weight: current_breadcrumb_element == page_title ? :bold : :normal) - - header.with_action_button(scheme: :primary, - mobile_icon: :plus, - mobile_label: t('activerecord.models.member'), - size: :medium, - aria: { label: I18n.t(:button_add_member) }, - title: I18n.t(:button_add_member), - id: "add-member-button", - data: add_button_data_attributes) do |button| - button.with_leading_visual_icon(icon: :plus) - t('activerecord.models.member') - end - - header.with_action_icon_button(mobile_icon: "filter", - scheme: :default, - icon: "filter", - label: I18n.t(:description_filter), - id: "filter-member-button", - aria: { label: I18n.t(:description_filter) }, - class: "toggle-member-filter-link", - data: filter_button_data_attributes) end %> diff --git a/app/components/members/index_page_header_component.rb b/app/components/members/index_page_header_component.rb index 6c6df07280c1..907a6cc2f4e3 100644 --- a/app/components/members/index_page_header_component.rb +++ b/app/components/members/index_page_header_component.rb @@ -38,25 +38,6 @@ def initialize(project: nil) @project = project end - def add_button_data_attributes - attributes = { - "members-form-target": "addMemberButton", - action: "members-form#showAddMemberForm", - "test-selector": "member-add-button" - } - - attributes["trigger-initially"] = "true" if params[:show_add_members] - - attributes - end - - def filter_button_data_attributes - { - "members-form-target": "filterMemberButton", - action: "members-form#toggleMemberFilter" - } - end - def breadcrumb_items [{ href: project_overview_path(@project.id), text: @project.name }, { href: project_members_path(@project), text: t(:label_member_plural) }, diff --git a/app/components/members/index_sub_header_component.html.erb b/app/components/members/index_sub_header_component.html.erb new file mode 100644 index 000000000000..d982cdf7950a --- /dev/null +++ b/app/components/members/index_sub_header_component.html.erb @@ -0,0 +1,22 @@ +<%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_filter_button(label: I18n.t(:description_filter), + id: "filter-member-button", + aria: { label: I18n.t(:description_filter) }, + class: "toggle-member-filter-link", + data: filter_button_data_attributes) do + I18n.t(:description_filter) + end + + subheader.with_action_button(scheme: :primary, + aria: { label: I18n.t(:button_add_member) }, + title: I18n.t(:button_add_member), + id: "add-member-button", + data: add_button_data_attributes) do |button| + button.with_leading_visual_icon(icon: :plus) + t('activerecord.models.member') + end + + subheader.with_bottom_pane_component do + render ::Members::UserFilterComponent.new(params, **@members_filter_options) + end +end %> diff --git a/app/components/members/index_sub_header_component.rb b/app/components/members/index_sub_header_component.rb new file mode 100644 index 000000000000..1ee814e243fb --- /dev/null +++ b/app/components/members/index_sub_header_component.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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 Members + # rubocop:disable OpenProject/AddPreviewForViewComponent + class IndexSubHeaderComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + + def initialize(project: nil, filter_options: nil) + super + @project = project + @members_filter_options = filter_options + end + + def add_button_data_attributes + attributes = { + "members-form-target": "addMemberButton", + action: "members-form#showAddMemberForm", + "test-selector": "member-add-button" + } + + attributes["trigger-initially"] = "true" if params[:show_add_members] + + attributes + end + + def filter_button_data_attributes + { + "members-form-target": "filterMemberButton", + action: "members-form#toggleMemberFilter" + } + end + end +end diff --git a/app/components/projects/index_sub_header_component.html.erb b/app/components/projects/index_sub_header_component.html.erb new file mode 100644 index 000000000000..bd09217edd2c --- /dev/null +++ b/app/components/projects/index_sub_header_component.html.erb @@ -0,0 +1,23 @@ +<%= render(Primer::OpenProject::SubHeader.new(data: { + controller: "filters", + "application-target": "dynamic", + })) do |subheader| + subheader.with_filter_component do + render(Filter::FilterButtonComponent.new(query: @query, disabled: @disable_buttons)) + end + + subheader.with_action_button(tag: :a, + href: new_project_path, + scheme: :primary, + disabled: @disable_buttons, + size: :medium, + aria: { label: I18n.t(:label_project_new) }, + data: { 'test-selector': 'project-new-button' }) do |button| + button.with_leading_visual_icon(icon: :plus) + Project.model_name.human + end if @current_user.allowed_globally?(:add_project) + + subheader.with_bottom_pane_component(mt: 0) do + render(Projects::ProjectsFiltersComponent.new(query: @query)) + end + end %> diff --git a/app/components/projects/index_sub_header_component.rb b/app/components/projects/index_sub_header_component.rb new file mode 100644 index 000000000000..cbd16be7fa47 --- /dev/null +++ b/app/components/projects/index_sub_header_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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 Projects + # rubocop:disable OpenProject/AddPreviewForViewComponent + class IndexSubHeaderComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + + def initialize(query:, current_user:, disable_buttons: nil) + super + @query = query + @current_user = current_user + @disable_buttons = disable_buttons + end + end +end diff --git a/app/components/projects/projects_filters_component.rb b/app/components/projects/projects_filters_component.rb index 450e2545aa77..e467c186dd1f 100644 --- a/app/components/projects/projects_filters_component.rb +++ b/app/components/projects/projects_filters_component.rb @@ -28,7 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. # ++ -class Projects::ProjectsFiltersComponent < FiltersComponent +# rubocop:disable OpenProject/AddPreviewForViewComponent +class Projects::ProjectsFiltersComponent < Filter::FilterComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent def allowed_filters super .select { |f| allowed_filter?(f) } diff --git a/app/components/projects/row_component.rb b/app/components/projects/row_component.rb index 34c5475e7a70..d3a8c9b6f32c 100644 --- a/app/components/projects/row_component.rb +++ b/app/components/projects/row_component.rb @@ -199,6 +199,8 @@ def additional_css_class(column) "project--hierarchy #{project.archived? ? 'archived' : ''}" elsif %i[status_explanation description].include?(column.attribute) "project-long-text-container" + elsif column.attribute == :favored + "-w-abs-45" elsif custom_field_column?(column) cf = column.custom_field formattable = cf.field_format == "text" ? " project-long-text-container" : "" diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb index 5da79313acf2..3c31b3294e78 100644 --- a/app/components/projects/settings/project_custom_field_sections/index_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -1,23 +1,23 @@ <%= component_wrapper(data: wrapper_data_attributes) do flex_layout do |flex| - flex.with_row(mt: 3) do - render(Primer::Alpha::TextField.new( - name: "project-custom-fields-mapping-filter", - label: t('projects.settings.project_custom_fields.filter.label'), - visually_hide_label: true, - placeholder: t('projects.settings.project_custom_fields.filter.label'), - leading_visual: { - icon: :search, - size: :small - }, - show_clear_button: true, - clear_button_id: "project-custom-fields-mapping-filter-clear-button", - data: { - action: "input->projects--settings--project-custom-fields-mapping-filter#filterLists", - "projects--settings--project-custom-fields-mapping-filter-target": "filter" - } - )) + flex.with_row do + render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_filter_input(name: "project-custom-fields-mapping-filter", + label: t('projects.settings.project_custom_fields.filter.label'), + visually_hide_label: true, + placeholder: t('projects.settings.project_custom_fields.filter.label'), + leading_visual: { + icon: :search, + size: :small + }, + show_clear_button: true, + clear_button_id: "project-custom-fields-mapping-filter-clear-button", + data: { + action: "input->projects--settings--project-custom-fields-mapping-filter#filterLists", + "projects--settings--project-custom-fields-mapping-filter-target": "filter" + }) + end end @project_custom_field_sections.each do |project_custom_field_section| diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb index 33c01da2fde6..727d8901228b 100644 --- a/app/components/projects/settings/project_custom_field_sections/show_component.html.erb +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -1,6 +1,6 @@ <%= component_wrapper do - render(border_box_container(mt: 3, classes: 'op-project-custom-field-section', data: { + render(border_box_container(mb: 3, classes: 'op-project-custom-field-section', data: { test_selector: "project-custom-field-section-#{@project_custom_field_section.id}" })) do |component| component.with_header(font_weight: :bold, py: 2) do diff --git a/app/components/projects/table_component.html.erb b/app/components/projects/table_component.html.erb index a9628a3a672e..9746443263a5 100644 --- a/app/components/projects/table_component.html.erb +++ b/app/components/projects/table_component.html.erb @@ -56,16 +56,22 @@ See COPYRIGHT and LICENSE files for more details. <% elsif sortable_column?(column) %> <%= build_sort_header column.attribute, order_options(column) %> + <% elsif column.attribute == :favored %> + +
+
+ + <%= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, "aria-label": I18n.t(:label_favorite))) %> + +
+
+ <% else %>
- <% if column.attribute == :favored %> - <%= render(Primer::Beta::Octicon.new(icon: "star-fill", color: :subtle, ml: 2, "aria-label": I18n.t(:label_favorite))) %> - <% else %> - <%= column.caption %> - <% end %> + <%= column.caption %>
diff --git a/app/components/settings/project_custom_fields/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb index d256f4bbed2d..d05b23f37dd9 100644 --- a/app/components/settings/project_custom_fields/header_component.html.erb +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -1,32 +1,36 @@ -<% button_block = lambda do |button| - button.with_leading_visual_icon(icon: :plus) - t('settings.project_attributes.label_new_section') -end %> - -<%= - component_wrapper do +<%= component_wrapper do %> + <%= render Primer::OpenProject::PageHeader.new do |header| header.with_title(variant: :default) { t("settings.project_attributes.heading") } header.with_description { t("settings.project_attributes.heading_description") } header.with_breadcrumbs(breadcrumbs_items) - header.with_action_button(tag: :a, - href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), - scheme: :primary, - data: { turbo: "false", test_selector: "new-project-custom-field-button" }, - mobile_icon: :plus, - mobile_label: t("settings.project_attributes.label_new_attribute")) do |button| + end + %> + + <%= + render Primer::OpenProject::SubHeader.new do |subheader| + subheader.with_action_component do + render(Primer::Alpha::Dialog.new(id: "project-custom-field-section-dialog", title: t('settings.project_attributes.label_new_section'), size: :medium_portrait)) do |dialog| + dialog.with_show_button('aria-label': t('settings.project_attributes.label_new_section')) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_section') + end + dialog.with_body do + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new) + end + end + end + + subheader.with_action_button(tag: :a, + href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), + scheme: :primary, + data: { turbo: "false", test_selector: "new-project-custom-field-button" }, + mobile_icon: :plus, + mobile_label: t("settings.project_attributes.label_new_attribute")) do |button| button.with_leading_visual_icon(icon: :plus) t("settings.project_attributes.label_new_attribute") end - header.with_action_dialog(mobile_icon: :plus, - mobile_label: t('settings.project_attributes.label_new_section'), - dialog_arguments: {id: "project-custom-field-section-dialog", title: t('settings.project_attributes.label_new_section', size: :medium_portrait), size: :medium_portrait}, - button_arguments: {'aria-label': t('settings.project_attributes.label_new_section'), button_block: button_block}) do |dialog| - dialog.with_body do - render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new) - end - end end - end -%> + %> +<% end %> diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.html.erb b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.html.erb new file mode 100644 index 000000000000..817a44612ec6 --- /dev/null +++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.html.erb @@ -0,0 +1,42 @@ +<%= + component_wrapper do + render( + Primer::Alpha::Dialog.new( + id: dialog_id, + title:, + test_selector: dialog_id, + size: :large + ) + ) do |dialog| + dialog.with_show_button(scheme: :primary) do |button| + button.with_leading_visual_icon(icon: 'op-include-projects') + show_button_text + end + + dialog.with_header( + show_divider: false, + visually_hide_title: false + ) + + primer_form_with( + class: "op-new-project-mapping-form", + model: @project_mapping, + url: link_admin_settings_project_custom_field_path(@project_custom_field), + data: { turbo: true }, + method: :post + ) do |form| + concat(render(Primer::Alpha::Dialog::Body.new( + id: dialog_body_id, test_selector: dialog_body_id, aria: { label: title }, + style: "min-height: 300px" + )) do + render(Projects::CustomFields::CustomFieldMappingForm.new(form, project_custom_field: @project_custom_field)) + end) + + concat(render(Primer::Alpha::Dialog::Footer.new(show_divider: false)) do + concat(render(Primer::ButtonComponent.new(data: { 'close-dialog-id': dialog_id })) { cancel_button_text }) + concat(render(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) { submit_button_text }) + end) + end + end + end +%> diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb new file mode 100644 index 000000000000..b6b8e71b9bb8 --- /dev/null +++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.rb @@ -0,0 +1,68 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 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 Settings + module ProjectCustomFields + module ProjectCustomFieldMapping + class NewProjectMappingComponent < ApplicationComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + include OpTurbo::Streamable + + options dialog_id: "settings--new-project-custom-field-mapping-component", + dialog_body_id: "settings--new-project-custom-field-mapping-body-component" + + def initialize(project_mapping:, project_custom_field:, **) + @project_mapping = project_mapping + @project_custom_field = project_custom_field + super(@project_mapping, **) + end + + def render? + !@project_custom_field.required? + end + + private + + def title + I18n.t("projects.settings.project_custom_fields.new_project_mapping_form.add_projects") + end + + def show_button_text + I18n.t("projects.settings.project_custom_fields.new_project_mapping_form.add_projects") + end + + def cancel_button_text + I18n.t("button_cancel") + end + + def submit_button_text + I18n.t("button_add") + end + end + end + end +end diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.sass b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.sass new file mode 100644 index 000000000000..76511b9c5b3e --- /dev/null +++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/new_project_mapping_component.sass @@ -0,0 +1,8 @@ +@import 'helpers' + +.op-new-project-mapping-form + .ng-placeholder + @extend .icon-search + &:before + @include icon-font-common + margin-right: 10px diff --git a/app/components/settings/project_custom_fields/project_custom_field_mapping/row_component.html.erb b/app/components/settings/project_custom_fields/project_custom_field_mapping/row_component.html.erb index 097b38764a0a..c92f886b908c 100644 --- a/app/components/settings/project_custom_fields/project_custom_field_mapping/row_component.html.erb +++ b/app/components/settings/project_custom_fields/project_custom_field_mapping/row_component.html.erb @@ -27,7 +27,14 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= component_wrapper(tag: "tr", class: row_css_class, data: { turbo: true }) do %> +<% + wrapper_data_attributes = { + turbo: true, + "projects--settings--available-project-mappings-filter-target": "searchItem" + } +%> + +<%= component_wrapper(tag: "tr", class: row_css_class, data: wrapper_data_attributes) do %> <% columns.each do |column| %> <%= column_value(column) %> diff --git a/app/components/statuses/row_component.rb b/app/components/statuses/row_component.rb index 8c2f1d8d6b6c..0533192cbb81 100644 --- a/app/components/statuses/row_component.rb +++ b/app/components/statuses/row_component.rb @@ -50,6 +50,10 @@ def readonly? checkmark(status.is_readonly?) end + def excluded_from_totals? + checkmark(status.excluded_from_totals?) + end + def color helpers.icon_for_color status.color end diff --git a/app/components/statuses/table_component.rb b/app/components/statuses/table_component.rb index b0c5dbadf554..240cf1490e55 100644 --- a/app/components/statuses/table_component.rb +++ b/app/components/statuses/table_component.rb @@ -60,9 +60,10 @@ def headers [:name, { caption: Status.human_attribute_name(:name) }], [:color, { caption: Status.human_attribute_name(:color) }], [:done_ratio, { caption: WorkPackage.human_attribute_name(:done_ratio) }], - [:default?, { caption: Status.human_attribute_name(:is_default) }], - [:closed?, { caption: Status.human_attribute_name(:is_closed) }], - [:readonly?, { caption: Status.human_attribute_name(:is_readonly) }], + [:default?, { caption: I18n.t("statuses.index.headers.is_default") }], + [:closed?, { caption: I18n.t("statuses.index.headers.is_closed") }], + [:readonly?, { caption: I18n.t("statuses.index.headers.is_readonly") }], + [:excluded_from_totals?, { caption: I18n.t("statuses.index.headers.excluded_from_totals") }], [:sort, { caption: I18n.t(:label_sort) }] ] end diff --git a/app/components/work_packages/share/share_counter_component.html.erb b/app/components/work_packages/share/share_counter_component.html.erb index 3b5b5c6f2ce4..855f37330382 100644 --- a/app/components/work_packages/share/share_counter_component.html.erb +++ b/app/components/work_packages/share/share_counter_component.html.erb @@ -1,5 +1,3 @@ <% - concat(render(Primer::Beta::Octicon.new(icon: 'person'))) - - concat(render(Primer::Beta::Text.new(ml: 2)) { I18n.t('work_package.sharing.count', count:) }) + concat(render(Primer::Beta::Text.new) { I18n.t('work_package.sharing.count', count:) }) %> diff --git a/app/contracts/settings/working_days_and_hours_params_contract.rb b/app/contracts/settings/working_days_and_hours_params_contract.rb new file mode 100644 index 000000000000..c6dbd9864c48 --- /dev/null +++ b/app/contracts/settings/working_days_and_hours_params_contract.rb @@ -0,0 +1,119 @@ +#-- 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 Settings + class WorkingDaysAndHoursParamsContract < ::ParamsContract + include RequiresAdminGuard + + validate :working_days_are_present + validate :hours_per_day_are_present + validate :days_per_week_are_present + validate :days_per_month_are_present + validate :durations_are_positive_values + validate :durations_are_within_bounds + validate :days_per_week_and_days_per_month_are_consistent + validate :unique_job + + protected + + def working_days_are_present + if working_days.blank? + errors.add :base, :working_days_are_missing + end + end + + def hours_per_day_are_present + if hours_per_day.blank? + errors.add :base, :hours_per_day_are_missing + end + end + + def days_per_week_are_present + if days_per_week.blank? + errors.add :base, :days_per_week_are_missing + end + end + + def days_per_month_are_present + if days_per_month.blank? + errors.add :base, :days_per_month_are_missing + end + end + + def durations_are_positive_values + if hours_per_day && + days_per_week && + days_per_month && + any_duration_is_negative_or_zero? + errors.add :base, :durations_are_not_positive_numbers + end + end + + def durations_are_within_bounds + errors.add :base, :hours_per_day_is_out_of_bounds if hours_per_day.to_i > 24 + errors.add :base, :days_per_week_is_out_of_bounds if days_per_week.to_i > 7 + errors.add :base, :days_per_month_is_out_of_bounds if days_per_month.to_i > 31 + end + + def any_duration_is_negative_or_zero? + !hours_per_day.to_i.positive? || + !days_per_week.to_i.positive? || + !days_per_month.to_i.positive? + end + + def days_per_week_and_days_per_month_are_consistent + if days_per_week && + days_per_month && + days_per_week.to_i != days_per_month.to_i / ChronicDuration::FULL_WEEKS_PER_MONTH + errors.add :base, :days_per_week_and_days_per_month_are_inconsistent + end + end + + def unique_job + WorkPackages::ApplyWorkingDaysChangeJob.new.check_concurrency do + errors.add :base, :previous_working_day_changes_unprocessed + end + end + + def working_days + params[:working_days] + end + + def hours_per_day + params[:hours_per_day] + end + + def days_per_week + params[:days_per_week] + end + + def days_per_month + params[:days_per_month] + end + end +end diff --git a/app/controllers/admin/settings/project_custom_fields_controller.rb b/app/controllers/admin/settings/project_custom_fields_controller.rb index 82271cbc4901..8844d1b4f72c 100644 --- a/app/controllers/admin/settings/project_custom_fields_controller.rb +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -39,10 +39,11 @@ class ProjectCustomFieldsController < ::Admin::SettingsController # rubocop:disable Rails/LexicallyScopedActionFilter before_action :set_sections, only: %i[show index edit update move drop] before_action :find_custom_field, - only: %i(show edit project_mappings unlink update destroy delete_option reorder_alphabetical move drop) + only: %i(show edit project_mappings link unlink update destroy delete_option reorder_alphabetical move drop) before_action :prepare_custom_option_position, only: %i(update create) before_action :find_custom_option, only: :delete_option before_action :project_custom_field_mappings_query, only: %i[project_mappings unlink] + before_action :find_link_project_custom_field_mapping, only: :link before_action :find_unlink_project_custom_field_mapping, only: :unlink # rubocop:enable Rails/LexicallyScopedActionFilter @@ -68,14 +69,34 @@ def new def edit; end - def project_mappings; end + def project_mappings + @project_mapping = ProjectCustomFieldProjectMapping.new(project_custom_field: @custom_field) + end + + def link + create_service = ProjectCustomFieldProjectMappings::BulkCreateService + .new(user: current_user, project: @project, project_custom_field: @custom_field, + include_sub_projects: include_sub_projects?) + .call + + create_service.on_success { render_project_list } + + create_service.on_failure do + update_flash_message_via_turbo_stream( + message: join_flash_messages(create_service.errors.full_messages), + full: true, dismiss_scheme: :hide, scheme: :danger + ) + end + + respond_to_with_turbo_streams(status: create_service.success? ? :ok : :unprocessable_entity) + end def unlink delete_service = ProjectCustomFieldProjectMappings::DeleteService .new(user: current_user, model: @project_custom_field_mapping) .call - delete_service.on_success { render_unlink_response } + delete_service.on_success { render_project_list } delete_service.on_failure do update_flash_message_via_turbo_stream( @@ -126,13 +147,18 @@ def destroy private - def render_unlink_response + def render_project_list + update_via_turbo_stream( + component: Settings::ProjectCustomFields::ProjectCustomFieldMapping::NewProjectMappingComponent.new( + project_mapping: ProjectCustomFieldProjectMapping.new(project_custom_field: @custom_field), + project_custom_field: @custom_field + ) + ) update_via_turbo_stream( component: Settings::ProjectCustomFields::ProjectCustomFieldMapping::TableComponent.new( - query: @project_custom_field_mappings_query, + query: project_custom_field_mappings_query, params: { custom_field: @custom_field } - ), - status: :ok + ) ) end @@ -169,6 +195,17 @@ def find_unlink_project_custom_field_mapping respond_with_turbo_streams end + def find_link_project_custom_field_mapping + @project = Project.find(permitted_params.project_custom_field_project_mapping[:project_id]) + rescue ActiveRecord::RecordNotFound + update_flash_message_via_turbo_stream( + message: t(:notice_file_not_found), full: true, dismiss_scheme: :hide, scheme: :danger + ) + render_project_list + + respond_with_turbo_streams + end + def find_custom_field @custom_field = ProjectCustomField.find(params[:id]) rescue ActiveRecord::RecordNotFound @@ -181,5 +218,9 @@ def drop_success_streams(call) update_section_via_turbo_stream(project_custom_field_section: call.result[:old_section]) end end + + def include_sub_projects? + ActiveRecord::Type::Boolean.new.cast(permitted_params.project_custom_field_project_mapping[:include_sub_projects]) + end end end diff --git a/app/controllers/admin/settings/working_days_settings_controller.rb b/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb similarity index 85% rename from app/controllers/admin/settings/working_days_settings_controller.rb rename to app/controllers/admin/settings/working_days_and_hours_settings_controller.rb index fa0d390f7e49..b0bd50d11abd 100644 --- a/app/controllers/admin/settings/working_days_settings_controller.rb +++ b/app/controllers/admin/settings/working_days_and_hours_settings_controller.rb @@ -27,11 +27,11 @@ #++ module Admin::Settings - class WorkingDaysSettingsController < ::Admin::SettingsController - menu_item :working_days + class WorkingDaysAndHoursSettingsController < ::Admin::SettingsController + menu_item :working_days_and_hours def default_breadcrumb - t(:label_working_days) + t(:label_working_days_and_hours) end def failure_callback(call) @@ -43,14 +43,14 @@ def failure_callback(call) protected def settings_params - settings = super - settings[:working_days] = working_days_params(settings) - settings[:non_working_days] = non_working_days_params - settings + super.tap do |settings| + settings[:working_days] = working_days_params(settings) + settings[:non_working_days] = non_working_days_params + end end def update_service - ::Settings::WorkingDaysUpdateService + ::Settings::WorkingDaysAndHoursUpdateService end private diff --git a/app/controllers/statuses_controller.rb b/app/controllers/statuses_controller.rb index 74bd847206b0..c3b0e10784f1 100644 --- a/app/controllers/statuses_controller.rb +++ b/app/controllers/statuses_controller.rb @@ -61,7 +61,7 @@ def create def update @status = Status.find(params[:id]) if @status.update(permitted_params.status) - apply_status_p_complete_change + recompute_progress_values flash[:notice] = I18n.t(:notice_successful_update) redirect_to action: "index" else @@ -97,14 +97,16 @@ def show_local_breadcrumb true end - def apply_status_p_complete_change - return unless WorkPackage.use_status_for_done_ratio? - return unless @status.default_done_ratio_previously_changed? + def recompute_progress_values + attributes_triggering_recomputing = ["excluded_from_totals"] + attributes_triggering_recomputing << "default_done_ratio" if WorkPackage.use_status_for_done_ratio? + changes = @status.previous_changes.slice(*attributes_triggering_recomputing) + return if changes.empty? - WorkPackages::Progress::ApplyStatusesPCompleteJob - .perform_later(cause_type: "status_p_complete_changed", + WorkPackages::Progress::ApplyStatusesChangeJob + .perform_later(cause_type: "status_changed", status_name: @status.name, status_id: @status.id, - change: @status.default_done_ratio_previous_change) + changes:) end end diff --git a/app/controllers/work_packages/progress_controller.rb b/app/controllers/work_packages/progress_controller.rb index a1024129733d..d9e5cfa196a4 100644 --- a/app/controllers/work_packages/progress_controller.rb +++ b/app/controllers/work_packages/progress_controller.rb @@ -130,7 +130,17 @@ def extract_persisted_progress_attributes def work_package_params params.require(:work_package) - .permit(allowed_params) + .permit(allowed_params).tap do |wp_params| + %w[estimated_hours remaining_hours].each do |attr| + if wp_params[attr].present? + begin + wp_params[attr] = DurationConverter.parse(wp_params[attr]) + rescue ChronicDuration::DurationParseError + @work_package.errors.add(attr.to_sym, :invalid) + end + end + end + end end def allowed_params @@ -141,12 +151,6 @@ def allowed_params end end - def reject_params_that_dont_differ_from_persisted_values - work_package_params.reject do |key, value| - @persisted_progress_attributes[key.to_s].to_f.to_s == value.to_f.to_s - end - end - def filtered_work_package_params {}.tap do |filtered_params| filtered_params[:estimated_hours] = work_package_params["estimated_hours"] if estimated_hours_touched? diff --git a/app/forms/projects/custom_fields/custom_field_mapping_form.rb b/app/forms/projects/custom_fields/custom_field_mapping_form.rb new file mode 100644 index 000000000000..2d0501ee2c4d --- /dev/null +++ b/app/forms/projects/custom_fields/custom_field_mapping_form.rb @@ -0,0 +1,69 @@ +#-- 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 Projects::CustomFields + class CustomFieldMappingForm < ApplicationForm + form do |f| + f.group(layout: :horizontal) do |f_group| + f_group.project_autocompleter( + name: :id, + label: Project.model_name.human, + visually_hide_label: true, + autocomplete_options: { + openDirectly: false, + focusDirectly: false, + dropdownPosition: "bottom", + disabledProjects: projects_with_custom_field_mapping, + inputName: "project_custom_field_project_mapping[project_id]" + } + ) + + f_group.check_box( + name: :include_sub_projects, + label: I18n.t("projects.settings.project_custom_fields.new_project_mapping_form.include_sub_projects"), + checked: false, + label_arguments: { class: "no-wrap" } + ) + end + end + + def initialize(project_custom_field:) + super() + @project_custom_field = project_custom_field + end + + private + + def projects_with_custom_field_mapping + ProjectCustomFieldProjectMapping + .where(project_custom_field: @project_custom_field) + .pluck(:project_id) + .to_h { |id| [id, id] } + end + end +end diff --git a/app/forms/work_packages/progress_form.rb b/app/forms/work_packages/progress_form.rb index baf36ce3d0ea..0e662e9b177e 100644 --- a/app/forms/work_packages/progress_form.rb +++ b/app/forms/work_packages/progress_form.rb @@ -97,7 +97,7 @@ def initialize(work_package:, def ensure_only_one_error_for_remaining_work_exceeding_work if work_package.errors.added?(:remaining_hours, :cant_exceed_work) && - work_package.errors.added?(:estimated_hours, :cant_be_inferior_to_remaining_work) + work_package.errors.added?(:estimated_hours, :cant_be_inferior_to_remaining_work) error_to_delete = if @focused_field == :estimated_hours :remaining_hours @@ -154,10 +154,12 @@ def render_readonly_text_field(group, def field_value(name) errors = @work_package.errors.where(name) - if user_value = errors.map { |error| error.options[:value] }.find { !_1.nil? } + if (user_value = errors.map { |error| error.options[:value] }.find { !_1.nil? }) user_value - else + elsif name == :done_ratio format_to_smallest_fractional_part(@work_package.public_send(name)) + else + DurationConverter.output(@work_package.public_send(name)) end end diff --git a/app/models/exports/formatters/custom_field.rb b/app/models/exports/formatters/custom_field.rb index 5815fb2a18ff..ef55bd0aff25 100644 --- a/app/models/exports/formatters/custom_field.rb +++ b/app/models/exports/formatters/custom_field.rb @@ -1,12 +1,12 @@ module Exports module Formatters class CustomField < Default - def self.apply?(attribute, _export_format) - attribute.start_with?("cf_") + def self.apply?(attribute, export_format) + export_format != :pdf && attribute.start_with?("cf_") end ## - # Takes a WorkPackage and an attribute and returns the value to be exported. + # Takes a WorkPackage or Project and an attribute and returns the value to be exported. def retrieve_value(object) custom_field = find_custom_field(object) return "" if custom_field.nil? diff --git a/app/models/exports/formatters/custom_field_pdf.rb b/app/models/exports/formatters/custom_field_pdf.rb new file mode 100644 index 000000000000..a299f785a169 --- /dev/null +++ b/app/models/exports/formatters/custom_field_pdf.rb @@ -0,0 +1,27 @@ +module Exports + module Formatters + class CustomFieldPdf < CustomField + def self.apply?(attribute, export_format) + export_format == :pdf && attribute.start_with?("cf_") + end + + ## + # Print the value meant for export. + # + # - For boolean values, use the Yes/No formatting for the PDF + # treat nil as false + # - For long text values, output the plain value + def format_for_export(object, custom_field) + case custom_field.field_format + when "bool" + value = object.typed_custom_value_for(custom_field) + value ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + when "text" + object.typed_custom_value_for(custom_field) + else + object.formatted_custom_value_for(custom_field) + end + end + end + end +end diff --git a/app/models/journal.rb b/app/models/journal.rb index fb5f3f329a38..1a9229a397a3 100644 --- a/app/models/journal.rb +++ b/app/models/journal.rb @@ -71,13 +71,13 @@ class Journal < ApplicationRecord changed_days status_name status_id - status_p_complete_change + status_changes ], prefix: true VALID_CAUSE_TYPES = %w[ default_attribute_written progress_mode_changed_to_status_based - status_p_complete_changed + status_changed system_update work_package_children_changed_times work_package_parent_changed_times diff --git a/app/models/journal/caused_by_status_p_complete_changed.rb b/app/models/journal/caused_by_status_changed.rb similarity index 84% rename from app/models/journal/caused_by_status_p_complete_changed.rb rename to app/models/journal/caused_by_status_changed.rb index da648aca57d6..4e28458fbc75 100644 --- a/app/models/journal/caused_by_status_p_complete_changed.rb +++ b/app/models/journal/caused_by_status_changed.rb @@ -26,14 +26,14 @@ # See COPYRIGHT and LICENSE files for more details. #++ # -class Journal::CausedByStatusPCompleteChanged < CauseOfChange::Base - def initialize(status_name:, status_id:, status_p_complete_change:) +class Journal::CausedByStatusChanged < CauseOfChange::Base + def initialize(status_name:, status_id:, status_changes:) additional = { "status_name" => status_name, "status_id" => status_id, - "status_p_complete_change" => status_p_complete_change + "status_changes" => status_changes } - super("status_p_complete_changed", additional) + super("status_changed", additional) end end diff --git a/app/models/notifications/scopes/unsent_reminders_before.rb b/app/models/notifications/scopes/unsent_reminders_before.rb index 7fb881482109..4434f0b5032a 100644 --- a/app/models/notifications/scopes/unsent_reminders_before.rb +++ b/app/models/notifications/scopes/unsent_reminders_before.rb @@ -36,8 +36,9 @@ module UnsentRemindersBefore def unsent_reminders_before(recipient:, time:) where(Notification.arel_table[:created_at].lteq(time)) .where(recipient:) - .where("read_ian IS NULL OR read_ian IS FALSE") + .where(read_ian: [false, nil]) .where(mail_reminder_sent: false) + .where(mail_alert_sent: [false, nil]) end end end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 241c79b26db5..fd947aef170d 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -563,6 +563,7 @@ def self.permitted_attributes project_id custom_field_id custom_field_section_id + include_sub_projects ), query: %i( name @@ -593,6 +594,7 @@ def self.permitted_attributes name color_id default_done_ratio + excluded_from_totals is_closed is_default is_readonly diff --git a/app/models/projects/exports/formatters/active.rb b/app/models/projects/exports/formatters/active.rb new file mode 100644 index 000000000000..93a78add3542 --- /dev/null +++ b/app/models/projects/exports/formatters/active.rb @@ -0,0 +1,42 @@ +#-- 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 Projects::Exports + module Formatters + class Active < ::Exports::Formatters::Default + def self.apply?(attribute, export_format) + export_format == :pdf && %i[active].include?(attribute.to_sym) + end + + ## + # Takes a project and returns yes/no depending on the active attribute + def format(project, **) + project.active? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + end + end + end +end diff --git a/app/models/projects/exports/formatters/public.rb b/app/models/projects/exports/formatters/public.rb new file mode 100644 index 000000000000..deb4deb03011 --- /dev/null +++ b/app/models/projects/exports/formatters/public.rb @@ -0,0 +1,42 @@ +#-- 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 Projects::Exports + module Formatters + class Public < ::Exports::Formatters::Default + def self.apply?(attribute, export_format) + export_format == :pdf && %i[public].include?(attribute.to_sym) + end + + ## + # Takes a project and returns yes/no depending on the public attribute + def format(project, **) + project.public? ? I18n.t(:general_text_Yes) : I18n.t(:general_text_No) + end + end + end +end diff --git a/app/models/work_package.rb b/app/models/work_package.rb index b60f013478b4..0d32c30f11aa 100644 --- a/app/models/work_package.rb +++ b/app/models/work_package.rb @@ -294,6 +294,10 @@ def milestone? alias_method :is_milestone?, :milestone? + def included_in_totals_calculation? + !status.excluded_from_totals + end + def done_ratio if WorkPackage.use_status_for_done_ratio? && status && status.default_done_ratio status.default_done_ratio diff --git a/app/models/work_package/exports/macros/attributes.rb b/app/models/work_package/exports/macros/attributes.rb new file mode 100644 index 000000000000..8b50adfa503e --- /dev/null +++ b/app/models/work_package/exports/macros/attributes.rb @@ -0,0 +1,172 @@ +#-- 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 WorkPackage::Exports + module Macros + # OpenProject attribute macros syntax + # Examples: + # workPackageLabel:subject # Outputs work package label attribute "Subject" + # workPackageLabel:1234:subject # Outputs work package label attribute "Subject" + + # workPackageValue:subject # Outputs the subject of the current work package + # workPackageValue:1234:subject # Outputs the subject of #1234 + # workPackageValue:"custom field name" # Outputs the custom field value of the current work package + # workPackageValue:1234:"custom field name" # Outputs the custom field value of #1234 + # + # projectLabel:active # Outputs current project label attribute "active" + # projectLabel:1234:active # Outputs project label attribute "active" + # projectLabel:my-project-identifier:active # Outputs project label attribute "active" + + # projectValue:active # Outputs current project value for "active" + # projectValue:1234:active # Outputs project with id 1234 value for "active" + # projectValue:my-project-identifier:active # Outputs project with identifier my-project-identifier value for "active" + class Attributes < OpenProject::TextFormatting::Matchers::RegexMatcher + DISABLED_PROJECT_RICH_TEXT_FIELDS = %i[description status_explanation status_description].freeze + DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS = %i[description].freeze + + def self.regexp + %r{ + (\w+)(Label|Value) # The model type we try to reference + (?::(?:([^"\s]+)|"([^"]+)"))? # Optional: An ID or subject reference + (?::([^"\s.]+|"([^".]+)")) # The attribute name we're trying to reference + }x + end + + ## + # Faster inclusion check before the regex is being applied + def self.applicable?(content) + content.include?("Label:") || content.include?("Value:") + end + + def self.process_match(match, _matched_string, context) + context => { user:, work_package: } + type = match[2].downcase + model_s = match[1] + id = match[4] || match[3] + attribute = match[6] || match[5] + resolve_match(type, model_s, id, attribute, work_package, user) + end + + def self.resolve_match(type, model_s, id, attribute, work_package, user) + if model_s == "workPackage" + resolve_work_package_match(id || work_package.id, type, attribute, user) + elsif model_s == "project" + resolve_project_match(id || work_package.project.id, type, attribute, user) + else + msg_macro_error I18n.t('export.macro.model_not_found', model: model_s) + end + end + + def self.msg_macro_error(message) + msg_inline I18n.t('export.macro.error', message:) + end + + def self.msg_macro_error_rich_text + msg_inline I18n.t('export.macro.rich_text_unsupported') + end + + def self.msg_inline(message) + "[#{message}]" + end + + def self.resolve_label_work_package(attribute) + resolve_label(WorkPackage, attribute) + end + + def self.resolve_label_project(attribute) + resolve_label(Project, attribute) + end + + def self.resolve_label(model, attribute) + model.human_attribute_name( + ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: model.new) + ) + end + + def self.resolve_work_package_match(id, type, attribute, user) + return resolve_label_work_package(attribute) if type == "label" + return msg_macro_error(I18n.t('export.macro.model_not_found', model: type)) unless type == "value" + + work_package = WorkPackage.visible(user).find_by(id:) + if work_package.nil? + return msg_macro_error(I18n.t('export.macro.resource_not_found', resource: "#{WorkPackage.name} #{id}")) + end + + resolve_value_work_package(work_package, attribute) + end + + def self.resolve_project_match(id, type, attribute, user) + return resolve_label_project(attribute) if type == "label" + return msg_macro_error(I18n.t("export.macro.model_not_found", model: type)) unless type == "value" + + project = Project.visible(user).find_by(id:) + project = Project.visible(user).find_by(identifier: id) if project.nil? + if project.nil? + return msg_macro_error(I18n.t("export.macro.resource_not_found", resource: "#{Project.name} #{id}")) + end + + resolve_value_project(project, attribute) + end + + def self.escape_tags(value) + # only disable html tags, but do not replace html entities + value.to_s.gsub("<", "<").gsub(">", ">") + end + + def self.resolve_value_project(project, attribute) + resolve_value(project, attribute, DISABLED_PROJECT_RICH_TEXT_FIELDS) + end + + def self.resolve_value_work_package(work_package, attribute) + resolve_value(work_package, attribute, DISABLED_WORK_PACKAGE_RICH_TEXT_FIELDS) + end + + def self.resolve_value(obj, attribute, disabled_rich_text_fields) + cf = obj.available_custom_fields.find { |pcf| pcf.name == attribute } + + return msg_macro_error_rich_text if cf&.formattable? + + ar_name = if cf.nil? + ::API::Utilities::PropertyNameConverter.to_ar_name(attribute.to_sym, context: obj) + else + "cf_#{cf.id}" + end + return msg_macro_error_rich_text if disabled_rich_text_fields.include?(ar_name.to_sym) + + format_attribute_value(ar_name, obj.class, obj) + end + + def self.format_attribute_value(ar_name, model, obj) + formatter = Exports::Register.formatter_for(model, ar_name, :pdf) + value = formatter.format(obj) + # important NOT to return empty string as this could change meaning of markdown + # e.g. **to_be_replaced** could be rendered as **** (horizontal line and a *) + value.blank? ? " " : escape_tags(value) + end + end + end +end diff --git a/app/models/work_package/journalized.rb b/app/models/work_package/journalized.rb index 2a8bac58a9aa..310e70c561c4 100644 --- a/app/models/work_package/journalized.rb +++ b/app/models/work_package/journalized.rb @@ -78,7 +78,7 @@ def self.event_url url: JournalizedProcs.event_url register_journal_formatted_fields(:id, "parent_id") - register_journal_formatted_fields(:fraction, + register_journal_formatted_fields(:chronic_duration, "estimated_hours", "derived_estimated_hours", "remaining_hours", "derived_remaining_hours") register_journal_formatted_fields(:percentage, "done_ratio", "derived_done_ratio") diff --git a/app/models/work_package/pdf_export/markdown_field.rb b/app/models/work_package/pdf_export/markdown_field.rb index 963396d30067..f5d45baa1dd5 100644 --- a/app/models/work_package/pdf_export/markdown_field.rb +++ b/app/models/work_package/pdf_export/markdown_field.rb @@ -28,6 +28,7 @@ module WorkPackage::PDFExport::MarkdownField include WorkPackage::PDFExport::Markdown + PREFORMATTED_BLOCKS = %w(pre code).freeze def write_markdown_field!(work_package, markdown, label) return if markdown.blank? @@ -37,7 +38,52 @@ def write_markdown_field!(work_package, markdown, label) pdf.formatted_text([styles.wp_markdown_label.merge({ text: label })]) end with_margin(styles.wp_markdown_margins) do - write_markdown! work_package, markdown + write_markdown! work_package, apply_markdown_field_macros(markdown, work_package) + end + end + + private + + def apply_markdown_field_macros(markdown, work_package) + apply_macros(markdown, work_package, WorkPackage::Exports::Macros::Attributes) + end + + def apply_macros(markdown, work_package, formatter) + return markdown unless formatter.applicable?(markdown) + + document = Markly.parse(markdown) + document.walk do |node| + if node.type == :html + node.string_content = apply_macro_html(node.string_content, work_package, formatter) || node.string_content + elsif node.type == :text + node.string_content = apply_macro_text(node.string_content, work_package, formatter) || node.string_content + end + end + document.to_markdown + end + + def apply_macro_text(text, work_package, formatter) + return text unless formatter.applicable?(text) + + text.gsub!(formatter.regexp) do |matched_string| + matchdata = Regexp.last_match + formatter.process_match(matchdata, matched_string, { user: User.current, work_package: }) + end + end + + def apply_macro_html(html, work_package, formatter) + return html unless formatter.applicable?(html) + + doc = Nokogiri::HTML.fragment(html) + apply_macro_html_node(doc, work_package, formatter) + doc.to_html + end + + def apply_macro_html_node(node, work_package, formatter) + if node.text? + node.content = apply_macro_text(node.content, work_package, formatter) + elsif PREFORMATTED_BLOCKS.exclude?(node.name) + node.children.each { |child| apply_macro_html_node(child, work_package, formatter) } end end end diff --git a/app/models/work_package/pdf_export/work_package_detail.rb b/app/models/work_package/pdf_export/work_package_detail.rb index 2d4aae33e8e7..632ea724c8fc 100644 --- a/app/models/work_package/pdf_export/work_package_detail.rb +++ b/app/models/work_package/pdf_export/work_package_detail.rb @@ -135,15 +135,19 @@ def form_configuration_columns(work_package) end.flatten end - def form_key_to_column_entries(form_key, work_package) - if CustomField.custom_field_attribute? form_key - id = form_key.to_s.sub("custom_field_", "").to_i - cf = CustomField.find_by(id:) - return [] if cf.nil? || cf.formattable? + def form_key_custom_field_to_column_entries(form_key, work_package) + id = form_key.to_s.sub("custom_field_", "").to_i + cf = CustomField.find_by(id:) + return [] if cf.nil? || cf.formattable? + + return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id) - return [] unless cf.is_for_all? || work_package.project.work_package_custom_field_ids.include?(cf.id) + [{ label: cf.name || form_key, name: form_key.to_s.sub("custom_field_", "cf_") }] + end - return [{ label: cf.name || form_key, name: form_key }] + def form_key_to_column_entries(form_key, work_package) + if CustomField.custom_field_attribute? form_key + return form_key_custom_field_to_column_entries(form_key, work_package) end if form_key == :date diff --git a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb index 7de6d091f45a..f933b5f6ee7d 100644 --- a/app/models/work_package/pdf_export/work_package_list_to_pdf.rb +++ b/app/models/work_package/pdf_export/work_package_list_to_pdf.rb @@ -73,7 +73,7 @@ def export! rescue Prawn::Errors::CannotFit error(I18n.t(:error_pdf_export_too_many_columns)) rescue StandardError => e - Rails.logger.error { "Failed to generated PDF export: #{e}." } + Rails.logger.error { "Failed to generate PDF export: #{e}." } error(I18n.t(:error_pdf_failed_to_export, error: e.message[0..300])) end diff --git a/app/services/duration_converter.rb b/app/services/duration_converter.rb new file mode 100644 index 000000000000..0d6ddb66b049 --- /dev/null +++ b/app/services/duration_converter.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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. +# ++ + +# We use BigDecimal to handle floating point arithmetic and avoid +# weird floating point results on decimal operations when converting +# hours to seconds on duration outputting. +require "bigdecimal" + +class DurationConverter + UNIT_ABBREVIATION_MAP = { + "seconds" => "seconds", + "second" => "seconds", + "secs" => "seconds", + "sec" => "seconds", + "s" => "seconds", + "minutes" => "minutes", + "minute" => "minutes", + "mins" => "minutes", + "min" => "minutes", + "m" => "minutes", + "hours" => "hours", + "hour" => "hours", + "hrs" => "hours", + "hr" => "hours", + "h" => "hours", + "days" => "days", + "day" => "days", + "dy" => "days", + "d" => "days", + "weeks" => "weeks", + "week" => "weeks", + "wks" => "weeks", + "wk" => "weeks", + "w" => "weeks", + "months" => "months", + "mo" => "months", + "mos" => "months", + "month" => "months", + "years" => "years", + "year" => "years", + "yrs" => "years", + "yr" => "years", + "y" => "years" + }.freeze + + NEXT_UNIT_MAP = { + "years" => "months", + "months" => "weeks", + "weeks" => "days", + "days" => "hours", + "hours" => "minutes", + "minutes" => "seconds" + }.freeze + + class << self + def parse(duration_string) + # Assume the next logical unit to allow users to write + # durations such as "2h 1" assuming "1" is "1 minute" + last_unit_in_string = duration_string.scan(/[a-zA-Z]+/) + .last + default_unit = if last_unit_in_string + last_unit_in_string + .then { |last_unit| UNIT_ABBREVIATION_MAP[last_unit.downcase] } + .then { |last_unit| NEXT_UNIT_MAP[last_unit] } + else + "hours" + end + + ChronicDuration.raise_exceptions = true + ChronicDuration.parse(duration_string, + keep_zero: true, + default_unit:, + **duration_length_options) / 3600.to_f + end + + def output(duration_in_hours) + return duration_in_hours if duration_in_hours.nil? + + # Prevents rounding errors when including seconds by chopping + # off the overflow seconds and keeping the nearest minute. + seconds = ((duration_in_hours * 3600) + 30).to_i + seconds_overflow = seconds % 60 + seconds_to_the_nearest_minute = seconds - seconds_overflow + + # return "0 h" if parsing 0. + # ChronicDuration returns nil when parsing 0. + # By default, its unit is seconds and if we were + # keeping zeroes, we'd format this as "0 secs". + # + # We want to override this behavior. + if ChronicDuration.output(seconds_to_the_nearest_minute, + default_unit: "hours", + **duration_length_options).nil? + "0h" + else + ChronicDuration.output(seconds_to_the_nearest_minute, + default_unit: "hours", + format: :short, + **duration_length_options) + end + end + + private + + def convert_duration_to_seconds(duration_in_hours) + (BigDecimal(duration_in_hours.to_s) * 3600).to_f + end + + def duration_length_options + { hours_per_day: Setting.hours_per_day, + days_per_month: Setting.days_per_month, + weeks: true } + end + end +end diff --git a/app/services/notifications/create_from_model_service.rb b/app/services/notifications/create_from_model_service.rb index f51be96b0494..9f42b1a31f17 100644 --- a/app/services/notifications/create_from_model_service.rb +++ b/app/services/notifications/create_from_model_service.rb @@ -111,9 +111,9 @@ def create_notification(recipient_id, reason) journal:, actor: user_with_fallback, reason:, - read_ian: strategy.supports_ian? ? false : nil, - mail_reminder_sent: strategy.supports_mail_digest? ? false : nil, - mail_alert_sent: strategy.supports_mail? ? false : nil + read_ian: strategy.supports_ian?(reason) ? false : nil, + mail_reminder_sent: strategy.supports_mail_digest?(reason) ? false : nil, + mail_alert_sent: strategy.supports_mail?(reason) ? false : nil } Notifications::CreateService @@ -129,7 +129,7 @@ def update_notification(recipient_id, reason) Notifications::UpdateService .new(model: existing_notification, user:, contract_class: EmptyContract) - .call(read_ian: strategy.supports_ian? ? false : nil, + .call(read_ian: strategy.supports_ian?(reason) ? false : nil, reason:) end @@ -288,24 +288,26 @@ def send_notification?(send_notifications) end def mention_matches - text = text_for_mentions - - user_ids_tag_after, - user_ids_tag_before, - user_ids_hash, - user_login_names, - group_ids_tag_after, - group_ids_tag_before, - group_ids_hash = text - .scan(MENTION_PATTERN) - .transpose - .each(&:compact!) - - { - user_ids: [user_ids_tag_after, user_ids_tag_before, user_ids_hash].flatten.compact, - user_login_names: [user_login_names].flatten.compact, - group_ids: [group_ids_tag_after, group_ids_tag_before, group_ids_hash].flatten.compact - } + @mention_matches ||= begin + text = text_for_mentions + + user_ids_tag_after, + user_ids_tag_before, + user_ids_hash, + user_login_names, + group_ids_tag_after, + group_ids_tag_before, + group_ids_hash = text + .scan(MENTION_PATTERN) + .transpose + .each(&:compact!) + + { + user_ids: [user_ids_tag_after, user_ids_tag_before, user_ids_hash].flatten.compact, + user_login_names: [user_login_names].flatten.compact, + group_ids: [group_ids_tag_after, group_ids_tag_before, group_ids_hash].flatten.compact + } + end end def abort_sending? @@ -328,10 +330,16 @@ def add_receiver(receivers, collection, reason) end end + def user_not_mentioned_or_mentioned_indirectly(self_reason) + self_reason != NotificationSetting::MENTIONED || + (mention_matches[:user_ids].exclude?(user_with_fallback.id) && + mention_matches[:user_login_names].exclude?(user_with_fallback.login)) + end + def remove_self_recipient(receivers) if receivers.key?(user_with_fallback.id) self_reasons = receivers[user_with_fallback.id] - self_reasons.delete_if { |item| item != NotificationSetting::MENTIONED } + self_reasons.delete_if { |reason| user_not_mentioned_or_mentioned_indirectly(reason) } if self_reasons.empty? receivers.delete(user_with_fallback.id) end diff --git a/app/services/notifications/create_from_model_service/comment_strategy.rb b/app/services/notifications/create_from_model_service/comment_strategy.rb index 1a3cd14f0611..dd139575ebd1 100644 --- a/app/services/notifications/create_from_model_service/comment_strategy.rb +++ b/app/services/notifications/create_from_model_service/comment_strategy.rb @@ -35,15 +35,15 @@ def self.permission :view_news end - def self.supports_ian? + def self.supports_ian?(_reason) false end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) false end - def self.supports_mail? + def self.supports_mail?(_reason) true end diff --git a/app/services/notifications/create_from_model_service/message_strategy.rb b/app/services/notifications/create_from_model_service/message_strategy.rb index eb34f9c56ca8..0e7084c09437 100644 --- a/app/services/notifications/create_from_model_service/message_strategy.rb +++ b/app/services/notifications/create_from_model_service/message_strategy.rb @@ -35,15 +35,15 @@ def self.permission :view_messages end - def self.supports_ian? + def self.supports_ian?(_reason) false end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) false end - def self.supports_mail? + def self.supports_mail?(_reason) true end diff --git a/app/services/notifications/create_from_model_service/news_strategy.rb b/app/services/notifications/create_from_model_service/news_strategy.rb index 38f963cd2960..2a2ae7d4ee47 100644 --- a/app/services/notifications/create_from_model_service/news_strategy.rb +++ b/app/services/notifications/create_from_model_service/news_strategy.rb @@ -35,15 +35,15 @@ def self.permission :view_news end - def self.supports_ian? + def self.supports_ian?(_reason) false end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) false end - def self.supports_mail? + def self.supports_mail?(_reason) true end diff --git a/app/services/notifications/create_from_model_service/wiki_page_strategy.rb b/app/services/notifications/create_from_model_service/wiki_page_strategy.rb index 8aadeaf57ca2..3dc3a8e36096 100644 --- a/app/services/notifications/create_from_model_service/wiki_page_strategy.rb +++ b/app/services/notifications/create_from_model_service/wiki_page_strategy.rb @@ -35,15 +35,15 @@ def self.permission :view_wiki_pages end - def self.supports_ian? + def self.supports_ian?(_reason) false end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) false end - def self.supports_mail? + def self.supports_mail?(_reason) true end diff --git a/app/services/notifications/create_from_model_service/work_package_strategy.rb b/app/services/notifications/create_from_model_service/work_package_strategy.rb index ff078b9aa168..c4150e8cf12b 100644 --- a/app/services/notifications/create_from_model_service/work_package_strategy.rb +++ b/app/services/notifications/create_from_model_service/work_package_strategy.rb @@ -35,16 +35,16 @@ def self.permission :view_work_packages end - def self.supports_ian? + def self.supports_ian?(_reason) true end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) true end - def self.supports_mail? - true + def self.supports_mail?(reason) + reason == :mentioned end def self.watcher_users(journal) diff --git a/app/services/project_custom_field_project_mappings/bulk_create_service.rb b/app/services/project_custom_field_project_mappings/bulk_create_service.rb new file mode 100644 index 000000000000..f9bb4b7f530d --- /dev/null +++ b/app/services/project_custom_field_project_mappings/bulk_create_service.rb @@ -0,0 +1,122 @@ +# 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 ProjectCustomFieldProjectMappings + class BulkCreateService < ::BaseServices::BaseCallable + def initialize(user:, project:, project_custom_field:, include_sub_projects: false) + super() + @user = user + @project = project + @project_custom_field = project_custom_field + @include_sub_projects = include_sub_projects + end + + def perform + service_call = validate_permissions + service_call = validate_contract(service_call, incoming_mapping_ids) if service_call.success? + service_call = perform_bulk_create(service_call) if service_call.success? + + service_call + end + + private + + def validate_permissions + if @user.allowed_in_project?(:select_project_custom_fields, projects) + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def validate_contract(service_call, project_ids) + set_attributes_results = project_ids.map do |id| + set_attributes(project_id: id, custom_field_id: @project_custom_field.id) + end + + if (failures = set_attributes_results.select(&:failure?)).any? + service_call.success = false + service_call.errors = failures.map(&:errors) + else + service_call.result = set_attributes_results.map(&:result) + end + + service_call + end + + def perform_bulk_create(service_call) + ProjectCustomFieldProjectMapping.import(service_call.result, validate: false) + + service_call + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + def incoming_mapping_ids + project_ids = projects.pluck(:id) + project_ids - existing_project_mappings(project_ids) + end + + def projects + [@project].tap do |projects_array| + projects_array.concat(@project.active_subprojects.to_a) if @include_sub_projects + end + end + + def existing_project_mappings(project_ids) + ProjectCustomFieldProjectMapping.where( + custom_field_id: @project_custom_field.id, + project_id: project_ids + ).pluck(:project_id) + end + + def set_attributes(params) + attributes_service_class + .new(user: @user, + model: instance(params), + contract_class: default_contract_class, + contract_options: {}) + .call(params) + end + + def instance(params) + ProjectCustomFieldProjectMapping.new(params) + end + + def attributes_service_class + ProjectCustomFieldProjectMappings::SetAttributesService + end + + def default_contract_class + ProjectCustomFieldProjectMappings::UpdateContract + end + end +end diff --git a/app/services/settings/update_service.rb b/app/services/settings/update_service.rb index 836a7381ca2f..255f7af69bee 100644 --- a/app/services/settings/update_service.rb +++ b/app/services/settings/update_service.rb @@ -46,7 +46,7 @@ def set_setting_value(name, value) new_value = derive_value(value) Setting[name] = new_value if name == :work_package_done_ratio && old_value != "status" && new_value == "status" - WorkPackages::Progress::ApplyStatusesPCompleteJob.perform_later(cause_type: "progress_mode_changed_to_status_based") + WorkPackages::Progress::ApplyStatusesChangeJob.perform_later(cause_type: "progress_mode_changed_to_status_based") end end diff --git a/app/services/settings/working_days_update_service.rb b/app/services/settings/working_days_and_hours_update_service.rb similarity index 95% rename from app/services/settings/working_days_update_service.rb rename to app/services/settings/working_days_and_hours_update_service.rb index 56575aed1e83..d3fe86a527bf 100644 --- a/app/services/settings/working_days_update_service.rb +++ b/app/services/settings/working_days_and_hours_update_service.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class Settings::WorkingDaysUpdateService < Settings::UpdateService +class Settings::WorkingDaysAndHoursUpdateService < Settings::UpdateService def call(params) params = params.to_h.deep_symbolize_keys self.non_working_days_params = params.delete(:non_working_days) || [] @@ -36,7 +36,7 @@ def call(params) end def validate_params(params) - contract = Settings::WorkingDaysParamsContract.new(model, user, params:) + contract = Settings::WorkingDaysAndHoursParamsContract.new(model, user, params:) ServiceResult.new success: contract.valid?, errors: contract.errors, result: model diff --git a/app/services/work_packages/update_ancestors/loader.rb b/app/services/work_packages/update_ancestors/loader.rb index 435301050231..3e9646e5f99e 100644 --- a/app/services/work_packages/update_ancestors/loader.rb +++ b/app/services/work_packages/update_ancestors/loader.rb @@ -25,6 +25,22 @@ # See COPYRIGHT and LICENSE files for more details. class WorkPackages::UpdateAncestors::Loader + DESCENDANT_ATTRIBUTES = { + id: "id", + parent_id: "parent_id", + estimated_hours: "estimated_hours", + remaining_hours: "remaining_hours", + status_excluded_from_totals: "statuses.excluded_from_totals", + schedule_manually: "schedule_manually", + ignore_non_working_days: "ignore_non_working_days" + }.freeze + + WorkPackageLikeStruct = Data.define(*DESCENDANT_ATTRIBUTES.keys) do + def included_in_totals_calculation? + !status_excluded_from_totals + end + end + def initialize(work_package, include_former_ancestors) self.work_package = work_package self.include_former_ancestors = include_former_ancestors @@ -40,7 +56,7 @@ def select def descendants_of(queried_work_package) @descendants ||= Hash.new do |hash, wp| - hash[wp] = replaced_related_of(wp, :descendants) + hash[wp] = replaced_related_descendants(wp) end @descendants[queried_work_package] @@ -72,7 +88,7 @@ def ancestors end end - # Replace descendants/leaves by ancestors if they are the same. + # Replace descendants by ancestors if they are the same. # This can e.g. be the case in scenario of # grandparent # | @@ -84,31 +100,27 @@ def ancestors # Then grandparent and parent are already in ancestors. # Parent might be modified during the UpdateAncestorsService run, # and the descendants of grandparent need to have the updated value. - def replaced_related_of(queried_work_package, relation_type) - related_of(queried_work_package, relation_type).map do |leaf| + def replaced_related_descendants(queried_work_package) + related_descendants(queried_work_package).map do |leaf| if work_package.id == leaf.id work_package elsif (ancestor = ancestors.detect { |a| a.id == leaf.id }) ancestor else - yield leaf if block_given? leaf end end end - def related_of(queried_work_package, relation_type) + def related_descendants(queried_work_package) scope = queried_work_package - .send(relation_type) + .descendants .where.not(id: queried_work_package.id) - if send(:"#{relation_type}_joins") - scope = scope.joins(send(:"#{relation_type}_joins")) - end - scope - .pluck(*send(:"selected_#{relation_type}_attributes")) - .map { |p| LoaderStruct.new(send(:"selected_#{relation_type}_attributes").zip(p).to_h) } + .left_joins(:status) + .pluck(*DESCENDANT_ATTRIBUTES.values) + .map { |p| WorkPackageLikeStruct.new(**DESCENDANT_ATTRIBUTES.keys.zip(p).to_h) } end # Returns the current ancestors sorted by distance (called generations in the table) @@ -128,23 +140,6 @@ def former_ancestors end end - def selected_descendants_attributes - # By having the id in here, we can avoid DISTINCT queries squashing duplicate values - %i(id estimated_hours parent_id schedule_manually ignore_non_working_days remaining_hours) - end - - def descendants_joins - nil - end - - def selected_leaves_attributes - %i(id done_ratio derived_estimated_hours estimated_hours is_closed remaining_hours derived_remaining_hours) - end - - def leaves_joins - :status - end - ## # Get the previous parent ID # This could either be +parent_id_was+ if parent was changed @@ -165,9 +160,6 @@ def previous_change_parent_id previous_parent_changes = previous[:parent_id] || previous[:parent] - previous_parent_changes ? previous_parent_changes.first : nil + previous_parent_changes&.first end - - class LoaderStruct < Hashie::Mash; end - LoaderStruct.disable_warnings end diff --git a/app/services/work_packages/update_ancestors_service.rb b/app/services/work_packages/update_ancestors_service.rb index 7627840b4886..0ced628eac74 100644 --- a/app/services/work_packages/update_ancestors_service.rb +++ b/app/services/work_packages/update_ancestors_service.rb @@ -99,7 +99,7 @@ def derive_attributes(work_package, loader, attributes) # or the derived remaining hours, depending on the % Complete mode # currently active. # - %i[estimated_hours remaining_hours] => :derive_total_estimated_and_remaining_hours, + %i[estimated_hours remaining_hours status status_id] => :derive_total_estimated_and_remaining_hours, %i[estimated_hours remaining_hours done_ratio status status_id] => :derive_done_ratio, %i[ignore_non_working_days] => :derive_ignore_non_working_days }.each do |derivative_attributes, method| @@ -155,7 +155,9 @@ def derive_total(work_package, attribute, loader) return if no_children?(work_package, loader) work_packages = [work_package] + loader.descendants_of(work_package) - values = work_packages.filter_map(&attribute) + values = work_packages + .filter(&:included_in_totals_calculation?) + .filter_map(&attribute) return if values.empty? values.sum.to_f diff --git a/app/views/admin/index.html.erb b/app/views/admin/index.html.erb index d318b2fb6be9..9f67f397c8da 100644 --- a/app/views/admin/index.html.erb +++ b/app/views/admin/index.html.erb @@ -34,7 +34,7 @@ See COPYRIGHT and LICENSE files for more details. -
-
+ diff --git a/frontend/src/app/shared/components/icon/icon.module.ts b/frontend/src/app/shared/components/icon/icon.module.ts index 5558307695e8..1a0ab158485d 100644 --- a/frontend/src/app/shared/components/icon/icon.module.ts +++ b/frontend/src/app/shared/components/icon/icon.module.ts @@ -2,6 +2,8 @@ import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { OpIconComponent } from './icon.component'; import { + ChevronLeftIconComponent, + ChevronRightIconComponent, HomeIconComponent, InfoIconComponent, OpCursorRectangleSelectIconComponent, @@ -11,6 +13,7 @@ import { OpFirstPersonViewIconComponent, OpGridMenuIconComponent, OpOrthographicPerspectiveIconComponent, + OpResizerVerticalLinesIconComponent, OpScissorsIconComponent, OpThreedReturnDefaultIconComponent, OpViewFitToIconComponent, @@ -29,6 +32,8 @@ import { imports: [ CommonModule, + ChevronLeftIconComponent, + ChevronRightIconComponent, HomeIconComponent, InfoIconComponent, OpCursorRectangleSelectIconComponent, @@ -38,6 +43,7 @@ import { OpFirstPersonViewIconComponent, OpGridMenuIconComponent, OpOrthographicPerspectiveIconComponent, + OpResizerVerticalLinesIconComponent, OpScissorsIconComponent, OpThreedReturnDefaultIconComponent, OpViewFitToIconComponent, @@ -58,6 +64,8 @@ import { exports: [ OpIconComponent, + ChevronLeftIconComponent, + ChevronRightIconComponent, HomeIconComponent, InfoIconComponent, OpCursorRectangleSelectIconComponent, @@ -67,6 +75,7 @@ import { OpFirstPersonViewIconComponent, OpGridMenuIconComponent, OpOrthographicPerspectiveIconComponent, + OpResizerVerticalLinesIconComponent, OpScissorsIconComponent, OpThreedReturnDefaultIconComponent, OpViewFitToIconComponent, @@ -79,6 +88,7 @@ import { StarFillIconComponent, StarIconComponent, XIconComponent, + ], }) export class IconModule {} diff --git a/frontend/src/app/shared/components/resizer/resizer.component.ts b/frontend/src/app/shared/components/resizer/resizer.component.ts index 7d6dbd3384e6..5383a9f50aff 100644 --- a/frontend/src/app/shared/components/resizer/resizer.component.ts +++ b/frontend/src/app/shared/components/resizer/resizer.component.ts @@ -1,10 +1,45 @@ +// -- 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. +//++ + import { - Component, EventEmitter, HostListener, Input, OnDestroy, Output, + ChangeDetectionStrategy, + Component, + EventEmitter, + HostListener, + Input, + OnDestroy, + Output, } from '@angular/core'; + import { setBodyCursor } from 'core-app/shared/helpers/dom/set-window-cursor.helper'; export interface ResizeDelta { - origin:any; + origin:UIEvent; // Absolute difference from start absolute:{ @@ -20,8 +55,9 @@ export interface ResizeDelta { } @Component({ - selector: 'resizer', + selector: 'op-resizer', templateUrl: './resizer.component.html', + changeDetection: ChangeDetectionStrategy.OnPush, }) export class ResizerComponent implements OnDestroy { private startX:number; @@ -40,13 +76,11 @@ export class ResizerComponent implements OnDestroy { private mouseUpHandler:EventListener; - private resizing = false; + @Output() resizeFinished:EventEmitter = new EventEmitter(); - @Output() end:EventEmitter = new EventEmitter(); + @Output() resizeStarted:EventEmitter = new EventEmitter(); - @Output() start:EventEmitter = new EventEmitter(); - - @Output() move:EventEmitter = new EventEmitter(); + @Output() move:EventEmitter = new EventEmitter(); @Input() customHandler = false; @@ -60,50 +94,64 @@ export class ResizerComponent implements OnDestroy { @HostListener('mousedown', ['$event']) @HostListener('touchstart', ['$event']) - public startResize(event:any) { + // public startResize(event:any) { + public startResize(event:MouseEvent|TouchEvent) { event.preventDefault(); event.stopPropagation(); - // Only on left mouse click the resizing is started - if (event.buttons === 1 || event.which === 1 || event.which === 0) { - // Getting starting position - this.oldX = this.startX = event.clientX || event.pageX || event.touches[0].clientX; - this.oldY = this.startY = event.clientY || event.pageY || event.touches[0].clientY; + if (this.isMouseEvent(event) && event.button !== 0) { + // Only handle primary mouse button clicks + return; + } - this.newX = event.clientX || event.pageX || event.touches[0].clientX; - this.newY = event.clientY || event.pageY || event.touches[0].clientY; + const { x, y } = this.position(event); + this.oldX = x; + this.startX = x; + this.newX = x; + this.oldY = y; + this.startY = y; + this.newY = y; + + this.setResizeCursor(); + this.bindEventListener(); + this.resizeStarted.emit(this.buildDelta(event)); + } - this.resizing = true; + private position(event:MouseEvent|TouchEvent):{ x:number, y:number } { + if (this.isMouseEvent(event)) { + return { x: event.clientX, y: event.clientY }; + } - this.setResizeCursor(); - this.bindEventListener(event); + return { x: event.touches[0].clientX, y: event.touches[0].clientY }; + } - this.start.emit(this.buildDelta(event)); - } + private isMouseEvent(event:MouseEvent|TouchEvent):event is MouseEvent { + return event instanceof MouseEvent; } - private onMouseUp(event:any) { + private onMouseUp(event:MouseEvent|TouchEvent) { this.setAutoCursor(); this.removeEventListener(); - this.end.emit(this.buildDelta(event)); + this.resizeFinished.emit(this.buildDelta(event)); } - private onMouseMove(event:any) { + private onMouseMove(event:MouseEvent|TouchEvent) { event.preventDefault(); event.stopPropagation(); this.oldX = this.newX; this.oldY = this.newY; - this.newX = event.clientX || event.pageX || event.touches[0].clientX; - this.newY = event.clientY || event.pageY || event.touches[0].clientX; + const { x, y } = this.position(event); + this.newX = x; + this.newY = y; this.move.emit(this.buildDelta(event)); } // Necessary to encapsulate this to be able to remove the event listener later - private bindEventListener(event:any) { + private bindEventListener() { this.mouseMoveHandler = this.onMouseMove.bind(this); this.mouseUpHandler = this.onMouseUp.bind(this); @@ -128,7 +176,7 @@ export class ResizerComponent implements OnDestroy { setBodyCursor('auto'); } - private buildDelta(event:any):ResizeDelta { + private buildDelta(event:MouseEvent|TouchEvent):ResizeDelta { return { origin: event, absolute: { @@ -137,7 +185,7 @@ export class ResizerComponent implements OnDestroy { }, relative: { x: this.newX - this.oldX, - y: this.newY - this.oldX, + y: this.newY - this.oldY, }, }; } diff --git a/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts b/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts index 0fd310b08de4..9644515a73a8 100644 --- a/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts +++ b/frontend/src/app/shared/components/resizer/resizer/main-menu-resizer.component.ts @@ -27,9 +27,14 @@ //++ import { - ChangeDetectorRef, Component, ElementRef, OnInit, + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + OnInit, } from '@angular/core'; import { distinctUntilChanged } from 'rxjs/operators'; + import { ResizeDelta } from 'core-app/shared/components/resizer/resizer.component'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import { MainMenuToggleService } from 'core-app/core/main-menu/main-menu-toggle.service'; @@ -38,23 +43,25 @@ export const mainMenuResizerSelector = 'main-menu-resizer'; @Component({ selector: mainMenuResizerSelector, + changeDetection: ChangeDetectionStrategy.OnPush, template: ` - -
- -
-
+ + + `, }) @@ -63,17 +70,17 @@ export class MainMenuResizerComponent extends UntilDestroyedMixin implements OnI private resizeEvent:string; - private localStorageKey:string; - private elementWidth:number; private mainMenu = jQuery('#main-menu')[0]; public moving = false; - constructor(readonly toggleService:MainMenuToggleService, + constructor( + readonly toggleService:MainMenuToggleService, readonly cdRef:ChangeDetectorRef, - readonly elementRef:ElementRef) { + readonly elementRef:ElementRef, + ) { super(); } @@ -89,7 +96,6 @@ export class MainMenuResizerComponent extends UntilDestroyedMixin implements OnI }); this.resizeEvent = 'main-menu-resize'; - this.localStorageKey = 'openProject-mainMenuWidth'; } public resizeStart() { diff --git a/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts b/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts index 5b8a939ca20f..eca03af8bea5 100644 --- a/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts +++ b/frontend/src/app/shared/components/resizer/resizer/wp-resizer.component.ts @@ -40,13 +40,13 @@ import { MainMenuToggleService } from 'core-app/core/main-menu/main-menu-toggle. @Component({ selector: 'wp-resizer', template: ` - - + `, changeDetection: ChangeDetectionStrategy.OnPush, }) diff --git a/frontend/src/app/shared/helpers/chronic_duration.d.ts b/frontend/src/app/shared/helpers/chronic_duration.d.ts new file mode 100644 index 000000000000..1d643f08a7b9 --- /dev/null +++ b/frontend/src/app/shared/helpers/chronic_duration.d.ts @@ -0,0 +1,31 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 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. + * ++ + */ + +export function outputChronicDuration(duration:number, opts = {}):string|null; diff --git a/frontend/src/app/shared/helpers/chronic_duration.js b/frontend/src/app/shared/helpers/chronic_duration.js new file mode 100644 index 000000000000..1be327fdf633 --- /dev/null +++ b/frontend/src/app/shared/helpers/chronic_duration.js @@ -0,0 +1,418 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 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. + * ++ + */ + +/* + * NOTE: + * Changes to this file should be kept in sync with + * https://gitlab.com/gitlab-org/gitlab-chronic-duration/-/blob/master/lib/gitlab_chronic_duration.rb. + */ + +/* eslint-disable */ +export class DurationParseError extends Error { +} + +// On average, there's a little over 4 weeks in month. +const FULL_WEEKS_PER_MONTH = 4; + +const HOURS_PER_DAY = 24; +const DAYS_PER_MONTH = 30; + +const FLOAT_MATCHER = /[0-9]*\.?[0-9]+/g; +const DURATION_UNITS_LIST = ['seconds', 'minutes', 'hours', 'days', 'weeks', 'months', 'years']; + +const MAPPINGS = { + seconds: 'seconds', + second: 'seconds', + secs: 'seconds', + sec: 'seconds', + s: 'seconds', + minutes: 'minutes', + minute: 'minutes', + mins: 'minutes', + min: 'minutes', + m: 'minutes', + hours: 'hours', + hour: 'hours', + hrs: 'hours', + hr: 'hours', + h: 'hours', + days: 'days', + day: 'days', + dy: 'days', + d: 'days', + weeks: 'weeks', + week: 'weeks', + wks: 'weeks', + wk: 'weeks', + w: 'weeks', + months: 'months', + mo: 'months', + mos: 'months', + month: 'months', + years: 'years', + year: 'years', + yrs: 'years', + yr: 'years', + y: 'years', +}; + +const JOIN_WORDS = ['and', 'with', 'plus']; + +function convertToNumber(string) { + const f = parseFloat(string); + return f % 1 > 0 ? f : parseInt(string, 10); +} + +function durationUnitsSecondsMultiplier(unit, opts) { + if (!DURATION_UNITS_LIST.includes(unit)) { + return 0; + } + + const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; + const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; + const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); + + switch (unit) { + case 'years': + return 31557600; + case 'months': + return 3600 * hoursPerDay * daysPerMonth; + case 'weeks': + return 3600 * hoursPerDay * daysPerWeek; + case 'days': + return 3600 * hoursPerDay; + case 'hours': + return 3600; + case 'minutes': + return 60; + case 'seconds': + return 1; + default: + return 0; + } +} + +function calculateFromWords(string, opts) { + let val = 0; + const words = string.split(' '); + words.forEach((v, k) => { + if (v === '') { + return; + } + if (v.search(FLOAT_MATCHER) >= 0) { + val += + convertToNumber(v) * + durationUnitsSecondsMultiplier( + words[parseInt(k, 10) + 1] || opts.defaultUnit || 'seconds', + opts, + ); + } + }); + return val; +} + +// Parse 3:41:59 and return 3 hours 41 minutes 59 seconds +function filterByType(string) { + const chronoUnitsList = DURATION_UNITS_LIST.filter((v) => v !== 'weeks'); + if ( + string + .replace(/ +/g, '') + .search(RegExp(`${FLOAT_MATCHER.source}(:${FLOAT_MATCHER.source})+`, 'g')) >= 0 + ) { + const res = []; + string + .replace(/ +/g, '') + .split(':') + .reverse() + .forEach((v, k) => { + if (!chronoUnitsList[k]) { + return; + } + res.push(`${v} ${chronoUnitsList[k]}`); + }); + return res.reverse().join(' '); + } + return string; +} + +// Get rid of unknown words and map found +// words to defined time units +function filterThroughWhiteList(string, opts) { + const res = []; + string.split(' ').forEach((word) => { + if (word === '') { + return; + } + if (word.search(FLOAT_MATCHER) >= 0) { + res.push(word.trim()); + return; + } + const strippedWord = word.trim().replace(/^,/g, '').replace(/,$/g, ''); + if (MAPPINGS[strippedWord] !== undefined) { + res.push(MAPPINGS[strippedWord]); + } else if (!JOIN_WORDS.includes(strippedWord) && opts.raiseExceptions) { + throw new DurationParseError( + `An invalid word ${JSON.stringify(word)} was used in the string to be parsed.`, + ); + } + }); + // add '1' at front if string starts with something recognizable but not with a number, like 'day' or 'minute 30sec' + if (res.length > 0 && MAPPINGS[res[0]]) { + res.splice(0, 0, 1); + } + return res.join(' '); +} + +function cleanup(string, opts) { + let res = string.toLowerCase(); + /* + * TODO The Ruby implementation of this algorithm uses the Numerizer module, + * which converts strings like "forty two" to "42", but there is no + * JavaScript equivalent of Numerizer. Skip it for now until Numerizer is + * ported to JavaScript. + */ + res = filterByType(res); + res = res + .replace(FLOAT_MATCHER, (n) => ` ${n} `) + .replace(/ +/g, ' ') + .trim(); + return filterThroughWhiteList(res, opts); +} + +function humanizeTimeUnit(number, unit, pluralize, keepZero) { + if (number === '0' && !keepZero) { + return null; + } + let res = number + unit; + // A poor man's pluralizer + if (number !== '1' && pluralize) { + res += 's'; + } + return res; +} + +// Given a string representation of elapsed time, +// return an integer (or float, if fractions of a +// second are input) +export function parseChronicDuration(string, opts = {}) { + const result = calculateFromWords(cleanup(string, opts), opts); + return !opts.keepZero && result === 0 ? null : result; +} + +// Given an integer and an optional format, +// returns a formatted string representing elapsed time +export function outputChronicDuration(seconds, opts = {}) { + const units = { + years: 0, + months: 0, + weeks: 0, + days: 0, + hours: 0, + minutes: 0, + seconds, + }; + + const hoursPerDay = opts.hoursPerDay || HOURS_PER_DAY; + const daysPerMonth = opts.daysPerMonth || DAYS_PER_MONTH; + const daysPerWeek = Math.trunc(daysPerMonth / FULL_WEEKS_PER_MONTH); + + const decimalPlaces = + seconds % 1 !== 0 ? seconds.toString().split('.').reverse()[0].length : null; + + const minute = 60; + const hour = 60 * minute; + const day = hoursPerDay * hour; + const month = daysPerMonth * day; + const year = 31557600; + + if (units.seconds >= 31557600 && units.seconds % year < units.seconds % month) { + units.years = Math.trunc(units.seconds / year); + units.months = Math.trunc((units.seconds % year) / month); + units.days = Math.trunc(((units.seconds % year) % month) / day); + units.hours = Math.trunc((((units.seconds % year) % month) % day) / hour); + units.minutes = Math.trunc(((((units.seconds % year) % month) % day) % hour) / minute); + units.seconds = Math.trunc(((((units.seconds % year) % month) % day) % hour) % minute); + } else if (seconds >= 60) { + units.minutes = Math.trunc(seconds / 60); + units.seconds %= 60; + if (units.minutes >= 60) { + units.hours = Math.trunc(units.minutes / 60); + units.minutes = Math.trunc(units.minutes % 60); + if (!opts.limitToHours) { + if (units.hours >= hoursPerDay) { + units.days = Math.trunc(units.hours / hoursPerDay); + units.hours = Math.trunc(units.hours % hoursPerDay); + if (opts.weeks) { + if (units.days >= daysPerWeek) { + units.weeks = Math.trunc(units.days / daysPerWeek); + units.days = Math.trunc(units.days % daysPerWeek); + if (units.weeks >= FULL_WEEKS_PER_MONTH) { + units.months = Math.trunc(units.weeks / FULL_WEEKS_PER_MONTH); + units.weeks = Math.trunc(units.weeks % FULL_WEEKS_PER_MONTH); + } + } + } else if (units.days >= daysPerMonth) { + units.months = Math.trunc(units.days / daysPerMonth); + units.days = Math.trunc(units.days % daysPerMonth); + } + } + } + } + } + + let joiner = opts.joiner || ' '; + let process = null; + + let dividers; + switch (opts.format) { + case 'micro': + dividers = { + years: 'y', + months: 'mo', + weeks: 'w', + days: 'd', + hours: 'h', + minutes: 'm', + seconds: 's', + }; + joiner = ''; + break; + case 'short': + dividers = { + years: 'y', + months: 'mo', + weeks: 'w', + days: 'd', + hours: 'h', + minutes: 'm', + seconds: 's', + }; + break; + case 'long': + dividers = { + /* eslint-disable @gitlab/require-i18n-strings */ + years: ' year', + months: ' month', + weeks: ' week', + days: ' day', + hours: ' hour', + minutes: ' minute', + seconds: ' second', + /* eslint-enable @gitlab/require-i18n-strings */ + pluralize: true, + }; + break; + case 'chrono': + dividers = { + years: ':', + months: ':', + weeks: ':', + days: ':', + hours: ':', + minutes: ':', + seconds: ':', + keepZero: true, + }; + process = (str) => { + // Pad zeros + // Get rid of lead off times if they are zero + // Get rid of lead off zero + // Get rid of trailing: + const divider = ':'; + const processed = []; + str.split(divider).forEach((n) => { + if (n === '') { + return; + } + // add zeros only if n is an integer + if (n.search('\\.') >= 0) { + processed.push( + parseFloat(n) + .toFixed(decimalPlaces) + .padStart(3 + decimalPlaces, '0'), + ); + } else { + processed.push(n.padStart(2, '0')); + } + }); + return processed + .join(divider) + .replace(/^(00:)+/g, '') + .replace(/^0/g, '') + .replace(/:$/g, ''); + }; + joiner = ''; + break; + default: + dividers = { + /* eslint-disable @gitlab/require-i18n-strings */ + years: ' yr', + months: ' mo', + weeks: ' wk', + days: ' day', + hours: ' hr', + minutes: ' min', + seconds: ' sec', + /* eslint-enable @gitlab/require-i18n-strings */ + pluralize: true, + }; + break; + } + + let result = []; + ['years', 'months', 'weeks', 'days', 'hours', 'minutes', 'seconds'].forEach((t) => { + if (t === 'weeks' && !opts.weeks) { + return; + } + let num = units[t]; + if (t === 'seconds' && num % 0 !== 0) { + num = num.toFixed(decimalPlaces); + } else { + num = num.toString(); + } + const keepZero = !dividers.keepZero && t === 'seconds' ? opts.keepZero : dividers.keepZero; + const humanized = humanizeTimeUnit(num, dividers[t], dividers.pluralize, keepZero); + if (humanized !== null) { + result.push(humanized); + } + }); + + if (opts.units) { + result = result.slice(0, opts.units); + } + + result = result.join(joiner); + + if (process) { + result = process(result); + } + + return result.length === 0 ? null : result; +} diff --git a/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts b/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts index 44915c491f34..ffeb9b9167ba 100644 --- a/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts +++ b/frontend/src/app/spot/components/drop-modal/drop-modal.component.ts @@ -207,7 +207,10 @@ export class SpotDropModalComponent implements OnDestroy { private onGlobalClick = this.close.bind(this) as () => void; ngOnDestroy():void { - this.teleportationService.clear(); + if (this.opened) { + this.teleportationService.clear(); + } + document.body.removeEventListener('click', this.onGlobalClick); document.body.removeEventListener('keydown', this.onEscape); window.removeEventListener('resize', this.onResize); diff --git a/frontend/src/app/spot/styles/sass/components/action-bar.sass b/frontend/src/app/spot/styles/sass/components/action-bar.sass index 89f88adc337b..c3cd906dbb60 100644 --- a/frontend/src/app/spot/styles/sass/components/action-bar.sass +++ b/frontend/src/app/spot/styles/sass/components/action-bar.sass @@ -16,7 +16,6 @@ flex-wrap: wrap align-items: center justify-content: flex-end - margin-top: -$spot-spacing-0_25 > .spot-action-bar--action // We need to set the margins on the buttons itself because diff --git a/frontend/src/global_styles/content/_forms_mobile.sass b/frontend/src/global_styles/content/_forms_mobile.sass index a1370e188bd4..3b8128b4b6f0 100644 --- a/frontend/src/global_styles/content/_forms_mobile.sass +++ b/frontend/src/global_styles/content/_forms_mobile.sass @@ -60,7 +60,7 @@ .form--field-inline-buttons-container, .form--field-inline-button - width: auto + width: auto !important .-browser-safari, .-browser-chrome diff --git a/frontend/src/global_styles/content/_table.sass b/frontend/src/global_styles/content/_table.sass index 379bef1a6d85..a4d0f4d00942 100644 --- a/frontend/src/global_styles/content/_table.sass +++ b/frontend/src/global_styles/content/_table.sass @@ -220,6 +220,9 @@ table.generic-table background-color: var(--body-background) // fixed column width helpers + &.-w-abs-45 + width: 45px + &.-w-rel-20 width: 20% @@ -259,7 +262,6 @@ thead.-sticky th .generic-table--header-outer, .generic-table--sort-header-outer - padding: 0 12px 0 6px line-height: var(--generic-table--header-height) height: var(--generic-table--header-height) z-index: 1 @@ -274,8 +276,14 @@ thead.-sticky th &.hover background: initial +.generic-table--sort-header-outer + padding: 0 12px 0 6px + +.generic-table--header-outer, +.generic-table--empty-header + padding: 0 6px + .generic-table--empty-header - padding: 0 6px height: var(--generic-table--header-height) line-height: var(--generic-table--header-height) border-bottom: 1px solid var(--table-border-color) @@ -304,6 +312,11 @@ thead.-sticky th max-width: 300px display: flex + &_no-min-width + min-width: initial + &_centered + justify-content: center + & > a flex: 1 1 width: 100% diff --git a/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass b/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass index 7bb82b874315..9885543e2036 100644 --- a/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass +++ b/frontend/src/global_styles/content/work_packages/inplace_editing/_display_fields.sass @@ -51,7 +51,6 @@ display-field padding-right: 0.25rem text-align: center - // READ value of edit fields .inline-edit--display-field display: inline-block diff --git a/frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass b/frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass index 36dbee861df2..483cd8af9bdd 100644 --- a/frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass +++ b/frontend/src/global_styles/content/work_packages/tabs/_tab_content.sass @@ -37,6 +37,9 @@ .op-files-tab--storage-info-box:not(:last-child) margin-bottom: $spot-spacing-1 + &--text-box + margin-top: $spot-spacing-0_5 + &--header border-bottom: 1px solid #ddd padding-bottom: $spot-spacing-0_75 diff --git a/frontend/src/global_styles/layout/_main_menu.sass b/frontend/src/global_styles/layout/_main_menu.sass index 2f7ce25040c6..f3458fbd4c42 100644 --- a/frontend/src/global_styles/layout/_main_menu.sass +++ b/frontend/src/global_styles/layout/_main_menu.sass @@ -26,6 +26,8 @@ // See COPYRIGHT and LICENSE files for more details. //++ +@import "app/spot/styles/sass/common/icon" + $menu-item-line-height: 30px $arrow-left-width: 40px @@ -37,6 +39,7 @@ $arrow-left-width: 40px @mixin main-menu-selected background: var(--main-menu-bg-selected-background) color: var(--main-menu-selected-font-color) + border: 1px solid var(--main-menu-bg-selected-border) .main-menu width: var(--main-menu-width) @@ -63,7 +66,8 @@ $arrow-left-width: 40px height: 100% .main-menu--children - height: calc(100% - (var(--main-menu-item-height) + 10px)) // 10px spacing + // 10px spacing + height: calc(100% - (var(--main-menu-item-height) + 10px)) overflow: auto @include styled-scroll-bar @@ -96,9 +100,11 @@ $arrow-left-width: 40px // work around due to dom manipulation on document: ready: // this isn't scoped to .main-item-wrapper to avoid flickering padding-left: 12px + &.toggler // explicitly reset to zero to avoid selector precedence problems padding-left: 0 + .main-menu--children li a // children have no icon so we need to push them right. padding-left: 24px @@ -114,6 +120,7 @@ $arrow-left-width: 40px font-size: var(--main-menu-font-size) font-style: normal padding: 0 12px + &:hover text-decoration: none @@ -138,16 +145,17 @@ $arrow-left-width: 40px a border: 1px solid transparent + &.selected, &.selected + a @include main-menu-selected &:hover @include main-menu-hover - &:hover, &:focus , &:active + &:hover, &:focus, &:active @include main-menu-hover - &~ .toggler + & ~ .toggler @include main-menu-hover a:not(.toggler) @@ -162,8 +170,10 @@ $arrow-left-width: 40px display: none padding: 10px 0 width: 100% + &.unattached border-top: 1px solid #ddd + li &:hover // simultaneously hover all menu item anchor tags @@ -173,7 +183,8 @@ $arrow-left-width: 40px > a &.selected, &.selected @include main-menu-selected - &:hover, &:focus , &:active + + &:hover, &:focus, &:active @include main-menu-hover .main-menu--children-menu-header @@ -188,6 +199,7 @@ $arrow-left-width: 40px padding-left: 14px padding-right: 14px border: 1px solid transparent + &:hover, &:focus, &:active @include main-menu-hover @@ -202,6 +214,7 @@ a.main-menu--parent-node line-height: var(--main-menu-item-height) border-radius: 3px color: var(--main-menu-font-color) + &:hover, &:focus, &:active @include main-menu-hover @@ -211,6 +224,7 @@ a.main-menu--parent-node &.closed li display: none + > li.open display: list-item @@ -225,11 +239,14 @@ a.main-menu--parent-node .main-menu--children display: block + li display: list-item + &.open > li display: list-item + .main-menu--children-menu-header display: none @@ -237,6 +254,7 @@ a.main-menu--parent-node .main-menu width: var(--main-menu-folded-width) min-width: var(--main-menu-folded-width) + ul &.menu_root > li @@ -248,6 +266,7 @@ a.main-menu--parent-node text-overflow: clip -o-text-overflow: clip -ms-text-overflow: clip + .toggler display: none @@ -284,8 +303,10 @@ a.main-menu--parent-node ul border: none overflow-x: hidden + li border: none + li a padding: 0 @@ -298,13 +319,17 @@ a.main-menu--parent-node #main-menu ul ul.main-menu--children ul.pages-hierarchy .tree-menu--hierarchy-indicator color: var(--main-menu-font-color) + .tree-menu--item &.-selected background: var(--main-menu-bg-selected-background) + .tree-menu--title color: var(--main-menu-selected-font-color) + &:hover background: var(--main-menu-bg-hover-background) + .tree-menu--title color: var(--main-menu-hover-font-color) text-decoration: none @@ -312,50 +337,46 @@ a.main-menu--parent-node // Resizer & toggle styles .main-menu--resizer background: none - height: 100vh - width: 18px + height: 100% + width: 1rem position: fixed - top: 0 - border-left-width: 2px - border-left-style: solid - border-left-color: transparent - left: calc(var(--main-menu-width) - 2px) - vertical-align: middle + display: flex + align-items: center + left: calc(var(--main-menu-width) - 1rem) z-index: 1 cursor: col-resize - &:hover - border-left-color: var(--main-menu-resizer-color) - i:before - color: var(--main-menu-resizer-color) - &.show - left: var(--main-menu-folded-width) - -.resizer-toggle-container - margin-top: 50vh - margin-left: -12px - display: inline-block + .main-menu--navigation-toggler + position: relative cursor: pointer - &:before - @include icon-common - font-size: 11px - font-weight: 400 - &:not(.open):before - @include icon-mixin-arrow-right2 - position: absolute - right: 0 + color: var(--main-menu-font-color) + + > .collapse-menu, + > .expand-menu + display: none + + &.open:hover + > .resize-handle + display: none + + > .collapse-menu + display: block + + &:not(.open) + left: 1rem + color: var(--accent-color) + + > .resize-handle + display: none + + > .expand-menu + display: block + &:hover - color: var(--main-menu-resizer-color) + border-right: 0.125rem solid var(--main-menu-border-color) + .main-menu--navigation-toggler - background-color: var(--main-menu-bg-color) - &.open:before - @include icon-mixin-arrow-left2 - i - display: inline-block - width: 12px - &:before - vertical-align: middle - color: var(--light-gray) + color: var(--main-menu-border-color) // Badges for menu items such as "EXPERIMENTAL" or "BETA" $badge_offset: 4px diff --git a/frontend/src/global_styles/openproject/_variable_defaults.scss b/frontend/src/global_styles/openproject/_variable_defaults.scss index a6e368dd65b4..82b2a94c7440 100644 --- a/frontend/src/global_styles/openproject/_variable_defaults.scss +++ b/frontend/src/global_styles/openproject/_variable_defaults.scss @@ -95,6 +95,7 @@ --main-menu-font-size: 14px; --main-menu-fieldset-header-color: #B0B2B3; --main-menu-hover-border-color: transparent; + --main-menu-bg-selected-border: transparent; --toolbar-title-color: #5F5F5F; --toolbar-item--bg-color: #F8F8F8; --toolbar-item--bg-color-pressed: var(--gray-lighter); diff --git a/frontend/src/stimulus/controllers/dynamic/admin/work-packages-settings.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-settings.controller.ts new file mode 100644 index 000000000000..f805ff85473a --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/work-packages-settings.controller.ts @@ -0,0 +1,69 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +/* + * Helps keep daysPerWeek and daysPerMonth in-line with each other + */ +export default class WorkPackagesSettingsController extends Controller { + static targets = [ + 'progressCalculationModeSelect', + 'warningText', + 'warningToast', + ]; + + declare readonly progressCalculationModeSelectTarget:HTMLSelectElement; + declare readonly warningTextTarget:HTMLElement; + declare readonly warningToastTarget:HTMLElement; + private initialMode:string; + + connect() { + this.initialMode = this.progressCalculationModeSelectTarget.value; + } + + displayWarning() { + const warningMessageHtml = this.getWarningMessageHtml(); + if (warningMessageHtml) { + this.warningTextTarget.innerHTML = warningMessageHtml; + this.warningToastTarget.hidden = false; + } else { + this.warningToastTarget.hidden = true; + } + } + + getWarningMessageHtml():string { + const newMode = this.progressCalculationModeSelectTarget.value; + return I18n.t( + `js.admin.work_packages_settings.warning_progress_calculation_mode_change_from_${this.initialMode}_to_${newMode}_html`, + { defaultValue: '' }, + ); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/admin/working-days-and-hours.controller.ts b/frontend/src/stimulus/controllers/dynamic/admin/working-days-and-hours.controller.ts new file mode 100644 index 000000000000..69298a5fe7a3 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/admin/working-days-and-hours.controller.ts @@ -0,0 +1,58 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +/* + * Helps keep daysPerWeek and daysPerMonth in-line with each other + */ +export default class WorkingDaysAndHoursController extends Controller { + static targets = [ + 'daysPerWeekInput', + 'daysPerMonthInput', + ]; + + declare readonly daysPerWeekInputTarget:HTMLInputElement; + declare readonly daysPerMonthInputTarget:HTMLInputElement; + + connect() {} + + recalculateDaysPerWeek() { + const daysPerMonth = parseFloat(this.daysPerMonthInputTarget.value); + const daysPerWeek = daysPerMonth / 4; + this.daysPerWeekInputTarget.value = daysPerWeek.toString(); + } + + recalculateDaysPerMonth() { + const daysPerWeek = parseFloat(this.daysPerWeekInputTarget.value); + const daysPerMonth = daysPerWeek * 4; + this.daysPerMonthInputTarget.value = daysPerMonth.toString(); + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/filters.controller.ts b/frontend/src/stimulus/controllers/dynamic/filters.controller.ts index 88ab9e95f1cc..4d9a3116fc68 100644 --- a/frontend/src/stimulus/controllers/dynamic/filters.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/filters.controller.ts @@ -74,6 +74,11 @@ export default class FiltersController extends Controller { declare displayFiltersValue:boolean; declare outputFormatValue:string; + connect() { + const urlParams = new URLSearchParams(window.location.search); + this.displayFiltersValue = urlParams.has('filters'); + } + toggleDisplayFilters() { this.displayFiltersValue = !this.displayFiltersValue; } @@ -85,9 +90,9 @@ export default class FiltersController extends Controller { toggleButtonActive() { if (this.displayFiltersValue) { - this.filterFormToggleTarget.setAttribute('aria-disabled', 'true'); + this.filterFormToggleTarget.setAttribute('aria-pressed', 'true'); } else { - this.filterFormToggleTarget.removeAttribute('aria-disabled'); + this.filterFormToggleTarget.removeAttribute('aria-pressed'); } } diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/available-project-mappings-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/available-project-mappings-filter.controller.ts new file mode 100644 index 000000000000..3f568a37e7a8 --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/available-project-mappings-filter.controller.ts @@ -0,0 +1,73 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 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. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class AvailableProjectMappingsFilterController extends Controller { + static targets = [ + 'filter', + 'searchItem', + ]; + + declare readonly filterTarget:HTMLInputElement; + declare readonly searchItemTargets:HTMLInputElement[]; + + connect():void { + this.element.querySelector('#available-project-mappings-filter-clear-button')?.addEventListener('click', () => { + this.resetFilterViaClearButton(); + }); + } + + disconnect():void { + this.element.querySelector('#available-project-mappings-filter-clear-button')?.removeEventListener('click', () => { + this.resetFilterViaClearButton(); + }); + } + + filterLists() { + const query = this.filterTarget.value.toLowerCase(); + + this.searchItemTargets.forEach((item) => { + const text = item.textContent?.toLowerCase(); + + if (text?.includes(query)) { + (item as HTMLElement).classList.remove('d-none'); + } else { + (item as HTMLElement).classList.add('d-none'); + } + }); + } + + resetFilterViaClearButton() { + this.searchItemTargets.forEach((item) => { + (item as HTMLElement).classList.remove('d-none'); + }); + } +} diff --git a/frontend/src/stimulus/controllers/op-application.controller.ts b/frontend/src/stimulus/controllers/op-application.controller.ts index 375065e3e901..e1234cc820e9 100644 --- a/frontend/src/stimulus/controllers/op-application.controller.ts +++ b/frontend/src/stimulus/controllers/op-application.controller.ts @@ -26,7 +26,7 @@ export class OpApplicationController extends ApplicationController { /** * Derive dynamic path from controller name. * - * Stimlus conventions allow subdirectories to be used by double dashes. + * Stimulus conventions allow subdirectories to be used by double dashes. * We convert these to slashes for the dynamic import. * * https://stimulus.hotwired.dev/handbook/installing#controller-filenames-map-to-identifiers diff --git a/lib/api/v3/configuration/configuration_representer.rb b/lib/api/v3/configuration/configuration_representer.rb index e5e51be1c568..b10f31f8c555 100644 --- a/lib/api/v3/configuration/configuration_representer.rb +++ b/lib/api/v3/configuration/configuration_representer.rb @@ -76,6 +76,15 @@ class ConfigurationRepresenter < ::API::Decorators::Single }, render_nil: true + property :hours_per_day, + render_nil: true + + property :days_per_week, + render_nil: true + + property :days_per_month, + render_nil: true + property :host_name, getter: ->(*) { Setting.host_name diff --git a/lib/api/v3/statuses/status_representer.rb b/lib/api/v3/statuses/status_representer.rb index 7012a529324b..1c5b041882a8 100644 --- a/lib/api/v3/statuses/status_representer.rb +++ b/lib/api/v3/statuses/status_representer.rb @@ -42,6 +42,7 @@ class StatusRepresenter < ::API::Decorators::Single render_nil: true property :is_default, render_nil: true property :is_readonly, render_nil: true + property :excluded_from_totals, render_nil: true property :default_done_ratio, render_nil: true property :position, render_nil: true diff --git a/lib/open_project/journal_formatter/cause.rb b/lib/open_project/journal_formatter/cause.rb index cd316b8fcd67..ae0a8385f44a 100644 --- a/lib/open_project/journal_formatter/cause.rb +++ b/lib/open_project/journal_formatter/cause.rb @@ -32,48 +32,57 @@ class OpenProject::JournalFormatter::Cause < JournalFormatter::Base include OpenProject::StaticRouting::UrlHelpers include OpenProject::ObjectLinking + attr_reader :cause + def render(_key, values, options = { html: true }) - cause = values.last + @cause = values.last + @html = options[:html] - if options[:html] - "#{content_tag(:strong, cause_type_translation(cause['type']))} #{cause_description(cause, true)}" - else - "#{cause_type_translation(cause['type'])} #{cause_description(cause, false)}" - end + "#{caused_change} #{cause_description}" end private - def cause_type_translation(type) - mapped_type = mapped_cause_type(type) - I18n.t("journals.caused_changes.#{mapped_type}", default: mapped_type) + def html? + @html + end + + def caused_change + caused_change_text = I18n.t("journals.caused_changes.#{mapped_cause_type}", + default: mapped_cause_type, + status_name: cause["status_name"]) + if html? + content_tag(:strong, caused_change_text) + else + caused_change_text + end end - def mapped_cause_type(type) - case type + def mapped_cause_type + case cause["type"] when /changed_times/, "working_days_changed" "dates_changed" else - type + cause["type"] end end - def cause_description(cause, html) + def cause_description case cause["type"] when "system_update" - system_update_message(cause, html) + system_update_message when "working_days_changed" working_days_changed_message(cause["changed_days"]) - when "status_p_complete_changed" - status_p_complete_changed_message(cause, html) + when "status_changed" + status_changed_message when "progress_mode_changed_to_status_based" progress_mode_changed_to_status_based_message else - related_work_package_changed_message(cause, html) + related_work_package_changed_message end end - def system_update_message(cause, html) + def system_update_message feature = cause["feature"] feature = "progress_calculation_adjusted" if feature == "progress_calculation_changed" @@ -88,7 +97,7 @@ def system_update_message(cause, html) {} end message = I18n.t("journals.cause_descriptions.system_update.#{feature}", **options) - html ? message : Sanitize.fragment(message) + html? ? message : Sanitize.fragment(message) end def working_days_changed_message(changed_dates) @@ -112,24 +121,35 @@ def working_date_change_message(date, working) date: I18n.l(Date.parse(date))) end - def status_p_complete_changed_message(cause, html) - cause.symbolize_keys => { status_name:, status_p_complete_change: [old_value, new_value]} - status_name = html_escape(status_name) if html + def status_changed_message + cause["status_changes"] + .sort + .map { |change| status_change_partial_message(change) } + .to_sentence + end - I18n.t("journals.cause_descriptions.status_p_complete_changed", status_name:, old_value:, new_value:) + def status_change_partial_message(change) + case change + in ["default_done_ratio", [old_value, new_value]] + I18n.t("journals.cause_descriptions.status_percent_complete_changed", old_value:, new_value:) + in ["excluded_from_totals", [true, false]] + I18n.t("journals.cause_descriptions.status_excluded_from_totals_set_to_false_message") + in ["excluded_from_totals", [false, true]] + I18n.t("journals.cause_descriptions.status_excluded_from_totals_set_to_true_message") + end end def progress_mode_changed_to_status_based_message I18n.t("journals.cause_descriptions.progress_mode_changed_to_status_based") end - def related_work_package_changed_message(cause, html) + def related_work_package_changed_message related_work_package = WorkPackage.includes(:project).visible(User.current).find_by(id: cause["work_package_id"]) if related_work_package I18n.t( "journals.cause_descriptions.#{cause['type']}", - link: html ? link_to_work_package(related_work_package, all_link: true) : "##{related_work_package.id}" + link: html? ? link_to_work_package(related_work_package, all_link: true) : "##{related_work_package.id}" ) else diff --git a/lib/open_project/patches/mailer_controller_preview.rb b/lib/open_project/patches/mailer_controller_preview.rb index aa68045c9683..081ae55ff406 100644 --- a/lib/open_project/patches/mailer_controller_preview.rb +++ b/lib/open_project/patches/mailer_controller_preview.rb @@ -41,6 +41,6 @@ def extend_content_security_policy end end -OpenProject::Patches.patch_gem_version "rails", "7.1.3.3" do +OpenProject::Patches.patch_gem_version "rails", "7.1.3.4" do Rails::MailersController.include OpenProject::Patches::MailerControllerCsp end diff --git a/lib/open_project/version.rb b/lib/open_project/version.rb index efc9bec9f5ee..898a286f6fef 100644 --- a/lib/open_project/version.rb +++ b/lib/open_project/version.rb @@ -138,7 +138,7 @@ def revision_from_git def read_optional(file) path = Rails.root.join(file) if File.exist? path - File.read(path) + String(File.read(path)).strip end end diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_formatter.rb b/lib_static/plugins/acts_as_journalized/lib/journal_formatter.rb index e551577fc5e1..4eef2b950f1b 100644 --- a/lib_static/plugins/acts_as_journalized/lib/journal_formatter.rb +++ b/lib_static/plugins/acts_as_journalized/lib/journal_formatter.rb @@ -52,6 +52,7 @@ require_relative "journal_formatter_cache" require_relative "journal_formatter/base" require_relative "journal_formatter/attribute" +require_relative "journal_formatter/chronic_duration" require_relative "journal_formatter/datetime" require_relative "journal_formatter/day_count" require_relative "journal_formatter/decimal" @@ -76,6 +77,7 @@ def self.register_formatted_field(journal_data_type, field, formatter_key) def self.default_formatters { + chronic_duration: JournalFormatter::ChronicDuration, datetime: JournalFormatter::Datetime, day_count: JournalFormatter::DayCount, decimal: JournalFormatter::Decimal, diff --git a/lib_static/plugins/acts_as_journalized/lib/journal_formatter/chronic_duration.rb b/lib_static/plugins/acts_as_journalized/lib/journal_formatter/chronic_duration.rb new file mode 100644 index 000000000000..e83b035f6aa9 --- /dev/null +++ b/lib_static/plugins/acts_as_journalized/lib/journal_formatter/chronic_duration.rb @@ -0,0 +1,41 @@ +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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 JournalFormatter + class ChronicDuration < Attribute + def format_values(values) + values.map do |v| + if v.nil? + nil + else + ::DurationConverter.output(v.to_f) + end + end + end + end +end diff --git a/lookbook/previews/open_project/common/filters_component_preview.rb b/lookbook/previews/open_project/common/filters_component_preview.rb deleted file mode 100644 index 2050984ab684..000000000000 --- a/lookbook/previews/open_project/common/filters_component_preview.rb +++ /dev/null @@ -1,23 +0,0 @@ -module OpenProject - module Common - # @logical_path OpenProject/Common - class FiltersComponentPreview < Lookbook::Preview - def default - @query = Queries::Projects::ProjectQuery.new - render(Projects::ProjectsFiltersComponent.new(query: @query)) do |component| - component.with_button( - tag: :a, - href: "", - scheme: :primary, - size: :medium, - aria: { label: I18n.t(:label_project_new) }, - data: { "test-selector": "project-new-button" } - ) do |button| - button.with_leading_visual_icon(icon: :plus) - Project.model_name.human - end - end - end - end - end -end diff --git a/lookbook/previews/open_project/filter/filter_button_component_preview.rb b/lookbook/previews/open_project/filter/filter_button_component_preview.rb new file mode 100644 index 000000000000..b16455531d6b --- /dev/null +++ b/lookbook/previews/open_project/filter/filter_button_component_preview.rb @@ -0,0 +1,20 @@ +module OpenProject + module Filter + # @logical_path OpenProject/Filter + class FilterButtonComponentPreview < Lookbook::Preview + def default + @query = Queries::Projects::ProjectQuery.new + render(::Filter::FilterButtonComponent.new(query: @query)) + end + + # @label With toggable filter section + # There is a stimulus controller, which can toggle the visibility of an FilterComponent with the help of a FilterButton. + # Just register the controller in a container around both elements. + # Unfortunately, stimulus controllers do not work in our lookbook as of now, so you will see no effect. + def filter_section_toggle + @query = Queries::Projects::ProjectQuery.new + render_with_template(locals: { query: @query }) + end + end + end +end diff --git a/lookbook/previews/open_project/filter/filter_button_component_preview/filter_section_toggle.html.erb b/lookbook/previews/open_project/filter/filter_button_component_preview/filter_section_toggle.html.erb new file mode 100644 index 000000000000..96432346e2e1 --- /dev/null +++ b/lookbook/previews/open_project/filter/filter_button_component_preview/filter_section_toggle.html.erb @@ -0,0 +1,7 @@ +
+ <%= render ::Filter::FilterButtonComponent.new(query: query) %> + <%= render Projects::ProjectsFiltersComponent.new(query: query) %> +
diff --git a/lookbook/previews/open_project/filter/filters_component_preview.rb b/lookbook/previews/open_project/filter/filters_component_preview.rb new file mode 100644 index 000000000000..13646dc4fcb7 --- /dev/null +++ b/lookbook/previews/open_project/filter/filters_component_preview.rb @@ -0,0 +1,11 @@ +module OpenProject + module Filter + # @logical_path OpenProject/Filter + class FiltersComponentPreview < Lookbook::Preview + def default + @query = Queries::Projects::ProjectQuery.new + render(Projects::ProjectsFiltersComponent.new(query: @query)) + end + end + end +end diff --git a/modules/avatars/config/locales/crowdin/et.yml b/modules/avatars/config/locales/crowdin/et.yml index 85920789a83e..ac8e181e58f0 100644 --- a/modules/avatars/config/locales/crowdin/et.yml +++ b/modules/avatars/config/locales/crowdin/et.yml @@ -10,7 +10,7 @@ et: label_choose_avatar: "Choose Avatar from file" message_avatar_uploaded: "Avatar changed successfully." error_image_upload: "Error saving the image." - error_image_size: "The image is too large." + error_image_size: "Pilt on liiga suur." button_change_avatar: "Change avatar" are_you_sure_delete_avatar: "Are you sure you want to delete your avatar?" avatar_deleted: "Avatar deleted successfully." diff --git a/modules/bim/config/locales/crowdin/id.seeders.yml b/modules/bim/config/locales/crowdin/id.seeders.yml index 8c3340ee9f67..d454cd8579e0 100644 --- a/modules/bim/config/locales/crowdin/id.seeders.yml +++ b/modules/bim/config/locales/crowdin/id.seeders.yml @@ -13,7 +13,7 @@ id: item_2: name: High item_3: - name: Critical + name: Kritik statuses: item_0: name: Baru @@ -42,7 +42,7 @@ id: item_4: name: Remark item_5: - name: Request + name: Permintaan item_6: name: Clash global_queries: @@ -694,7 +694,7 @@ id: item_5: name: Tahapan pencapaian item_6: - name: Tasks + name: Tugas item_7: name: Rencana tim boards: @@ -704,7 +704,7 @@ id: widgets: item_0: options: - name: Welcome + name: Selamat datang item_1: options: name: Getting started diff --git a/modules/bim/config/locales/crowdin/id.yml b/modules/bim/config/locales/crowdin/id.yml index b09848242bdc..77bc93dfeabe 100644 --- a/modules/bim/config/locales/crowdin/id.yml +++ b/modules/bim/config/locales/crowdin/id.yml @@ -107,30 +107,30 @@ id: uuid_already_taken: "Tidak dapat mengimpor masalah BCF ini karena sudah ada masalah lain dengan GUID yang sama. Mungkinkah masalah BCF ini sudah diimpor ke proyek lain?" ifc_models: label_ifc_models: 'Model IFC' - label_new_ifc_model: 'New IFC model' - label_show_defaults: 'Show defaults' - label_default_ifc_models: 'Default IFC models' - label_edit_defaults: 'Edit defaults' + label_new_ifc_model: 'Model IFC baru' + label_show_defaults: 'Tampilkan bawaan' + label_default_ifc_models: 'Model bawaan IFC' + label_edit_defaults: 'Edit pengaturan bawaan' no_defaults_warning: - title: 'No IFC model was set as default for this project.' - check_1: 'Check that you have uploaded at least one IFC model.' + title: 'Tidak ada model IFC yang ditetapkan sebagai bawaan dalam projek ini.' + check_1: 'Pastikan anda telah mengunggah setidaknya satu model IFC.' check_2: 'Check that at least one IFC model is set to "Default".' - no_results: "No IFC models have been uploaded in this project." + no_results: "Tidak ada model IFC yang diunggah dalam proyek ini." conversion_status: - label: 'Processing?' - pending: 'Pending' - processing: 'Processing' - completed: 'Completed' + label: 'Memproses?' + pending: 'Menunggu' + processing: 'Memproses' + completed: 'Selesai' error: 'Eror' processing_notice: - processing_default: 'The following default IFC models are still being processed and are thus not available, yet:' + processing_default: 'Model IFC bawaan berikut ini masih diproses dan belum tersedia sekarang:' flash_messages: - upload_successful: 'Upload succeeded. It will now get processed and will be ready to use in a couple of minutes.' + upload_successful: 'Pengunggahan sukses. File akan diproses dan akan siap digunakan dalam beberapa menit.' conversion: - missing_commands: "The following IFC converter commands are missing on this system: %{names}" + missing_commands: "Perintah IFC converter berikut ini tidak ditemukan dalam perangkat ini: %{names}\n" project_module_ifc_models: "Model IFC" - permission_view_ifc_models: "View IFC models" - permission_manage_ifc_models: "Import and manage IFC models" + permission_view_ifc_models: "Lihat model IFC" + permission_manage_ifc_models: "Import dan sesuaikan model IFC" extraction: available: ifc_convert: "IFC conversion pipeline available" diff --git a/modules/boards/config/locales/crowdin/ar.yml b/modules/boards/config/locales/crowdin/ar.yml index 85cb3a8ca23b..21ed45cdd902 100644 --- a/modules/boards/config/locales/crowdin/ar.yml +++ b/modules/boards/config/locales/crowdin/ar.yml @@ -23,17 +23,17 @@ ar: basic: Basic board_type_descriptions: basic: > - Start from scratch with a blank board. Manually add cards and columns to this board. + ابدأ من الصفر بلوحة فارغة. قم بإضافة البطاقات والأعمدة إلى هذه اللوحة يدويًا. status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + لوحة أساسية بنمط كانبان مع أعمدة للحالة مثل: المهام، قيد التنفيذ، تم الإنجاز. assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + لوحة بأعمدة تلقائية بناءً على المستخدمين المعينين. وتعتبر لوحة مثالية لتوزيع حزم العمل. version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + لوحة بأعمدة تلقائية بناءً على الإصدار. و هي مثالية لتخطيط تطوير المنتجات. subproject: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + لوحة بأعمدة تلقائية للمشاريع الفرعية. يؤدي سحب حزم العمل إلى قوائم أخرى إلى تحديث المشروع (أو المشروع الفرعي) وفقًا لذلك. subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + لوحة بأعمدة تلقائية للعناصر الفرعية. يؤدي سحب حزم العمل إلى قوائم أخرى إلى تحديث العنصر الرئيسي وفقًا لذلك. upsale: - teaser_text: 'Would you like to automate your workflows with Boards? Advanced boards are an Enterprise add-on. Please upgrade to a paid plan.' + teaser_text: 'هل ترغب في أتمتة سير العمل الخاص بك باستخدام اللوحات؟ اللوحات المتقدمة هي إضافة خاصة بالمؤسسات. يرجى الترقية إلى خطة مدفوعة.' upgrade: 'الترقية الآن' diff --git a/modules/boards/config/locales/crowdin/no.yml b/modules/boards/config/locales/crowdin/no.yml index e233de4921c3..4910340d70cc 100644 --- a/modules/boards/config/locales/crowdin/no.yml +++ b/modules/boards/config/locales/crowdin/no.yml @@ -15,7 +15,7 @@ free: Enkel action: "Handlingstavle (%{attribute})" board_type_attributes: - assignee: Deltaker + assignee: Utførende status: Status version: Versjon subproject: Underprosjekt diff --git a/modules/calendar/config/locales/crowdin/ar.yml b/modules/calendar/config/locales/crowdin/ar.yml index ba85841ad341..2fc8832c8138 100644 --- a/modules/calendar/config/locales/crowdin/ar.yml +++ b/modules/calendar/config/locales/crowdin/ar.yml @@ -4,9 +4,9 @@ ar: name: "OpenProject Calendar" description: "Provides calendar views." label_calendar: "التقويم" - label_calendar_plural: "Calendars" - label_new_calendar: "New calendar" + label_calendar_plural: "التقويمات" + label_new_calendar: "تقويم جديد" permission_view_calendar: "View calendars" - permission_manage_calendars: "Manage calendars" - permission_share_calendars: "Subscribe to iCalendars" - project_module_calendar_view: "Calendars" + permission_manage_calendars: "إدارة التقويمات" + permission_share_calendars: "الاشتراك في iCalendar" + project_module_calendar_view: "التقويمات" diff --git a/modules/calendar/config/locales/crowdin/et.yml b/modules/calendar/config/locales/crowdin/et.yml index 826e2f640b47..5a131281ac13 100644 --- a/modules/calendar/config/locales/crowdin/et.yml +++ b/modules/calendar/config/locales/crowdin/et.yml @@ -4,9 +4,9 @@ et: name: "OpenProject Calendar" description: "Provides calendar views." label_calendar: "Kalender" - label_calendar_plural: "Calendars" - label_new_calendar: "New calendar" - permission_view_calendar: "View calendars" - permission_manage_calendars: "Manage calendars" + label_calendar_plural: "$Kalendrid" + label_new_calendar: "Uus Kalender" + permission_view_calendar: "Vaata kalendrit" + permission_manage_calendars: "Halda kalendreid" permission_share_calendars: "Subscribe to iCalendars" - project_module_calendar_view: "Calendars" + project_module_calendar_view: "$Kalendrid" diff --git a/modules/costs/spec/features/time_entries_spec.rb b/modules/costs/spec/features/time_entries_spec.rb index 3d6002cc3689..cb0a76abe215 100644 --- a/modules/costs/spec/features/time_entries_spec.rb +++ b/modules/costs/spec/features/time_entries_spec.rb @@ -54,7 +54,7 @@ let(:wp_table) { Pages::WorkPackagesTable.new(project) } let(:query) do - query = build(:query, user:, project:) + query = build(:query, user:, project:) query.column_names = %w(id subject spent_hours) query.save! @@ -73,8 +73,8 @@ parent_row = wp_table.row(parent) wp_row = wp_table.row(work_package) - expect(parent_row).to have_css(".inline-edit--container.spentTime", text: "12.5 h") - expect(wp_row).to have_css(".inline-edit--container.spentTime", text: "2.5 h") + expect(parent_row).to have_css(".inline-edit--container.spentTime", text: "1d 4h 30m") + expect(wp_row).to have_css(".inline-edit--container.spentTime", text: "2h 30m") end it "creates an activity" do diff --git a/modules/costs/spec/features/view_own_rates_spec.rb b/modules/costs/spec/features/view_own_rates_spec.rb index ae9d5b4b798f..34c5a072d34d 100644 --- a/modules/costs/spec/features/view_own_rates_spec.rb +++ b/modules/costs/spec/features/view_own_rates_spec.rb @@ -109,7 +109,7 @@ it "only displays own entries and rates" do # All the values do not include the entries made by the other user - wp_page.expect_attributes spent_time: "1 h", + wp_page.expect_attributes spent_time: "1h", costs_by_type: "2 Translations", overall_costs: "24.00 EUR", labor_costs: "10.00 EUR", diff --git a/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb b/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb index d2855acbcefd..8198cb5470c4 100644 --- a/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb +++ b/modules/documents/app/services/notifications/create_from_model_service/document_strategy.rb @@ -35,15 +35,15 @@ def self.permission :view_documents end - def self.supports_ian? + def self.supports_ian?(_reason) false end - def self.supports_mail_digest? + def self.supports_mail_digest?(_reason) false end - def self.supports_mail? + def self.supports_mail?(_reason) true end diff --git a/modules/gantt/config/locales/crowdin/js-ar.yml b/modules/gantt/config/locales/crowdin/js-ar.yml index abdce33ccb58..656b1c08ba97 100644 --- a/modules/gantt/config/locales/crowdin/js-ar.yml +++ b/modules/gantt/config/locales/crowdin/js-ar.yml @@ -1,6 +1,6 @@ ar: js: queries: - all_open: 'All open' - timeline: 'Timeline' - milestones: 'Milestones' + all_open: 'كل المفتوح' + timeline: 'الخط الزمني' + milestones: 'الأحداث الرئيسية' diff --git a/modules/github_integration/app/components/deploy_targets/row_component.rb b/modules/github_integration/app/components/deploy_targets/row_component.rb new file mode 100644 index 000000000000..7c51367f4316 --- /dev/null +++ b/modules/github_integration/app/components/deploy_targets/row_component.rb @@ -0,0 +1,54 @@ +# 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 DeployTargets + class RowComponent < ::RowComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + property :host, :type + + def deploy_target + model + end + + def created_at + helpers.format_time deploy_target.created_at + end + + def button_links + [delete_link] + end + + def delete_link + link_to I18n.t(:button_delete), + deploy_target_path(deploy_target, back_url: request.fullpath), + method: :delete, + class: "icon icon-delete" + end + end +end diff --git a/app/contracts/settings/working_days_params_contract.rb b/modules/github_integration/app/components/deploy_targets/table_component.rb similarity index 71% rename from app/contracts/settings/working_days_params_contract.rb rename to modules/github_integration/app/components/deploy_targets/table_component.rb index 42bacb779bab..2cd46d501f25 100644 --- a/app/contracts/settings/working_days_params_contract.rb +++ b/modules/github_integration/app/components/deploy_targets/table_component.rb @@ -1,3 +1,5 @@ +# frozen_string_literal: true + #-- copyright # OpenProject is an open source project management software. # Copyright (C) 2012-2024 the OpenProject GmbH @@ -26,29 +28,23 @@ # See COPYRIGHT and LICENSE files for more details. #++ -module Settings - class WorkingDaysParamsContract < ::ParamsContract - include RequiresAdminGuard - - validate :working_days_are_present - validate :unique_job +module DeployTargets + class TableComponent < ::TableComponent # rubocop:disable OpenProject/AddPreviewForViewComponent + columns :host, :type, :created_at + options :current_user - protected - - def working_days_are_present - if working_days.blank? - errors.add :base, :working_days_are_missing - end + def initial_sort + %i[id asc] end - def unique_job - WorkPackages::ApplyWorkingDaysChangeJob.new.check_concurrency do - errors.add :base, :previous_working_day_changes_unprocessed + def headers + columns.map do |name| + [name.to_s, header_options(name)] end end - def working_days - params[:working_days] + def header_options(name) + { caption: User.human_attribute_name(name) } end end end diff --git a/spec/contracts/settings/working_days_params_contract_spec.rb b/modules/github_integration/app/controllers/deploy_targets_controller.rb similarity index 51% rename from spec/contracts/settings/working_days_params_contract_spec.rb rename to modules/github_integration/app/controllers/deploy_targets_controller.rb index ddd60516a286..ed64c7d88fb4 100644 --- a/spec/contracts/settings/working_days_params_contract_spec.rb +++ b/modules/github_integration/app/controllers/deploy_targets_controller.rb @@ -26,38 +26,43 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require "spec_helper" -require "contracts/shared/model_contract_shared_context" - -RSpec.describe Settings::WorkingDaysParamsContract do - include_context "ModelContract shared context" - shared_let(:current_user) { create(:admin) } - let(:setting) { Setting } - let(:params) { { working_days: [1] } } - let(:contract) do - described_class.new(setting, current_user, params:) - end +class DeployTargetsController < ApplicationController + layout "admin" - it_behaves_like "contract is valid for active admins and invalid for regular users" + before_action :require_admin - context "without working days" do - let(:params) { { working_days: [] } } + def index + @deploy_targets = DeployTarget.all + end - include_examples "contract is invalid", base: :working_days_are_missing + def new + @deploy_target = DeployTarget.new type: "OpenProject" end - context "with an ApplyWorkingDaysChangeJob already existing", - with_good_job: WorkPackages::ApplyWorkingDaysChangeJob do - let(:params) { { working_days: [1, 2, 3] } } + def create + args = params + .permit("deploy_target" => ["host", "type", "api_key"])[:deploy_target] + .to_h + .merge(type: "OpenProject") + + @deploy_target = DeployTarget.create **args + + if @deploy_target.persisted? + flash[:success] = I18n.t(:notice_deploy_target_created) - before do - WorkPackages::ApplyWorkingDaysChangeJob - .set(wait: 10.minutes) # GoodJob executes inline job without wait immediately - .perform_later(user_id: current_user.id, - previous_non_working_days: [], - previous_working_days: [1, 2, 3, 4]) + redirect_to deploy_targets_path + else + render "new" end + end + + def destroy + deploy_target = DeployTarget.find params[:id] + + deploy_target.destroy! + + flash[:success] = I18n.t(:notice_deploy_target_destroyed) - include_examples "contract is invalid", base: :previous_working_day_changes_unprocessed + redirect_to deploy_targets_path end end diff --git a/modules/github_integration/app/models/deploy_status_check.rb b/modules/github_integration/app/models/deploy_status_check.rb new file mode 100644 index 000000000000..670bfb321ad5 --- /dev/null +++ b/modules/github_integration/app/models/deploy_status_check.rb @@ -0,0 +1,40 @@ +#-- 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. +#++ + +class DeployStatusCheck < ApplicationRecord + belongs_to :github_pull_request + belongs_to :deploy_target + + validates_presence_of :core_sha + + delegate :merge_commit_sha, to: :github_pull_request + + def pull_request + github_pull_request + end +end diff --git a/modules/github_integration/app/models/deploy_target.rb b/modules/github_integration/app/models/deploy_target.rb new file mode 100644 index 000000000000..cb4d5942193e --- /dev/null +++ b/modules/github_integration/app/models/deploy_target.rb @@ -0,0 +1,43 @@ +#-- 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. +#++ + +class DeployTarget < ApplicationRecord + self.inheritance_column = nil + + store_accessor :options, :api_key + + has_many :deploy_status_checks, dependent: :destroy + + validates_presence_of :host + validates_uniqueness_of :host + + validates_presence_of :type + + # this is very much specific to the only type of target we support for now, OpenProject + validates_presence_of :api_key +end diff --git a/modules/github_integration/app/models/github_pull_request.rb b/modules/github_integration/app/models/github_pull_request.rb index 4b53509d7ba1..5cef02b4dfbd 100644 --- a/modules/github_integration/app/models/github_pull_request.rb +++ b/modules/github_integration/app/models/github_pull_request.rb @@ -31,12 +31,14 @@ class GithubPullRequest < ApplicationRecord has_and_belongs_to_many :work_packages has_many :github_check_runs, dependent: :destroy + has_many :deploy_status_checks, dependent: :destroy belongs_to :github_user, optional: true belongs_to :merged_by, optional: true, class_name: "GithubUser" enum state: { open: "open", - closed: "closed" + closed: "closed", + deployed: "deployed" } validates_presence_of :github_html_url, diff --git a/modules/github_integration/app/views/deploy_targets/_form.html.erb b/modules/github_integration/app/views/deploy_targets/_form.html.erb new file mode 100644 index 000000000000..34c7efcf5744 --- /dev/null +++ b/modules/github_integration/app/views/deploy_targets/_form.html.erb @@ -0,0 +1,58 @@ +<%#-- 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. + +++#%> +
+
+ <%= f.text_field :host, required: true, container_class: '-middle' %> +
+ +
+ <%= f.select :type, + ['OpenProject'], + { container_class: '-slim' }, + disabled: true, + required: true + %> + + <%= t(:text_deploy_target_type_info) %> + +
+ + <%# This field is specific to the only type of DeployTarget we support for now, which is OpenProject. %> +
+ <%= f.text_field "api_key", container_class: '-wide' %> + + <%= link_translate( + :text_deploy_target_api_key_info, + links: { + docs_url: "https://www.openproject.org/docs/api/introduction/#api-key-through-basic-auth" + } + ) %> + +
+
diff --git a/modules/github_integration/app/views/deploy_targets/index.html.erb b/modules/github_integration/app/views/deploy_targets/index.html.erb new file mode 100644 index 000000000000..1b564473e841 --- /dev/null +++ b/modules/github_integration/app/views/deploy_targets/index.html.erb @@ -0,0 +1,40 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t(:label_github_integration) %> +<%= toolbar title: t(:label_deploy_target_plural) do %> +
  • + + <%= op_icon('button--icon icon-add') %> + <%= t(:label_deploy_target) %> + +
  • +<% end %> + +<%= render DeployTargets::TableComponent.new(rows: @deploy_targets, current_user: ) %> diff --git a/modules/github_integration/app/views/deploy_targets/new.html.erb b/modules/github_integration/app/views/deploy_targets/new.html.erb new file mode 100644 index 000000000000..f47dcca016da --- /dev/null +++ b/modules/github_integration/app/views/deploy_targets/new.html.erb @@ -0,0 +1,40 @@ +<%#-- 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. + +++#%> + +<% html_title t(:label_administration), t("label_deploy_target_new") %> +<% local_assigns[:additional_breadcrumb] = t(:label_deploy_target_new) %> + +<%= toolbar title: t(:label_deploy_target_new) %> + +<%= error_messages_for @deploy_target %> + +<%= labelled_tabular_form_for @deploy_target, url: deploy_targets_path do |f| %> + <%= render partial: 'deploy_targets/form', locals: { f: f, deploy_target: @deploy_target } %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<% end %> diff --git a/modules/github_integration/app/workers/cron/check_deploy_status_job.rb b/modules/github_integration/app/workers/cron/check_deploy_status_job.rb new file mode 100644 index 000000000000..1372811aa5d0 --- /dev/null +++ b/modules/github_integration/app/workers/cron/check_deploy_status_job.rb @@ -0,0 +1,193 @@ +#-- 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 Cron + class CheckDeployStatusJob < ApplicationJob + include OpenProject::GithubIntegration::NotificationHandler::Helper + + priority_number :low + + def perform + deploy_targets.find_each do |deploy_target| + sha = openproject_core_sha deploy_target.host, deploy_target.api_key + + if sha.present? + pull_requests.find_each do |pull_request| + check_deploy_status deploy_target, pull_request, sha + end + else + OpenProject.logger.error "Failed to retrieve core SHA for deploy target #{deploy_target.host}" + end + end + end + + def deploy_targets + DeployTarget.all + end + + def pull_requests + GithubPullRequest + .closed + .where.not(merge_commit_sha: nil) + end + + def check_deploy_status(deploy_target, pull_request, core_sha) + status_check = deploy_status_check deploy_target, pull_request + + # we already checked this PR against the given core SHA, so no need to check again + return if status_check.core_sha == core_sha + + # if the commit is contained, it has been deployed + if commit_contains? core_sha, pull_request.merge_commit_sha + update_deploy_status pull_request, deploy_target + else + status_check.update core_sha: # remember last checked SHA to not check twice + end + end + + ## + # Marks the given PR as deployed and removes the last status check + # as it won't be needed anymore. This is because only closed (not yet deployed) + # PRs are ever checked for their deployment status. + def update_deploy_status(pull_request, deploy_target) + host = deploy_target.host + + ActiveRecord::Base.transaction do + delete_status_check pull_request, deploy_target + pull_request.update! state: "deployed" + + comment_on_referenced_work_packages( + pull_request.work_packages, + comment_user, + "[#{pull_request.repository}##{pull_request.number}](#{pull_request.github_html_url}) deployed to [#{host}](https://#{host})" + ) + end + end + + def delete_status_check(pull_request, deploy_target) + # we use `select` and delete it this way to also cover + # not-yet-persisted records + checks = pull_request + .deploy_status_checks + .select { |c| c.deploy_target == deploy_target } + + pull_request.deploy_status_checks.delete(checks) + end + + ## + # Ideally this would be the github user, but we don't really have a way + # to identify it outside of the webhook request cycle. + def comment_user + User.system + end + + def deploy_status_check(deploy_target, pull_request) + pull_request + .deploy_status_checks + .find_or_initialize_by( + deploy_target:, + github_pull_request: pull_request + ) + end + + def openproject_core_sha(host, api_token) + res = introspection_request(host, api_token) + + return nil if handle_request_error res, "Could not get OpenProject core SHA" + + info = JSON.parse res.body.to_s + + info["coreSha"].presence + end + + def introspection_request(host, api_token) + OpenProject.httpx.basic_auth("apikey", api_token).get("http://#{host}/api/v3") + end + + ## + # Uses the GitHub APIs compare endpoint to compare the currently deployed base commit + # and a merge commit from a PR. + # + # If the latter is included in the former, there will be 'ahead_by' and 'behind_by' + # in the response. 'aheady_by' will be 0, 'behind_by' greater than 0. + # + # If the commits are not included in the same branch, these fields + # will not be present at all. + def commit_contains?(base_commit_sha, merge_commit_sha) + data = compare_commits base_commit_sha, merge_commit_sha + + return false if data.nil? + + status_identical?(data) || status_behind?(data) + end + + def status_identical?(data) + status = data["status"].presence + + status == "identical" + end + + def status_behind?(data) + status = data["status"].presence + ahead_by = data["ahead_by"].presence + behind_by = data["behind_by"].presence + + status == "behind" && ahead_by == 0 && (behind_by.present? && behind_by > 0) + end + + def compare_commits(sha_a, sha_b) + res = compare_commits_request sha_a, sha_b + + return nil if handle_request_error res, "Failed to compare commits" + + JSON.parse res.body.read + end + + def handle_request_error(res, error_prefix) + if res.is_a? HTTPX::ErrorResponse + OpenProject.logger.error "#{error_prefix}: #{res.error}" + elsif res.status == 404 + OpenProject.logger.error "#{error_prefix}: not found" + elsif res.status != 200 + OpenProject.logger.error "#{error_prefix}: #{res.body}" + else + return false + end + + true + end + + def compare_commits_request(sha_a, sha_b) + OpenProject.httpx.get(compare_commits_url(sha_a, sha_b)) + end + + def compare_commits_url(sha_a, sha_b) + "https://api.github.com/repos/opf/openproject/compare/#{sha_a}...#{sha_b}" + end + end +end diff --git a/modules/github_integration/config/locales/crowdin/af.yml b/modules/github_integration/config/locales/crowdin/af.yml index bf078ba5074f..a50b60d7fd63 100644 --- a/modules/github_integration/config/locales/crowdin/af.yml +++ b/modules/github_integration/config/locales/crowdin/af.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ af: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ar.yml b/modules/github_integration/config/locales/crowdin/ar.yml index 1d9a37fda1ff..898406d566ac 100644 --- a/modules/github_integration/config/locales/crowdin/ar.yml +++ b/modules/github_integration/config/locales/crowdin/ar.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ar: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" - permission_show_github_content: "Show GitHub content" + permission_show_github_content: "عرض محتوى GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/az.yml b/modules/github_integration/config/locales/crowdin/az.yml index 39d4f01f4312..14c738320d54 100644 --- a/modules/github_integration/config/locales/crowdin/az.yml +++ b/modules/github_integration/config/locales/crowdin/az.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ az: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/be.yml b/modules/github_integration/config/locales/crowdin/be.yml index ecca59cddb7d..0ed3f730b28f 100644 --- a/modules/github_integration/config/locales/crowdin/be.yml +++ b/modules/github_integration/config/locales/crowdin/be.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ be: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/bg.yml b/modules/github_integration/config/locales/crowdin/bg.yml index 2951ef2ed5c1..ef87dda6866b 100644 --- a/modules/github_integration/config/locales/crowdin/bg.yml +++ b/modules/github_integration/config/locales/crowdin/bg.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ bg: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ca.yml b/modules/github_integration/config/locales/crowdin/ca.yml index 281de70a9e8c..f9295b89a765 100644 --- a/modules/github_integration/config/locales/crowdin/ca.yml +++ b/modules/github_integration/config/locales/crowdin/ca.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ca: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Mostra el contingut de GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ckb-IR.yml b/modules/github_integration/config/locales/crowdin/ckb-IR.yml index a196a2b20966..e49f8f07b4c1 100644 --- a/modules/github_integration/config/locales/crowdin/ckb-IR.yml +++ b/modules/github_integration/config/locales/crowdin/ckb-IR.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ckb-IR: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/cs.yml b/modules/github_integration/config/locales/crowdin/cs.yml index 29da6e2a4b86..2701b1a04370 100644 --- a/modules/github_integration/config/locales/crowdin/cs.yml +++ b/modules/github_integration/config/locales/crowdin/cs.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ cs: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub integrace" description: "Integruje OpenProject a GitHub pro lepší workflow" project_module_github: "GitHub" permission_show_github_content: "Zobrazit GitHub obsah" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/da.yml b/modules/github_integration/config/locales/crowdin/da.yml index 63535f8c8bff..eb951a74bf1d 100644 --- a/modules/github_integration/config/locales/crowdin/da.yml +++ b/modules/github_integration/config/locales/crowdin/da.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ da: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Vis GitHub indhold" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/de.yml b/modules/github_integration/config/locales/crowdin/de.yml index 90e23bd57458..27c5349f54fc 100644 --- a/modules/github_integration/config/locales/crowdin/de.yml +++ b/modules/github_integration/config/locales/crowdin/de.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ de: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub-Integration" description: "Integriert OpenProject und GitHub für einen besseren Arbeitsablauf" project_module_github: "GitHub" permission_show_github_content: "Zeige weiteren Inhalt" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/el.yml b/modules/github_integration/config/locales/crowdin/el.yml index d0ff03cb836e..5dbd8258fc84 100644 --- a/modules/github_integration/config/locales/crowdin/el.yml +++ b/modules/github_integration/config/locales/crowdin/el.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ el: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Εμφάνιση περιεχομένου GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/eo.yml b/modules/github_integration/config/locales/crowdin/eo.yml index 2a3c878fcb6e..30283f1c7ffb 100644 --- a/modules/github_integration/config/locales/crowdin/eo.yml +++ b/modules/github_integration/config/locales/crowdin/eo.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ eo: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/es.yml b/modules/github_integration/config/locales/crowdin/es.yml index 054612a72417..7e0cf3cbeba5 100644 --- a/modules/github_integration/config/locales/crowdin/es.yml +++ b/modules/github_integration/config/locales/crowdin/es.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ es: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integración de OpenProject y GitHub" description: "Integra OpenProject y GitHub para un mejor flujo de trabajo" project_module_github: "GitHub" permission_show_github_content: "Mostrar contenido de GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/et.yml b/modules/github_integration/config/locales/crowdin/et.yml index 2f044d80b514..07a614243bce 100644 --- a/modules/github_integration/config/locales/crowdin/et.yml +++ b/modules/github_integration/config/locales/crowdin/et.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ et: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/eu.yml b/modules/github_integration/config/locales/crowdin/eu.yml index a6fc297c2037..2d925faa3b64 100644 --- a/modules/github_integration/config/locales/crowdin/eu.yml +++ b/modules/github_integration/config/locales/crowdin/eu.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ eu: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/fa.yml b/modules/github_integration/config/locales/crowdin/fa.yml index 23756ff7fe35..4bfebe337745 100644 --- a/modules/github_integration/config/locales/crowdin/fa.yml +++ b/modules/github_integration/config/locales/crowdin/fa.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ fa: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/fi.yml b/modules/github_integration/config/locales/crowdin/fi.yml index 45427fba8763..d8b69535e9f2 100644 --- a/modules/github_integration/config/locales/crowdin/fi.yml +++ b/modules/github_integration/config/locales/crowdin/fi.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ fi: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/fil.yml b/modules/github_integration/config/locales/crowdin/fil.yml index 53862d954cbe..d2460144746d 100644 --- a/modules/github_integration/config/locales/crowdin/fil.yml +++ b/modules/github_integration/config/locales/crowdin/fil.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ fil: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/fr.yml b/modules/github_integration/config/locales/crowdin/fr.yml index e7756809697a..b8391722e13f 100644 --- a/modules/github_integration/config/locales/crowdin/fr.yml +++ b/modules/github_integration/config/locales/crowdin/fr.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ fr: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Intégration de GitHub et OpenProject" description: "Permet une intégration étroite entre OpenProject et GitHub pour un meilleur flux de travail" project_module_github: "GitHub" permission_show_github_content: "Afficher le contenu GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/he.yml b/modules/github_integration/config/locales/crowdin/he.yml index b2dc75766e5f..57965b9f3a7f 100644 --- a/modules/github_integration/config/locales/crowdin/he.yml +++ b/modules/github_integration/config/locales/crowdin/he.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ he: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/hi.yml b/modules/github_integration/config/locales/crowdin/hi.yml index c2c206940e38..9a54ed36ce3d 100644 --- a/modules/github_integration/config/locales/crowdin/hi.yml +++ b/modules/github_integration/config/locales/crowdin/hi.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ hi: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/hr.yml b/modules/github_integration/config/locales/crowdin/hr.yml index 34ec7803ff4a..9fb9e050f445 100644 --- a/modules/github_integration/config/locales/crowdin/hr.yml +++ b/modules/github_integration/config/locales/crowdin/hr.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ hr: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/hu.yml b/modules/github_integration/config/locales/crowdin/hu.yml index e25a58801b7b..95f163b045af 100644 --- a/modules/github_integration/config/locales/crowdin/hu.yml +++ b/modules/github_integration/config/locales/crowdin/hu.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ hu: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "GitHub tartalom mutatása" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/id.yml b/modules/github_integration/config/locales/crowdin/id.yml index f7e8c79d0ac2..3c2130025b73 100644 --- a/modules/github_integration/config/locales/crowdin/id.yml +++ b/modules/github_integration/config/locales/crowdin/id.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ id: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Tampilkan konten GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/it.yml b/modules/github_integration/config/locales/crowdin/it.yml index d2b0ecda8dec..e1dcd0ff21ef 100644 --- a/modules/github_integration/config/locales/crowdin/it.yml +++ b/modules/github_integration/config/locales/crowdin/it.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ it: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integrazione OpenProject GitHub" description: "Integra OpenProject e GitHub per un migliore flusso di lavoro" project_module_github: "GitHub" permission_show_github_content: "Mostra il contenuto di GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ja.yml b/modules/github_integration/config/locales/crowdin/ja.yml index ba104b1a6e78..26401ac03dcc 100644 --- a/modules/github_integration/config/locales/crowdin/ja.yml +++ b/modules/github_integration/config/locales/crowdin/ja.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ja: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "GitHub コンテンツを表示する" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/js-ar.yml b/modules/github_integration/config/locales/crowdin/js-ar.yml index ab342782a5a7..c1c99c6b5a24 100644 --- a/modules/github_integration/config/locales/crowdin/js-ar.yml +++ b/modules/github_integration/config/locales/crowdin/js-ar.yml @@ -40,12 +40,12 @@ ar: empty: 'There are no pull requests linked yet. Link an existing PR by using the code OP#%{wp_id} in the PR description or create a new PR.' github_actions: Actions pull_requests: - message: "Pull request #%{pr_number} %{pr_link} for %{repository_link} authored by %{github_user_link} has been %{pr_state}." - merged_message: "Pull request #%{pr_number} %{pr_link} for %{repository_link} has been %{pr_state} by %{github_user_link}." - referenced_message: "Pull request #%{pr_number} %{pr_link} for %{repository_link} authored by %{github_user_link} referenced this work package." + message: "طلب السحب #%{pr_number} %{pr_link} لمستودع %{repository_link} الذي أنشأه %{github_user_link} تم %{pr_state}." + merged_message: "طلب السحب #%{pr_number} %{pr_link} لمستودع %{repository_link} تم %{pr_state} من قبل %{github_user_link}." + referenced_message: "طلب السحب #%{pr_number} %{pr_link} لمستودع %{repository_link} الذي أنشأه %{github_user_link} أشار إلى حزمة العمل هذه." states: - opened: 'opened' + opened: 'مفتوح' closed: 'مغلق' - draft: 'drafted' - merged: 'merged' - ready_for_review: 'marked ready for review' + draft: 'تمت صياغته' + merged: 'مدمج' + ready_for_review: 'تم وضع علامة جاهز للمراجعة' diff --git a/modules/github_integration/config/locales/crowdin/ka.yml b/modules/github_integration/config/locales/crowdin/ka.yml index 853ae6c0e2e7..e82e553a0c95 100644 --- a/modules/github_integration/config/locales/crowdin/ka.yml +++ b/modules/github_integration/config/locales/crowdin/ka.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ka: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/kk.yml b/modules/github_integration/config/locales/crowdin/kk.yml index e0826ee9b562..1709043c43ed 100644 --- a/modules/github_integration/config/locales/crowdin/kk.yml +++ b/modules/github_integration/config/locales/crowdin/kk.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ kk: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ko.yml b/modules/github_integration/config/locales/crowdin/ko.yml index 2bad6ba92add..1ff41a30cb75 100644 --- a/modules/github_integration/config/locales/crowdin/ko.yml +++ b/modules/github_integration/config/locales/crowdin/ko.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ko: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub 통합" description: "개선된 워크플로를 위해 OpenProject와 GitHub를 통합합니다" project_module_github: "GitHub" permission_show_github_content: "GitHub 콘텐츠 표시" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/lt.yml b/modules/github_integration/config/locales/crowdin/lt.yml index 712dcedd15d5..d785406d8b54 100644 --- a/modules/github_integration/config/locales/crowdin/lt.yml +++ b/modules/github_integration/config/locales/crowdin/lt.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ lt: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub integracija" description: "Integruoja OpenProject ir GitHub geresniems procesams" project_module_github: "GitHub" permission_show_github_content: "Parodyti GitHub turinį" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/lv.yml b/modules/github_integration/config/locales/crowdin/lv.yml index 2dce3eb5dae2..285956e3e20a 100644 --- a/modules/github_integration/config/locales/crowdin/lv.yml +++ b/modules/github_integration/config/locales/crowdin/lv.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ lv: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/mn.yml b/modules/github_integration/config/locales/crowdin/mn.yml index 6750905a8175..499560dd9975 100644 --- a/modules/github_integration/config/locales/crowdin/mn.yml +++ b/modules/github_integration/config/locales/crowdin/mn.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ mn: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ms.yml b/modules/github_integration/config/locales/crowdin/ms.yml index 4d24deee6a10..5194c5899f6a 100644 --- a/modules/github_integration/config/locales/crowdin/ms.yml +++ b/modules/github_integration/config/locales/crowdin/ms.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ms: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integrasi GitHub OpenProject" description: "Mengintegrasikan OpenProject dan GitHub untuk aliran kerja yang lebih baik" project_module_github: "GitHub" permission_show_github_content: "Paparkan kandungan GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ne.yml b/modules/github_integration/config/locales/crowdin/ne.yml index 8933e430608a..703c0bb0a1e8 100644 --- a/modules/github_integration/config/locales/crowdin/ne.yml +++ b/modules/github_integration/config/locales/crowdin/ne.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ne: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/nl.yml b/modules/github_integration/config/locales/crowdin/nl.yml index 7f46d226eed6..ee4ec41dd907 100644 --- a/modules/github_integration/config/locales/crowdin/nl.yml +++ b/modules/github_integration/config/locales/crowdin/nl.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ nl: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Toon GitHub inhoud" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/no.yml b/modules/github_integration/config/locales/crowdin/no.yml index ee9779d1b1df..f24b4677792a 100644 --- a/modules/github_integration/config/locales/crowdin/no.yml +++ b/modules/github_integration/config/locales/crowdin/no.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ "no": + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub integrasjon" description: "Integrerer OpenProject og GitHub for en bedre arbeidsflyt" project_module_github: "GitHub" permission_show_github_content: "Vis GitHub innhold" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/pl.yml b/modules/github_integration/config/locales/crowdin/pl.yml index 366ff3450d18..ebd3fe849c7d 100644 --- a/modules/github_integration/config/locales/crowdin/pl.yml +++ b/modules/github_integration/config/locales/crowdin/pl.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ pl: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integracja OpenProject z GitHub" description: "Integruje OpenProject i GitHub dla lepszego przepływu pracy" project_module_github: "GitHub" permission_show_github_content: "Pokaż treść GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/pt-BR.yml b/modules/github_integration/config/locales/crowdin/pt-BR.yml index a623297c3791..6537addd962d 100644 --- a/modules/github_integration/config/locales/crowdin/pt-BR.yml +++ b/modules/github_integration/config/locales/crowdin/pt-BR.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ pt-BR: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" project_module_github: "GitHub" permission_show_github_content: "Exibir conteúdo GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/pt-PT.yml b/modules/github_integration/config/locales/crowdin/pt-PT.yml index 319a17c6e6a0..f2c6faa6be1d 100644 --- a/modules/github_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/github_integration/config/locales/crowdin/pt-PT.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ pt-PT: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" project_module_github: "GitHub" permission_show_github_content: "Mostrar conteúdo GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ro.yml b/modules/github_integration/config/locales/crowdin/ro.yml index 322fb50887f2..f60b02ab23c0 100644 --- a/modules/github_integration/config/locales/crowdin/ro.yml +++ b/modules/github_integration/config/locales/crowdin/ro.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ro: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Afișați conținutul GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/ru.yml b/modules/github_integration/config/locales/crowdin/ru.yml index 222a47827d9c..003a9741bdfd 100644 --- a/modules/github_integration/config/locales/crowdin/ru.yml +++ b/modules/github_integration/config/locales/crowdin/ru.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ ru: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Интеграция с OpenProject GitHub" description: "Интегрирует OpenProject и GitHub для лучшего рабочего процесса" project_module_github: "GitHub" permission_show_github_content: "Показать контент GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/rw.yml b/modules/github_integration/config/locales/crowdin/rw.yml index a42bca3db70d..8a5009d3e822 100644 --- a/modules/github_integration/config/locales/crowdin/rw.yml +++ b/modules/github_integration/config/locales/crowdin/rw.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ rw: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/si.yml b/modules/github_integration/config/locales/crowdin/si.yml index f06ef5264ae2..5dd3bd30f9e5 100644 --- a/modules/github_integration/config/locales/crowdin/si.yml +++ b/modules/github_integration/config/locales/crowdin/si.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ si: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/sk.yml b/modules/github_integration/config/locales/crowdin/sk.yml index 833ddae134c3..5ad998b0ac80 100644 --- a/modules/github_integration/config/locales/crowdin/sk.yml +++ b/modules/github_integration/config/locales/crowdin/sk.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ sk: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/sl.yml b/modules/github_integration/config/locales/crowdin/sl.yml index 483112f142f5..dc3bd69181cb 100644 --- a/modules/github_integration/config/locales/crowdin/sl.yml +++ b/modules/github_integration/config/locales/crowdin/sl.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ sl: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Pokaži GitHub vsebino" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/sr.yml b/modules/github_integration/config/locales/crowdin/sr.yml index 3cf9eed05324..d6ba7ce2ebb3 100644 --- a/modules/github_integration/config/locales/crowdin/sr.yml +++ b/modules/github_integration/config/locales/crowdin/sr.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ sr: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/sv.yml b/modules/github_integration/config/locales/crowdin/sv.yml index 95999c5cf523..ba84910b2e73 100644 --- a/modules/github_integration/config/locales/crowdin/sv.yml +++ b/modules/github_integration/config/locales/crowdin/sv.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ sv: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/th.yml b/modules/github_integration/config/locales/crowdin/th.yml index fb54c5c421b4..3cd44effbc8a 100644 --- a/modules/github_integration/config/locales/crowdin/th.yml +++ b/modules/github_integration/config/locales/crowdin/th.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ th: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "แสดงเนื้อหา GitHub " + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/tr.yml b/modules/github_integration/config/locales/crowdin/tr.yml index 55573af1443c..b07857734afb 100644 --- a/modules/github_integration/config/locales/crowdin/tr.yml +++ b/modules/github_integration/config/locales/crowdin/tr.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ tr: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Entegrasyonu" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Github içeriğini göster" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/uk.yml b/modules/github_integration/config/locales/crowdin/uk.yml index 75c9840641e9..c7524bb03581 100644 --- a/modules/github_integration/config/locales/crowdin/uk.yml +++ b/modules/github_integration/config/locales/crowdin/uk.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ uk: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "Інтеграція OpenProject GitHub" description: "Інтегрує OpenProject і GitHub для покращення робочого процесу" project_module_github: "GitHub" permission_show_github_content: "Показати контент GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/uz.yml b/modules/github_integration/config/locales/crowdin/uz.yml index a56b43bc7f59..afc55646cf9e 100644 --- a/modules/github_integration/config/locales/crowdin/uz.yml +++ b/modules/github_integration/config/locales/crowdin/uz.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ uz: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/vi.yml b/modules/github_integration/config/locales/crowdin/vi.yml index 0127650d8340..9e14e2e70dab 100644 --- a/modules/github_integration/config/locales/crowdin/vi.yml +++ b/modules/github_integration/config/locales/crowdin/vi.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ vi: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Hiển thị nội dung GitHub" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/zh-CN.yml b/modules/github_integration/config/locales/crowdin/zh-CN.yml index a37088622f53..80299b66e3e1 100644 --- a/modules/github_integration/config/locales/crowdin/zh-CN.yml +++ b/modules/github_integration/config/locales/crowdin/zh-CN.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ zh-CN: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub 集成" description: "将 OpenProject 和 GitHub 进行集成,以实现更好的工作流程。" project_module_github: "GitHub" permission_show_github_content: "显示 GitHub 内容" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/crowdin/zh-TW.yml b/modules/github_integration/config/locales/crowdin/zh-TW.yml index 15c4247585b6..138bb92750de 100644 --- a/modules/github_integration/config/locales/crowdin/zh-TW.yml +++ b/modules/github_integration/config/locales/crowdin/zh-TW.yml @@ -20,8 +20,20 @@ #See COPYRIGHT and LICENSE files for more details. #++ zh-TW: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub 整合" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "顯示 GitHub 內容" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/locales/en.yml b/modules/github_integration/config/locales/en.yml index 8b5a2e3e6dcf..182110277b56 100644 --- a/modules/github_integration/config/locales/en.yml +++ b/modules/github_integration/config/locales/en.yml @@ -27,9 +27,22 @@ #++ en: + button_add_deploy_target: Add deploy target + label_deploy_target: Deploy target + label_deploy_target_new: New deploy target + label_deploy_target_plural: Deploy targets + label_github_integration: GitHub Integration + notice_deploy_target_created: Deploy target created + notice_deploy_target_destroyed: Deploy target deleted plugin_openproject_github_integration: name: "OpenProject GitHub Integration" description: "Integrates OpenProject and GitHub for a better workflow" project_module_github: "GitHub" permission_show_github_content: "Show GitHub content" + permission_introspection: Read running OpenProject core version and build SHA + text_deploy_target_type_info: > + So far we only support OpenProject itself. + text_deploy_target_api_key_info: > + An OpenProject [API key](docs_url) + belonging to a user who has the global introspection permission. diff --git a/modules/github_integration/config/routes.rb b/modules/github_integration/config/routes.rb new file mode 100644 index 000000000000..2f6f23ab8f4e --- /dev/null +++ b/modules/github_integration/config/routes.rb @@ -0,0 +1,31 @@ +#-- 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. +#++ + +Rails.application.routes.draw do + resources :deploy_targets, only: %i[index new create destroy] +end diff --git a/modules/github_integration/db/migrate/20240501083852_create_deploy_targets.rb b/modules/github_integration/db/migrate/20240501083852_create_deploy_targets.rb new file mode 100644 index 000000000000..782e976724bb --- /dev/null +++ b/modules/github_integration/db/migrate/20240501083852_create_deploy_targets.rb @@ -0,0 +1,13 @@ +class CreateDeployTargets < ActiveRecord::Migration[7.1] + def change + create_table :deploy_targets do |t| + t.text :type, null: false + t.text :host, null: false + t.jsonb :options, null: false, default: {} + + t.timestamps + end + + add_index :deploy_targets, :host, unique: true + end +end diff --git a/modules/github_integration/db/migrate/20240501093751_create_deploy_status_checks.rb b/modules/github_integration/db/migrate/20240501093751_create_deploy_status_checks.rb new file mode 100644 index 000000000000..2881a758cdeb --- /dev/null +++ b/modules/github_integration/db/migrate/20240501093751_create_deploy_status_checks.rb @@ -0,0 +1,12 @@ +class CreateDeployStatusChecks < ActiveRecord::Migration[7.1] + def change + create_table :deploy_status_checks do |t| + t.references :deploy_target + t.references :github_pull_request + + t.text :core_sha, null: false + + t.timestamps + end + end +end diff --git a/modules/github_integration/db/migrate/20240502081436_add_merge_commit_sha_to_github_pull_requests.rb b/modules/github_integration/db/migrate/20240502081436_add_merge_commit_sha_to_github_pull_requests.rb new file mode 100644 index 000000000000..5ae78152b886 --- /dev/null +++ b/modules/github_integration/db/migrate/20240502081436_add_merge_commit_sha_to_github_pull_requests.rb @@ -0,0 +1,5 @@ +class AddMergeCommitShaToGithubPullRequests < ActiveRecord::Migration[7.1] + def change + add_column :github_pull_requests, :merge_commit_sha, :text + end +end diff --git a/modules/github_integration/frontend/module/pull-request/pull-request-state.component.sass b/modules/github_integration/frontend/module/pull-request/pull-request-state.component.sass index 80af28bde1dd..dc0c8a9933b3 100644 --- a/modules/github_integration/frontend/module/pull-request/pull-request-state.component.sass +++ b/modules/github_integration/frontend/module/pull-request/pull-request-state.component.sass @@ -49,3 +49,6 @@ &_closed background-color: #d73a49 + + &_deployed + background-color: #d73af9 diff --git a/modules/github_integration/frontend/module/pull-request/pull-request-state.component.ts b/modules/github_integration/frontend/module/pull-request/pull-request-state.component.ts index 387a2dc378d1..33bbec81afca 100644 --- a/modules/github_integration/frontend/module/pull-request/pull-request-state.component.ts +++ b/modules/github_integration/frontend/module/pull-request/pull-request-state.component.ts @@ -35,7 +35,7 @@ import { import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -export type PullRequestState = 'opened'|'closed'|'referenced'|'ready_for_review'|'merged'|'draft'; +export type PullRequestState = 'opened'|'closed'|'referenced'|'ready_for_review'|'merged'|'draft'|'deployed'; @Component({ selector: 'op-github-pull-request-state', diff --git a/modules/github_integration/frontend/module/pull-request/pull-request.component.ts b/modules/github_integration/frontend/module/pull-request/pull-request.component.ts index ecc70c8ec87a..5b81b1e8e202 100644 --- a/modules/github_integration/frontend/module/pull-request/pull-request.component.ts +++ b/modules/github_integration/frontend/module/pull-request/pull-request.component.ts @@ -71,6 +71,9 @@ export class PullRequestComponent { if (this.pullRequest.state === 'open') { return (this.pullRequest.draft ? 'draft' : 'open'); } + if (this.pullRequest.state === 'deployed') { + return 'deployed'; + } return (this.pullRequest.merged ? 'merged' : 'closed'); } diff --git a/modules/github_integration/frontend/module/tab-header/styles/tab-header.sass b/modules/github_integration/frontend/module/tab-header/styles/tab-header.sass index 0f80e7171fe4..3f87eb6aec16 100644 --- a/modules/github_integration/frontend/module/tab-header/styles/tab-header.sass +++ b/modules/github_integration/frontend/module/tab-header/styles/tab-header.sass @@ -27,14 +27,6 @@ */ .github-pr-header - display: flex - flex-wrap: wrap-reverse - justify-content: flex-end - - border-bottom: 1px solid #ddd - - margin: 0 0 0.8rem 0 - .title flex: 1 1 auto border-bottom: 0 diff --git a/modules/github_integration/frontend/module/tab-header/tab-header.template.html b/modules/github_integration/frontend/module/tab-header/tab-header.template.html index 82a240172f72..a26ea6faee58 100644 --- a/modules/github_integration/frontend/module/tab-header/tab-header.template.html +++ b/modules/github_integration/frontend/module/tab-header/tab-header.template.html @@ -1,4 +1,4 @@ -
    +

    {{text.title}} diff --git a/modules/github_integration/frontend/module/tab-prs/tab-prs.component.html b/modules/github_integration/frontend/module/tab-prs/tab-prs.component.html index 1ad3aa9b9cd6..e8c9e37e551a 100644 --- a/modules/github_integration/frontend/module/tab-prs/tab-prs.component.html +++ b/modules/github_integration/frontend/module/tab-prs/tab-prs.component.html @@ -1,5 +1,8 @@ -

    +

    { OpenProject::FeatureDecisions.deploy_targets_active? } # can only be enable at start-time end + + menu :admin_menu, + :deploy_targets, + { controller: "/deploy_targets", action: "index" }, + if: ->(*) { OpenProject::FeatureDecisions.deploy_targets_active? && User.current.admin? }, + parent: :admin_github_integration, + caption: :label_deploy_target_plural, + icon: "hosting" end initializer "github.register_hook" do @@ -62,6 +92,13 @@ class Engine < ::Rails::Engine end end + extend_api_response(:v3, :root) do + property :core_sha, + exec_context: :decorator, + getter: ->(*) { OpenProject::VERSION.core_sha }, + if: ->(*) { current_user.admin? || current_user.allowed_globally?(:introspection) } + end + extend_api_response(:v3, :work_packages, :work_package, &::OpenProject::GithubIntegration::Patches::API::WorkPackageRepresenter.extension) @@ -86,12 +123,24 @@ class Engine < ::Rails::Engine end add_cron_jobs do - { + jobs = { "Cron::ClearOldPullRequestsJob": { cron: "25 1 * * *", # runs at 1:25 nightly class: ::Cron::ClearOldPullRequestsJob.name } } + + # Enabling the feature flag at runtime won't enable + # the cron job. So if you want this feature, enable it + # at start-time. + if OpenProject::FeatureDecisions.deploy_targets_active? + jobs["Cron::CheckDeployStatusJob"] = { + cron: "15,45 * * * *", # runs every half hour + class: ::Cron::CheckDeployStatusJob.name + } + end + + jobs end end end diff --git a/modules/github_integration/lib/open_project/github_integration/services/upsert_pull_request.rb b/modules/github_integration/lib/open_project/github_integration/services/upsert_pull_request.rb index 2ed0ea027f78..914af5b921b2 100644 --- a/modules/github_integration/lib/open_project/github_integration/services/upsert_pull_request.rb +++ b/modules/github_integration/lib/open_project/github_integration/services/upsert_pull_request.rb @@ -70,6 +70,7 @@ def extract_params(payload) .fetch("repo") .fetch("html_url"), draft: payload.fetch("draft"), + merge_commit_sha: payload["merge_commit_sha"], merged: payload.fetch("merged"), merged_by: github_user_id(payload["merged_by"]), merged_at: payload["merged_at"], diff --git a/modules/github_integration/spec/factories/deploy_status_check_factory.rb b/modules/github_integration/spec/factories/deploy_status_check_factory.rb new file mode 100644 index 000000000000..2bc91787f426 --- /dev/null +++ b/modules/github_integration/spec/factories/deploy_status_check_factory.rb @@ -0,0 +1,34 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :deploy_status_check do + deploy_target + github_pull_request + end +end diff --git a/modules/github_integration/spec/factories/deploy_target_factory.rb b/modules/github_integration/spec/factories/deploy_target_factory.rb new file mode 100644 index 000000000000..42fcf21bd5eb --- /dev/null +++ b/modules/github_integration/spec/factories/deploy_target_factory.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :deploy_target do + type { "OpenProject" } + + sequence(:host) { |n| "https://qa-#{n}.openproject-edge.com" } + + api_key { "4p1k3y" } + end +end diff --git a/modules/github_integration/spec/lib/open_project/github_integration/notification_handler/pull_request_spec.rb b/modules/github_integration/spec/lib/open_project/github_integration/notification_handler/pull_request_spec.rb index 289fad06664b..8cb4edab8d78 100644 --- a/modules/github_integration/spec/lib/open_project/github_integration/notification_handler/pull_request_spec.rb +++ b/modules/github_integration/spec/lib/open_project/github_integration/notification_handler/pull_request_spec.rb @@ -51,6 +51,7 @@ "merged" => pr_merged, "merged_by" => nil, "merged_at" => nil, + "merge_commit_sha" => nil, "comments" => 1, "review_comments" => 2, "additions" => 3, diff --git a/modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb b/modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb index afc97f1b655c..b504f87e2536 100644 --- a/modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb +++ b/modules/github_integration/spec/lib/open_project/github_integration/services/upsert_pull_request_spec.rb @@ -181,7 +181,8 @@ { "merged" => true, "merged_by" => user_payload, - "merged_at" => "20210410T09:45:03Z" + "merged_at" => "20210410T09:45:03Z", + "merge_commit_sha" => "955af2f83de81c39fcf912376855eb3ee5e38f26" } end @@ -195,7 +196,8 @@ github_user:, merged: true, merged_by: github_user, - merged_at: Time.zone.parse("20210410T09:45:03Z") + merged_at: Time.zone.parse("20210410T09:45:03Z"), + merge_commit_sha: "955af2f83de81c39fcf912376855eb3ee5e38f26" ) end end diff --git a/modules/github_integration/spec/requests/extended_root_resource_spec.rb b/modules/github_integration/spec/requests/extended_root_resource_spec.rb new file mode 100644 index 000000000000..03422d4e26e7 --- /dev/null +++ b/modules/github_integration/spec/requests/extended_root_resource_spec.rb @@ -0,0 +1,90 @@ +#-- 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" +require "rack/test" + +RSpec.describe "API v3 Root resource with the github integration extension", with_flag: { deploy_targets: true } do + include Rack::Test::Methods + include API::V3::Utilities::PathHelper + + let(:current_user) do + create(:user, member_with_roles: { project => role }) + end + let(:role) { create(:project_role, permissions: []) } + let(:project) { create(:project, public: false) } + + before do + # reset permissions cache, otherwise the introspection permissions enabled with the + # deploy_targets feature flag won't be registered + OpenProject::AccessControl.instance_variable_set(:@permissions, nil) + end + + describe "#get" do + let(:response) { last_response } + let(:get_path) { api_v3_paths.root } + + subject { response.body } + + context "without introspection permission" do + before do + allow(User).to receive(:current).and_return(current_user) + + get get_path + end + + it "responds with 200" do + expect(response.status).to eq(200) # rubocop:disable RSpecRails/HaveHttpStatus + end + + it "does not include the core SHA in the res" do + expect(subject).not_to have_json_path("coreSha") + end + end + + context "with introspection permission" do + let(:current_user) { create(:user, global_permissions: [:introspection]) } + let(:core_sha) { "b86f391bf02c345e934ca8a945d83fc82d2063ef" } + + before do + allow(OpenProject::VERSION).to receive(:core_sha).and_return core_sha + allow(User).to receive(:current).and_return(current_user) + + get get_path + end + + it "responds with 200" do + expect(response.status).to eq(200) # rubocop:disable RSpecRails/HaveHttpStatus + end + + it "does includes the core SHA in the response" do + expect(subject).to be_json_eql(core_sha.to_json).at_path("coreSha") + end + end + end +end diff --git a/modules/github_integration/spec/workers/cron/check_deploy_status_job_spec.rb b/modules/github_integration/spec/workers/cron/check_deploy_status_job_spec.rb new file mode 100644 index 000000000000..5de8a3f4be78 --- /dev/null +++ b/modules/github_integration/spec/workers/cron/check_deploy_status_job_spec.rb @@ -0,0 +1,115 @@ +#-- 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 Cron::CheckDeployStatusJob, type: :job, with_flag: { deploy_targets: true } do + let(:api_key) { "foobar42" } + + let(:merge_commit_sha) { "576e25f7befffa5fc02a4311704e9894a5c9bdd4" } + let(:core_sha) { "663f3a128aef9c0b031cbd59bb6f740ee50130a7" } + + let(:work_package) { create(:work_package) } + + let(:deploy_target) { create(:deploy_target, api_key:) } + let(:pull_request) { create(:github_pull_request, work_packages: [work_package], state: :closed, merge_commit_sha:) } + + let(:job) { described_class.new } + + context "with no prior checks and the same deployed sha" do + before do + deploy_target + pull_request + + allow(job).to receive(:openproject_core_sha).with(deploy_target.host, api_key).and_return(core_sha) + allow(job).to receive(:commit_contains?).with(core_sha, merge_commit_sha).and_return true + + job.perform + end + + it "marks the pull request 'deployed'" do + expect(pull_request.reload.state).to eq "deployed" + end + end + + context "with prior checks" do + before do + allow(job).to receive(:openproject_core_sha).with(deploy_target.host, api_key).and_return(core_sha) + allow(job).to receive :commit_contains? + end + + context "with the same core sha" do + let!(:deploy_status_check) { create(:deploy_status_check, deploy_target:, github_pull_request: pull_request, core_sha:) } + + before do + job.perform + end + + it "leaves the pull request closed while not checking with github again" do + expect(pull_request.reload.state).to eq "closed" + expect(job).not_to have_received :commit_contains? + end + end + + context "with a different core sha in the previous check" do + let!(:deploy_status_check) do + create(:deploy_status_check, deploy_target:, github_pull_request: pull_request, core_sha: "foo") + end + + before do + allow(job).to receive(:commit_contains?).with(core_sha, merge_commit_sha).and_return contains_commit + + job.perform + end + + context "with the same core sha deployed" do + let(:contains_commit) { true } + + it "marks the pull request deployed" do + expect(pull_request.reload.state).to eq "deployed" + end + + it "has checked with github again" do + expect(job).to have_received(:commit_contains?).with(core_sha, merge_commit_sha) + end + end + + context "with a different core sha deployed" do + let(:contains_commit) { false } + + it "leaves the pull request closed" do + expect(pull_request.reload.state).to eq "closed" + end + + it "has checked with github again" do + expect(job).to have_received(:commit_contains?).with(core_sha, merge_commit_sha) + end + end + end + end +end diff --git a/modules/gitlab_integration/frontend/module/tab-header-issue/styles/tab-header-issue.sass b/modules/gitlab_integration/frontend/module/tab-header-issue/styles/tab-header-issue.sass index e5eb014b830a..7e42b26b2fc5 100644 --- a/modules/gitlab_integration/frontend/module/tab-header-issue/styles/tab-header-issue.sass +++ b/modules/gitlab_integration/frontend/module/tab-header-issue/styles/tab-header-issue.sass @@ -28,13 +28,6 @@ //++ .gitlab-issue-header - display: flex - flex-wrap: wrap-reverse - justify-content: flex-end - - border-bottom: 1px solid #ddd - background-color: var(--body-background) - .title flex: 1 1 auto border-bottom: 0 diff --git a/modules/gitlab_integration/frontend/module/tab-header-issue/tab-header-issue.template.html b/modules/gitlab_integration/frontend/module/tab-header-issue/tab-header-issue.template.html index 1fc5efe1023f..b3b8936dd350 100644 --- a/modules/gitlab_integration/frontend/module/tab-header-issue/tab-header-issue.template.html +++ b/modules/gitlab_integration/frontend/module/tab-header-issue/tab-header-issue.template.html @@ -1,4 +1,4 @@ -
    +

    {{text.title}} diff --git a/modules/gitlab_integration/frontend/module/tab-header-mr/styles/tab-header-mr.sass b/modules/gitlab_integration/frontend/module/tab-header-mr/styles/tab-header-mr.sass index 1613363e759d..70a7556232e4 100644 --- a/modules/gitlab_integration/frontend/module/tab-header-mr/styles/tab-header-mr.sass +++ b/modules/gitlab_integration/frontend/module/tab-header-mr/styles/tab-header-mr.sass @@ -28,13 +28,6 @@ //++ .gitlab-mr-header - display: flex - flex-wrap: wrap-reverse - justify-content: flex-end - - border-bottom: 1px solid #ddd - background-color: var(--body-background) - .title flex: 1 1 auto border-bottom: 0 diff --git a/modules/gitlab_integration/frontend/module/tab-header-mr/tab-header-mr.template.html b/modules/gitlab_integration/frontend/module/tab-header-mr/tab-header-mr.template.html index 031b3c52a576..7ace2a84b125 100644 --- a/modules/gitlab_integration/frontend/module/tab-header-mr/tab-header-mr.template.html +++ b/modules/gitlab_integration/frontend/module/tab-header-mr/tab-header-mr.template.html @@ -1,4 +1,4 @@ -
    +

    {{text.title}} diff --git a/modules/gitlab_integration/frontend/module/tab-issue/tab-issue.template.html b/modules/gitlab_integration/frontend/module/tab-issue/tab-issue.template.html index ce4a51d334e8..66a824dd67df 100644 --- a/modules/gitlab_integration/frontend/module/tab-issue/tab-issue.template.html +++ b/modules/gitlab_integration/frontend/module/tab-issue/tab-issue.template.html @@ -1,5 +1,8 @@ -

    +

    diff --git a/modules/gitlab_integration/frontend/module/tab-mrs/tab-mrs.template.html b/modules/gitlab_integration/frontend/module/tab-mrs/tab-mrs.template.html index 43ab49c22624..8405dc814b58 100644 --- a/modules/gitlab_integration/frontend/module/tab-mrs/tab-mrs.template.html +++ b/modules/gitlab_integration/frontend/module/tab-mrs/tab-mrs.template.html @@ -1,5 +1,8 @@ -

    +

    diff --git a/modules/grids/config/locales/crowdin/js-ru.yml b/modules/grids/config/locales/crowdin/js-ru.yml index ef1bd92a2c8c..15bd64c296e3 100644 --- a/modules/grids/config/locales/crowdin/js-ru.yml +++ b/modules/grids/config/locales/crowdin/js-ru.yml @@ -32,13 +32,13 @@ ru: no_results: 'Для проектов не определены настраиваемые поля.' project_status: title: 'Статус проекта' - not_started: 'Не начато' - on_track: 'В работе' + not_started: 'Не начат' + on_track: 'По плану' off_track: 'Приостановлен' - at_risk: 'Есть риск' + at_risk: 'Под угрозой' not_set: 'Не задано' - finished: 'Завершено' - discontinued: 'Прекращено' + finished: 'Завершен' + discontinued: 'Прекращен' subprojects: title: 'Подпроекты' no_results: 'Подпроектов нет.' diff --git a/modules/ldap_groups/config/locales/crowdin/no.yml b/modules/ldap_groups/config/locales/crowdin/no.yml index 18e5f1dca1d9..41fbca9d63d9 100644 --- a/modules/ldap_groups/config/locales/crowdin/no.yml +++ b/modules/ldap_groups/config/locales/crowdin/no.yml @@ -66,7 +66,7 @@ form: auth_source_text: 'Velg hvilken LDAP-tilkobling som skal brukes.' sync_users_text: > - Hvis du aktiverer dette valget, vil funnede brukere automatisk bli opprettet i OpenProject. Uten dette vil bare eksisterende kontoer i OpenProject bli lagt til i grupper. + Hvis du aktiverer dette valget, vil relevante brukere automatisk bli opprettet i OpenProject. Uten dette vil bare eksisterende kontoer i OpenProject bli lagt til i grupper. dn_text: 'Skriv inn hele DN for gruppen i LDAP' group_text: 'Velg en eksisterende OpenProject gruppe som medlemmene i LDAP-gruppen skal synkroniseres med' upsale: diff --git a/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb index f950248b5fcf..9fbe7916d537 100644 --- a/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb @@ -6,7 +6,7 @@ t("button_add") end component.with_item( - label: t("activerecord.models.meeting_agenda_item", count: 1), + label: t("activerecord.models.meeting_agenda_item"), tag: :a, content_arguments: { href: new_meeting_agenda_item_path(@meeting, type: "simple", meeting_section_id: @meeting_section&.id), @@ -14,7 +14,7 @@ } ) component.with_item( - label: t("activerecord.models.work_package", count: 1), + label: t("activerecord.models.work_package"), tag: :a, content_arguments: { href: new_meeting_agenda_item_path(@meeting, type: "work_package", meeting_section_id: @meeting_section&.id), @@ -23,7 +23,7 @@ ) unless @meeting_section component.with_item( - label: "Section", + label: t("activerecord.models.meeting_section"), tag: :a, content_arguments: { href: meeting_sections_path(@meeting), diff --git a/modules/meeting/app/components/meetings/index_page_header_component.html.erb b/modules/meeting/app/components/meetings/index_page_header_component.html.erb index 7c1db02b469d..9b6787bd6726 100644 --- a/modules/meeting/app/components/meetings/index_page_header_component.html.erb +++ b/modules/meeting/app/components/meetings/index_page_header_component.html.erb @@ -1,18 +1,4 @@ <%= render(Primer::OpenProject::PageHeader.new) do |header| header.with_title { page_title } header.with_breadcrumbs(breadcrumb_items) - if render_create_button? - header.with_action_button(tag: :a, - href: dynamic_path, - scheme: :primary, - mobile_icon: :plus, - mobile_label: label_text, - aria: { label: accessibility_label_text }, - title: accessibility_label_text, - id: id, - test_selector: "add-meeting-button") do |button| - button.with_leading_visual_icon(icon: :plus) - label_text - end - end end %> diff --git a/modules/meeting/app/components/meetings/index_page_header_component.rb b/modules/meeting/app/components/meetings/index_page_header_component.rb index e17a3ca8f370..6cd8a338a6f4 100644 --- a/modules/meeting/app/components/meetings/index_page_header_component.rb +++ b/modules/meeting/app/components/meetings/index_page_header_component.rb @@ -30,7 +30,6 @@ module Meetings class IndexPageHeaderComponent < ApplicationComponent - include OpPrimer::ComponentHelpers include ApplicationHelper def initialize(project: nil) @@ -38,30 +37,6 @@ def initialize(project: nil) @project = project end - def render_create_button? - if @project - User.current.allowed_in_project?(:create_meetings, @project) - else - User.current.allowed_in_any_project?(:create_meetings) - end - end - - def dynamic_path - polymorphic_path([:new, @project, :meeting]) - end - - def id - "add-meeting-button" - end - - def accessibility_label_text - I18n.t(:label_meeting_new) - end - - def label_text - I18n.t(:label_meeting) - end - def page_title I18n.t(:label_meeting_plural) end diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.html.erb b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb new file mode 100644 index 000000000000..5f8c67601e1a --- /dev/null +++ b/modules/meeting/app/components/meetings/index_sub_header_component.html.erb @@ -0,0 +1,26 @@ +<%= render(Primer::OpenProject::SubHeader.new(data: { + controller: "filters", + "application-target": "dynamic", + "filters-output-format-value": "json", +})) do |subheader| + subheader.with_filter_component do + render(Meetings::MeetingFilterButtonComponent.new(query: @query, project: @project)) + end + + subheader.with_action_button(tag: :a, + href: dynamic_path, + scheme: :primary, + mobile_icon: :plus, + mobile_label: label_text, + aria: { label: accessibility_label_text }, + title: accessibility_label_text, + id: id, + test_selector: "add-meeting-button") do |button| + button.with_leading_visual_icon(icon: :plus) + label_text + end if render_create_button? + + subheader.with_bottom_pane_component(mt: 0) do + render(Meetings::MeetingFiltersComponent.new(query: @query, project: @project)) + end +end %> diff --git a/modules/meeting/app/components/meetings/index_sub_header_component.rb b/modules/meeting/app/components/meetings/index_sub_header_component.rb new file mode 100644 index 000000000000..21c17659f10f --- /dev/null +++ b/modules/meeting/app/components/meetings/index_sub_header_component.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-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 Meetings + # rubocop:disable OpenProject/AddPreviewForViewComponent + class IndexSubHeaderComponent < ApplicationComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + include ApplicationHelper + + def initialize(query:, project: nil) + super + @query = query + @project = project + end + + def render_create_button? + if @project + User.current.allowed_in_project?(:create_meetings, @project) + else + User.current.allowed_in_any_project?(:create_meetings) + end + end + + def dynamic_path + polymorphic_path([:new, @project, :meeting]) + end + + def id + "add-meeting-button" + end + + def accessibility_label_text + I18n.t(:label_meeting_new) + end + + def label_text + I18n.t(:label_meeting) + end + end +end diff --git a/modules/meeting/app/components/meetings/meeting_filter_button_component.rb b/modules/meeting/app/components/meetings/meeting_filter_button_component.rb new file mode 100644 index 000000000000..954d482d97e7 --- /dev/null +++ b/modules/meeting/app/components/meetings/meeting_filter_button_component.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 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 Meetings + # rubocop:disable OpenProject/AddPreviewForViewComponent + class MeetingFilterButtonComponent < Filter::FilterButtonComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent + options :project + def filters_count + @filters_count ||= begin + count = super + count -= 1 if project.present? + + count + end + end + end +end diff --git a/modules/meeting/app/components/meetings/meeting_filters_component.rb b/modules/meeting/app/components/meetings/meeting_filters_component.rb index 987425ad05db..8c333aa7cb53 100644 --- a/modules/meeting/app/components/meetings/meeting_filters_component.rb +++ b/modules/meeting/app/components/meetings/meeting_filters_component.rb @@ -28,7 +28,9 @@ # See COPYRIGHT and LICENSE files for more details. # ++ module Meetings - class MeetingFiltersComponent < FiltersComponent + # rubocop:disable OpenProject/AddPreviewForViewComponent + class MeetingFiltersComponent < Filter::FilterComponent + # rubocop:enable OpenProject/AddPreviewForViewComponent options :project def allowed_filters @@ -37,15 +39,6 @@ def allowed_filters .sort_by(&:human_name) end - def filters_count - @filters_count ||= begin - count = super - count -= 1 if project.present? - - count - end - end - protected def additional_filter_attributes(filter) diff --git a/modules/meeting/app/views/meetings/index.html.erb b/modules/meeting/app/views/meetings/index.html.erb index cde9cfb053f9..1f1e9233458b 100644 --- a/modules/meeting/app/views/meetings/index.html.erb +++ b/modules/meeting/app/views/meetings/index.html.erb @@ -29,8 +29,7 @@ See COPYRIGHT and LICENSE files for more details. <% html_title t(:label_meeting_plural) %> <%= render(Meetings::IndexPageHeaderComponent.new(project: @project)) %> - -<%= render(Meetings::MeetingFiltersComponent.new(query: @query, project: @project, output_format: 'json')) %> +<%= render(Meetings::IndexSubHeaderComponent.new(query: @query, project: @project)) %> <% if @meetings.empty? -%> <%= no_results_box %> diff --git a/modules/meeting/config/locales/crowdin/af.yml b/modules/meeting/config/locales/crowdin/af.yml index ffa8f6202ced..9185cff97822 100644 --- a/modules/meeting/config/locales/crowdin/af.yml +++ b/modules/meeting/config/locales/crowdin/af.yml @@ -58,9 +58,23 @@ af: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/ar.yml b/modules/meeting/config/locales/crowdin/ar.yml index 74f72c1e6950..75f366088113 100644 --- a/modules/meeting/config/locales/crowdin/ar.yml +++ b/modules/meeting/config/locales/crowdin/ar.yml @@ -62,9 +62,23 @@ ar: meeting_agenda_item: "Agenda item" meeting_agenda: "جدول الأعمال" meeting_minutes: "محضر الجلسة" + meeting_section: "Section" activity: filter: meeting: "الاجتماعات" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "حَضَر" description_invite: "دعا" events: diff --git a/modules/meeting/config/locales/crowdin/az.yml b/modules/meeting/config/locales/crowdin/az.yml index 13e1e5d1f1d3..32564f39a60e 100644 --- a/modules/meeting/config/locales/crowdin/az.yml +++ b/modules/meeting/config/locales/crowdin/az.yml @@ -58,9 +58,23 @@ az: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/be.yml b/modules/meeting/config/locales/crowdin/be.yml index daf6b5d2ccd6..a199dcd61cb7 100644 --- a/modules/meeting/config/locales/crowdin/be.yml +++ b/modules/meeting/config/locales/crowdin/be.yml @@ -60,9 +60,23 @@ be: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/bg.yml b/modules/meeting/config/locales/crowdin/bg.yml index cd9bdd5cb7ee..710db69a4fa2 100644 --- a/modules/meeting/config/locales/crowdin/bg.yml +++ b/modules/meeting/config/locales/crowdin/bg.yml @@ -58,9 +58,23 @@ bg: meeting_agenda_item: "Agenda item" meeting_agenda: "Дневен ред" meeting_minutes: "Минути" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "присъства" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/ca.yml b/modules/meeting/config/locales/crowdin/ca.yml index 6c9a0b22c442..7505ff74884a 100644 --- a/modules/meeting/config/locales/crowdin/ca.yml +++ b/modules/meeting/config/locales/crowdin/ca.yml @@ -58,9 +58,23 @@ ca: meeting_agenda_item: "Element d'agenda" meeting_agenda: "Agenda" meeting_minutes: "Acta" + meeting_section: "Section" activity: filter: meeting: "Reunions" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "eliminat" + updated: "canviat de %{old_value} a %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordenat" + work_package: + updated: "canviat de %{old_value} a %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "hi vaig assistir" description_invite: "convidat" events: diff --git a/modules/meeting/config/locales/crowdin/ckb-IR.yml b/modules/meeting/config/locales/crowdin/ckb-IR.yml index 86a56ba4eba7..b5275043361e 100644 --- a/modules/meeting/config/locales/crowdin/ckb-IR.yml +++ b/modules/meeting/config/locales/crowdin/ckb-IR.yml @@ -58,9 +58,23 @@ ckb-IR: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/cs.yml b/modules/meeting/config/locales/crowdin/cs.yml index 5c7adda9a778..c585e54808cd 100644 --- a/modules/meeting/config/locales/crowdin/cs.yml +++ b/modules/meeting/config/locales/crowdin/cs.yml @@ -60,9 +60,23 @@ cs: meeting_agenda_item: "Pořad jednání" meeting_agenda: "Agenda" meeting_minutes: "Zápis" + meeting_section: "Section" activity: filter: meeting: "Schůzky" + item: + meeting_agenda_item: + duration: + added: "nastavit na %{value}" + added_html: "nastavit na %{value}" + removed: "odstraněno" + updated: "změněno z %{old_value} na %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "změněno z %{old_value} na %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "zúčastněn" description_invite: "pozván" events: diff --git a/modules/meeting/config/locales/crowdin/da.yml b/modules/meeting/config/locales/crowdin/da.yml index e18d4d779e1f..9233250249bb 100644 --- a/modules/meeting/config/locales/crowdin/da.yml +++ b/modules/meeting/config/locales/crowdin/da.yml @@ -58,9 +58,23 @@ da: meeting_agenda_item: "Agenda item" meeting_agenda: "Dagsorden" meeting_minutes: "Referat" + meeting_section: "Section" activity: filter: meeting: "Møder" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "tilsluttede" description_invite: "inviteret" events: diff --git a/modules/meeting/config/locales/crowdin/de.yml b/modules/meeting/config/locales/crowdin/de.yml index 00db26ef47d3..672ce0be8056 100644 --- a/modules/meeting/config/locales/crowdin/de.yml +++ b/modules/meeting/config/locales/crowdin/de.yml @@ -58,9 +58,23 @@ de: meeting_agenda_item: "Tagesordnungspunkt" meeting_agenda: "Agenda" meeting_minutes: "Protokoll" + meeting_section: "Section" activity: filter: meeting: "Besprechungen" + item: + meeting_agenda_item: + duration: + added: "auf %{value} eingestellt" + added_html: "auf %{value} eingestellt" + removed: "entfernt" + updated: "von %{old_value} zu %{value} geändert" + updated_html: "von %{old_value} zu %{value} geändert" + position: + updated: "neu geordnet" + work_package: + updated: "von %{old_value} zu %{value} geändert" + updated_html: "von %{old_value} zu %{value} geändert" description_attended: "teilgenommen" description_invite: "eingeladen" events: diff --git a/modules/meeting/config/locales/crowdin/el.yml b/modules/meeting/config/locales/crowdin/el.yml index b0fcf9dc47a6..2fe2e0058a7f 100644 --- a/modules/meeting/config/locales/crowdin/el.yml +++ b/modules/meeting/config/locales/crowdin/el.yml @@ -58,9 +58,23 @@ el: meeting_agenda_item: "Agenda item" meeting_agenda: "Ατζέντα" meeting_minutes: "Πρακτικά" + meeting_section: "Section" activity: filter: meeting: "Συναντήσεις" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "παρευρέθηκε" description_invite: "προσκεκλημένος" events: diff --git a/modules/meeting/config/locales/crowdin/eo.yml b/modules/meeting/config/locales/crowdin/eo.yml index 47a5374cba34..638355c6aa09 100644 --- a/modules/meeting/config/locales/crowdin/eo.yml +++ b/modules/meeting/config/locales/crowdin/eo.yml @@ -58,9 +58,23 @@ eo: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/es.yml b/modules/meeting/config/locales/crowdin/es.yml index 58bc32b3ba2b..0534701eb97c 100644 --- a/modules/meeting/config/locales/crowdin/es.yml +++ b/modules/meeting/config/locales/crowdin/es.yml @@ -58,9 +58,23 @@ es: meeting_agenda_item: "Puntos de Agenda" meeting_agenda: "Agenda" meeting_minutes: "Minutas" + meeting_section: "Section" activity: filter: meeting: "Reuniones" + item: + meeting_agenda_item: + duration: + added: "fijado en %{value}" + added_html: "fijado en %{value}" + removed: "eliminado" + updated: "cambiado de %{old_value} a %{value}" + updated_html: "cambiado de %{old_value} a %{value}" + position: + updated: "reordenado" + work_package: + updated: "cambiado de %{old_value} a %{value}" + updated_html: "cambiado de %{old_value} a %{value}" description_attended: "ha asistido" description_invite: "invitado" events: diff --git a/modules/meeting/config/locales/crowdin/et.yml b/modules/meeting/config/locales/crowdin/et.yml index 2bcec63a168b..a38c1e645212 100644 --- a/modules/meeting/config/locales/crowdin/et.yml +++ b/modules/meeting/config/locales/crowdin/et.yml @@ -58,9 +58,23 @@ et: meeting_agenda_item: "Agenda item" meeting_agenda: "Päevakava" meeting_minutes: "Minutit" + meeting_section: "Section" activity: filter: meeting: "Koosolekud" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "osales" description_invite: "ktsutud" events: diff --git a/modules/meeting/config/locales/crowdin/eu.yml b/modules/meeting/config/locales/crowdin/eu.yml index da0859fdd9e1..2af65e54def8 100644 --- a/modules/meeting/config/locales/crowdin/eu.yml +++ b/modules/meeting/config/locales/crowdin/eu.yml @@ -58,9 +58,23 @@ eu: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Aktak" + meeting_section: "Section" activity: filter: meeting: "Hitzorduak" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "Egon da" description_invite: "Gonbidatua izan da" events: diff --git a/modules/meeting/config/locales/crowdin/fa.yml b/modules/meeting/config/locales/crowdin/fa.yml index 356dc4f999f7..9f2173818213 100644 --- a/modules/meeting/config/locales/crowdin/fa.yml +++ b/modules/meeting/config/locales/crowdin/fa.yml @@ -58,9 +58,23 @@ fa: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "دقیقه ها" + meeting_section: "Section" activity: filter: meeting: "جلسات" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/fi.yml b/modules/meeting/config/locales/crowdin/fi.yml index 9472447d4ffa..91bcdf289f5c 100644 --- a/modules/meeting/config/locales/crowdin/fi.yml +++ b/modules/meeting/config/locales/crowdin/fi.yml @@ -58,9 +58,23 @@ fi: meeting_agenda_item: "Agenda item" meeting_agenda: "Esityslista" meeting_minutes: "Pöytäkirja" + meeting_section: "Section" activity: filter: meeting: "Kokoukset" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "osallistunut" description_invite: "kutsuttu" events: diff --git a/modules/meeting/config/locales/crowdin/fil.yml b/modules/meeting/config/locales/crowdin/fil.yml index 141931601ee3..a7db74607ffc 100644 --- a/modules/meeting/config/locales/crowdin/fil.yml +++ b/modules/meeting/config/locales/crowdin/fil.yml @@ -58,9 +58,23 @@ fil: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Mga Pagpupulong" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "dumalo" description_invite: "maimbitahan" events: diff --git a/modules/meeting/config/locales/crowdin/fr.yml b/modules/meeting/config/locales/crowdin/fr.yml index 6acf46548fa4..ae5e5ef72891 100644 --- a/modules/meeting/config/locales/crowdin/fr.yml +++ b/modules/meeting/config/locales/crowdin/fr.yml @@ -58,9 +58,23 @@ fr: meeting_agenda_item: "" meeting_agenda: "Ordre du jour" meeting_minutes: "Compte-rendu" + meeting_section: "Section" activity: filter: meeting: "Réunions" + item: + meeting_agenda_item: + duration: + added: "défini sur %{value}" + added_html: "défini sur %{value}" + removed: "supprimé" + updated: "modifié de %{old_value} à %{value}" + updated_html: "modifié de %{old_value} à %{value}" + position: + updated: "réorganisé" + work_package: + updated: "modifié de %{old_value} à %{value}" + updated_html: "modifié de %{old_value} à %{value}" description_attended: "ont participé" description_invite: "invité" events: diff --git a/modules/meeting/config/locales/crowdin/he.yml b/modules/meeting/config/locales/crowdin/he.yml index a6975c1e4a4a..8fec1974c3aa 100644 --- a/modules/meeting/config/locales/crowdin/he.yml +++ b/modules/meeting/config/locales/crowdin/he.yml @@ -60,9 +60,23 @@ he: meeting_agenda_item: "Agenda item" meeting_agenda: "סדר היום" meeting_minutes: "דקות" + meeting_section: "Section" activity: filter: meeting: "פגישות" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "נכחו" description_invite: "הוזמנו" events: diff --git a/modules/meeting/config/locales/crowdin/hi.yml b/modules/meeting/config/locales/crowdin/hi.yml index 0bb23b1a2c1b..3a4239a5ab3a 100644 --- a/modules/meeting/config/locales/crowdin/hi.yml +++ b/modules/meeting/config/locales/crowdin/hi.yml @@ -58,9 +58,23 @@ hi: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/hr.yml b/modules/meeting/config/locales/crowdin/hr.yml index ca98b0420163..4d12a58be53e 100644 --- a/modules/meeting/config/locales/crowdin/hr.yml +++ b/modules/meeting/config/locales/crowdin/hr.yml @@ -59,9 +59,23 @@ hr: meeting_agenda_item: "Agenda item" meeting_agenda: "Dnevni red" meeting_minutes: "Minute" + meeting_section: "Section" activity: filter: meeting: "Sastanci" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "prisustvovali su" description_invite: "pozvani" events: diff --git a/modules/meeting/config/locales/crowdin/hu.yml b/modules/meeting/config/locales/crowdin/hu.yml index 4f497df998d8..79445029b3e2 100644 --- a/modules/meeting/config/locales/crowdin/hu.yml +++ b/modules/meeting/config/locales/crowdin/hu.yml @@ -58,9 +58,23 @@ hu: meeting_agenda_item: "Napirendi pont" meeting_agenda: "Napirend" meeting_minutes: "Jegyzőkönyv" + meeting_section: "Section" activity: filter: meeting: "Megbeszélések" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "részt vett" description_invite: "meghívott" events: diff --git a/modules/meeting/config/locales/crowdin/id.yml b/modules/meeting/config/locales/crowdin/id.yml index ec6afd53f76d..a798145696b1 100644 --- a/modules/meeting/config/locales/crowdin/id.yml +++ b/modules/meeting/config/locales/crowdin/id.yml @@ -57,9 +57,23 @@ id: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Laporan" + meeting_section: "Section" activity: filter: meeting: "Rapat" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "diubah dari %{old_value} menjadi %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "diubah dari %{old_value} menjadi %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "hadir" description_invite: "diundang" events: diff --git a/modules/meeting/config/locales/crowdin/it.yml b/modules/meeting/config/locales/crowdin/it.yml index 97b03c3d7d46..0f65741c2f62 100644 --- a/modules/meeting/config/locales/crowdin/it.yml +++ b/modules/meeting/config/locales/crowdin/it.yml @@ -58,9 +58,23 @@ it: meeting_agenda_item: "Attività" meeting_agenda: "Ordine del giorno" meeting_minutes: "Verbali" + meeting_section: "Section" activity: filter: meeting: "Riunioni" + item: + meeting_agenda_item: + duration: + added: "impostato a %{value}" + added_html: "impostato a %{value}" + removed: "rimosso" + updated: "modificato da %{old_value} a %{value}" + updated_html: "modificato da %{old_value} a %{value}" + position: + updated: "riordinato" + work_package: + updated: "modificato da %{old_value} a %{value}" + updated_html: "modificato da %{old_value} a %{value}" description_attended: "ha partecipato" description_invite: "invitato" events: diff --git a/modules/meeting/config/locales/crowdin/ja.yml b/modules/meeting/config/locales/crowdin/ja.yml index 13387f19f16c..c92a632d1192 100644 --- a/modules/meeting/config/locales/crowdin/ja.yml +++ b/modules/meeting/config/locales/crowdin/ja.yml @@ -57,9 +57,23 @@ ja: meeting_agenda_item: "Agenda item" meeting_agenda: "アジェンダ" meeting_minutes: "議事録" + meeting_section: "Section" activity: filter: meeting: "会議" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "出席した" description_invite: "招待済み" events: diff --git a/modules/meeting/config/locales/crowdin/ka.yml b/modules/meeting/config/locales/crowdin/ka.yml index ee3c5a643fc4..2361f0940c55 100644 --- a/modules/meeting/config/locales/crowdin/ka.yml +++ b/modules/meeting/config/locales/crowdin/ka.yml @@ -58,9 +58,23 @@ ka: meeting_agenda_item: "განრიგის პუნქტი" meeting_agenda: "დღის განრიგი" meeting_minutes: "წუთი" + meeting_section: "Section" activity: filter: meeting: "შეხვედრები" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "დაესწრო" description_invite: "მოწვეულია" events: diff --git a/modules/meeting/config/locales/crowdin/kk.yml b/modules/meeting/config/locales/crowdin/kk.yml index 24b815bb33f2..c1312f0bacbf 100644 --- a/modules/meeting/config/locales/crowdin/kk.yml +++ b/modules/meeting/config/locales/crowdin/kk.yml @@ -58,9 +58,23 @@ kk: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/ko.yml b/modules/meeting/config/locales/crowdin/ko.yml index c03f9ae280e4..3ead8c74fde5 100644 --- a/modules/meeting/config/locales/crowdin/ko.yml +++ b/modules/meeting/config/locales/crowdin/ko.yml @@ -57,9 +57,23 @@ ko: meeting_agenda_item: "의제 항목" meeting_agenda: "의제" meeting_minutes: "의사록" + meeting_section: "Section" activity: filter: meeting: "미팅" + item: + meeting_agenda_item: + duration: + added: "%{value}(으)로 설정됨" + added_html: "%{value}(으)로 설정됨" + removed: "제거됨" + updated: "%{old_value}에서 %{value}(으)로 변경됨" + updated_html: "%{old_value}에서 %{value}(으)로 변경됨" + position: + updated: "재정렬됨" + work_package: + updated: "%{old_value}에서 %{value}(으)로 변경됨" + updated_html: "%{old_value}에서 %{value}(으)로 변경됨" description_attended: "참석함" description_invite: "초대됨" events: diff --git a/modules/meeting/config/locales/crowdin/lt.yml b/modules/meeting/config/locales/crowdin/lt.yml index 035789fb594f..a5b28bf32b5e 100644 --- a/modules/meeting/config/locales/crowdin/lt.yml +++ b/modules/meeting/config/locales/crowdin/lt.yml @@ -60,9 +60,23 @@ lt: meeting_agenda_item: "Darbotvarkės punktas" meeting_agenda: "Dienotvarkė" meeting_minutes: "Minutės" + meeting_section: "Section" activity: filter: meeting: "Susitikimai" + item: + meeting_agenda_item: + duration: + added: "nustatyti %{value}" + added_html: "nustatyti %{value}" + removed: "išimta" + updated: "pakeitė iš %{old_value} į %{value}" + updated_html: "pakeista iš %{old_value} į %{value}" + position: + updated: "perrikiuota" + work_package: + updated: "pakeista iš %{old_value} į %{value}" + updated_html: "pakeista iš %{old_value} į %{value}" description_attended: "būtinai dalyvavo" description_invite: "pakviesta" events: diff --git a/modules/meeting/config/locales/crowdin/lv.yml b/modules/meeting/config/locales/crowdin/lv.yml index 3e7c01f486e9..e4038e4ea1c6 100644 --- a/modules/meeting/config/locales/crowdin/lv.yml +++ b/modules/meeting/config/locales/crowdin/lv.yml @@ -59,9 +59,23 @@ lv: meeting_agenda_item: "Darba kārtības punkts" meeting_agenda: "Darba kārtība" meeting_minutes: "Protokols" + meeting_section: "Section" activity: filter: meeting: "Sanāksmes" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "apmeklēja" description_invite: "uzaicināts" events: diff --git a/modules/meeting/config/locales/crowdin/mn.yml b/modules/meeting/config/locales/crowdin/mn.yml index 5ecd378b9ba4..f58454cacdd7 100644 --- a/modules/meeting/config/locales/crowdin/mn.yml +++ b/modules/meeting/config/locales/crowdin/mn.yml @@ -58,9 +58,23 @@ mn: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/ms.yml b/modules/meeting/config/locales/crowdin/ms.yml index d7917b6f4d30..cd3f4b60a5d4 100644 --- a/modules/meeting/config/locales/crowdin/ms.yml +++ b/modules/meeting/config/locales/crowdin/ms.yml @@ -57,9 +57,23 @@ ms: meeting_agenda_item: "Item agenda" meeting_agenda: "Agenda" meeting_minutes: "Minit mesyuarat" + meeting_section: "Section" activity: filter: meeting: "Mesyuarat" + item: + meeting_agenda_item: + duration: + added: "ditetapkan kepada %{value}" + added_html: "ditetapkan kepada %{value}" + removed: "dikeluarkan" + updated: "ditukar dari %{old_value} kepada %{value}" + updated_html: "ditukar dari %{old_value} kepada %{value}" + position: + updated: "disusun semula" + work_package: + updated: "ditukar dari %{old_value} kepada %{value}" + updated_html: "ditukar dari %{old_value} kepada %{value}" description_attended: "dihadiri" description_invite: "dijemput" events: diff --git a/modules/meeting/config/locales/crowdin/ne.yml b/modules/meeting/config/locales/crowdin/ne.yml index af194d691aa6..e0517d4e1c60 100644 --- a/modules/meeting/config/locales/crowdin/ne.yml +++ b/modules/meeting/config/locales/crowdin/ne.yml @@ -58,9 +58,23 @@ ne: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/nl.yml b/modules/meeting/config/locales/crowdin/nl.yml index f0abbb1bb3f3..166c861c534e 100644 --- a/modules/meeting/config/locales/crowdin/nl.yml +++ b/modules/meeting/config/locales/crowdin/nl.yml @@ -58,9 +58,23 @@ nl: meeting_agenda_item: "Agendapunt" meeting_agenda: "Agenda" meeting_minutes: "Minuten" + meeting_section: "Section" activity: filter: meeting: "Vergaderingen" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "veranderd van %{old_value} naar %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "veranderd van %{old_value} naar %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "bijgewoond" description_invite: "uitgenodigd" events: diff --git a/modules/meeting/config/locales/crowdin/no.yml b/modules/meeting/config/locales/crowdin/no.yml index cac6c4f429d8..2710e1d82c8c 100644 --- a/modules/meeting/config/locales/crowdin/no.yml +++ b/modules/meeting/config/locales/crowdin/no.yml @@ -58,9 +58,23 @@ meeting_agenda_item: "Dagsorden element" meeting_agenda: "Saksliste" meeting_minutes: "Referat" + meeting_section: "Section" activity: filter: meeting: "Møter" + item: + meeting_agenda_item: + duration: + added: "satt til %{value}" + added_html: "satt til %{value}" + removed: "fjernet" + updated: "endret fra %{old_value} til %{value}" + updated_html: "endret fra %{old_value} til %{value}" + position: + updated: "omsortert" + work_package: + updated: "endret fra %{old_value} til %{value}" + updated_html: "endret fra %{old_value} til %{value}" description_attended: "deltok" description_invite: "invitert" events: diff --git a/modules/meeting/config/locales/crowdin/pl.yml b/modules/meeting/config/locales/crowdin/pl.yml index 3ddf8efaa7ed..1bc94b95ace0 100644 --- a/modules/meeting/config/locales/crowdin/pl.yml +++ b/modules/meeting/config/locales/crowdin/pl.yml @@ -60,9 +60,23 @@ pl: meeting_agenda_item: "Punkt programu" meeting_agenda: "Plan spotkania" meeting_minutes: "Protokół ze spotkania" + meeting_section: "Section" activity: filter: meeting: "Spotkania" + item: + meeting_agenda_item: + duration: + added: "ustawiono na %{value}" + added_html: "ustawiono na %{value}" + removed: "usunięto" + updated: "zmieniono z %{old_value} na %{value}" + updated_html: "zmieniono z %{old_value} na %{value}" + position: + updated: "zmieniono kolejność" + work_package: + updated: "zmieniono z %{old_value} na %{value}" + updated_html: "zmieniono z %{old_value} na %{value}" description_attended: "Obecny" description_invite: "Zaproszony" events: diff --git a/modules/meeting/config/locales/crowdin/pt-BR.yml b/modules/meeting/config/locales/crowdin/pt-BR.yml index c54203ff864c..4ee36b373c64 100644 --- a/modules/meeting/config/locales/crowdin/pt-BR.yml +++ b/modules/meeting/config/locales/crowdin/pt-BR.yml @@ -58,9 +58,23 @@ pt-BR: meeting_agenda_item: "Item da agenda" meeting_agenda: "Agenda" meeting_minutes: "Atas" + meeting_section: "Section" activity: filter: meeting: "Reuniões" + item: + meeting_agenda_item: + duration: + added: "definido como %{value}" + added_html: "definido como %{value}" + removed: "removido" + updated: "alterado de %{old_value} para %{value}" + updated_html: "alterado de %{old_value} para %{value}" + position: + updated: "reorganizado" + work_package: + updated: "alterado de %{old_value} para %{value}" + updated_html: "alterado de %{old_value} para %{value}" description_attended: "compareceu" description_invite: "convidado" events: diff --git a/modules/meeting/config/locales/crowdin/pt-PT.yml b/modules/meeting/config/locales/crowdin/pt-PT.yml index ee16f445e43a..c1359db8c076 100644 --- a/modules/meeting/config/locales/crowdin/pt-PT.yml +++ b/modules/meeting/config/locales/crowdin/pt-PT.yml @@ -58,9 +58,23 @@ pt-PT: meeting_agenda_item: "Pontos da ordem de trabalhos" meeting_agenda: "Agenda" meeting_minutes: "Minutos" + meeting_section: "Section" activity: filter: meeting: "Reuniões" + item: + meeting_agenda_item: + duration: + added: "definido como %{value}" + added_html: "definido como %{value}" + removed: "removido" + updated: "alterado de %{old_value} para %{value}" + updated_html: "alterado de %{old_value} para %{value}" + position: + updated: "reordenado" + work_package: + updated: "alterado de %{old_value} para %{value}" + updated_html: "alterado de %{old_value} para %{value}" description_attended: "participantes" description_invite: "convidados" events: diff --git a/modules/meeting/config/locales/crowdin/ro.yml b/modules/meeting/config/locales/crowdin/ro.yml index f28d9bf5b257..fe513c9db315 100644 --- a/modules/meeting/config/locales/crowdin/ro.yml +++ b/modules/meeting/config/locales/crowdin/ro.yml @@ -59,9 +59,23 @@ ro: meeting_agenda_item: "Agenda item" meeting_agenda: "Agendă" meeting_minutes: "Concluzii" + meeting_section: "Section" activity: filter: meeting: "Reuniuni" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "a participat" description_invite: "a fost invitat" events: diff --git a/modules/meeting/config/locales/crowdin/ru.yml b/modules/meeting/config/locales/crowdin/ru.yml index 25deae587d4f..ff24f27e5724 100644 --- a/modules/meeting/config/locales/crowdin/ru.yml +++ b/modules/meeting/config/locales/crowdin/ru.yml @@ -60,9 +60,23 @@ ru: meeting_agenda_item: "Пункт повестки" meeting_agenda: "Повестка дня" meeting_minutes: "Протокол(-ы)" + meeting_section: "Раздел" activity: filter: meeting: "Совещания" + item: + meeting_agenda_item: + duration: + added: "установлено в %{value}" + added_html: "установлено в %{value}" + removed: "удалено" + updated: "изменено с %{old_value} на %{value}" + updated_html: "изменено с %{old_value} на %{value}" + position: + updated: "переупорядочено" + work_package: + updated: "изменено с %{old_value} на %{value}" + updated_html: "изменено с %{old_value} на %{value}" description_attended: "участие" description_invite: "приглашено" events: diff --git a/modules/meeting/config/locales/crowdin/rw.yml b/modules/meeting/config/locales/crowdin/rw.yml index 8d5264a8fed9..c159f79e031d 100644 --- a/modules/meeting/config/locales/crowdin/rw.yml +++ b/modules/meeting/config/locales/crowdin/rw.yml @@ -58,9 +58,23 @@ rw: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/si.yml b/modules/meeting/config/locales/crowdin/si.yml index 7431700c0458..7a0a5654b5f7 100644 --- a/modules/meeting/config/locales/crowdin/si.yml +++ b/modules/meeting/config/locales/crowdin/si.yml @@ -58,9 +58,23 @@ si: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "ආරාධනා" events: diff --git a/modules/meeting/config/locales/crowdin/sk.yml b/modules/meeting/config/locales/crowdin/sk.yml index 37d36fb3d8de..baed2b580f72 100644 --- a/modules/meeting/config/locales/crowdin/sk.yml +++ b/modules/meeting/config/locales/crowdin/sk.yml @@ -60,9 +60,23 @@ sk: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Zápisnica" + meeting_section: "Section" activity: filter: meeting: "Stretnutia" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "sa zúčastnil" description_invite: "pozvaní" events: diff --git a/modules/meeting/config/locales/crowdin/sl.yml b/modules/meeting/config/locales/crowdin/sl.yml index 241521cc2e97..8ecaf19a311c 100644 --- a/modules/meeting/config/locales/crowdin/sl.yml +++ b/modules/meeting/config/locales/crowdin/sl.yml @@ -60,9 +60,23 @@ sl: meeting_agenda_item: "Agenda item" meeting_agenda: "Dnevni red" meeting_minutes: "Zapisnik" + meeting_section: "Section" activity: filter: meeting: "Sestanki" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "se je udeležil/a" description_invite: "Povabljen" events: diff --git a/modules/meeting/config/locales/crowdin/sr.yml b/modules/meeting/config/locales/crowdin/sr.yml index c86b311833e1..b686dd309a1c 100644 --- a/modules/meeting/config/locales/crowdin/sr.yml +++ b/modules/meeting/config/locales/crowdin/sr.yml @@ -59,9 +59,23 @@ sr: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/sv.yml b/modules/meeting/config/locales/crowdin/sv.yml index b7fdbfa4a8ea..45e376dc0c87 100644 --- a/modules/meeting/config/locales/crowdin/sv.yml +++ b/modules/meeting/config/locales/crowdin/sv.yml @@ -58,9 +58,23 @@ sv: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Protokoll" + meeting_section: "Section" activity: filter: meeting: "Möten" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "deltog" description_invite: "inbjudna" events: diff --git a/modules/meeting/config/locales/crowdin/th.yml b/modules/meeting/config/locales/crowdin/th.yml index edaf097b87e7..2729b7aa0f27 100644 --- a/modules/meeting/config/locales/crowdin/th.yml +++ b/modules/meeting/config/locales/crowdin/th.yml @@ -57,9 +57,23 @@ th: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "ประชุม" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/tr.yml b/modules/meeting/config/locales/crowdin/tr.yml index 7889c4169090..8aa974ac5b9e 100644 --- a/modules/meeting/config/locales/crowdin/tr.yml +++ b/modules/meeting/config/locales/crowdin/tr.yml @@ -58,9 +58,23 @@ tr: meeting_agenda_item: "Ajanda öğesi" meeting_agenda: "Ajanda" meeting_minutes: "Dakika" + meeting_section: "Section" activity: filter: meeting: "Toplantılar" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "katıldı" description_invite: "davet et" events: diff --git a/modules/meeting/config/locales/crowdin/uk.yml b/modules/meeting/config/locales/crowdin/uk.yml index 661b8fd35e56..c39410201798 100644 --- a/modules/meeting/config/locales/crowdin/uk.yml +++ b/modules/meeting/config/locales/crowdin/uk.yml @@ -60,9 +60,23 @@ uk: meeting_agenda_item: "Порядок денний" meeting_agenda: "Порядок денний" meeting_minutes: "Хвилини" + meeting_section: "Section" activity: filter: meeting: "Наради" + item: + meeting_agenda_item: + duration: + added: "встановлено значення %{value}" + added_html: "встановлено значення %{value}" + removed: "вилучено" + updated: "змінено %{old_value} на %{value}" + updated_html: "змінено з %{old_value} на %{value}" + position: + updated: "перевпорядковано" + work_package: + updated: "змінено з %{old_value} на %{value}" + updated_html: "змінено з %{old_value} на %{value}" description_attended: "Участь" description_invite: "Запрошені" events: diff --git a/modules/meeting/config/locales/crowdin/uz.yml b/modules/meeting/config/locales/crowdin/uz.yml index 4c33c93d20cc..fc63d672201c 100644 --- a/modules/meeting/config/locales/crowdin/uz.yml +++ b/modules/meeting/config/locales/crowdin/uz.yml @@ -58,9 +58,23 @@ uz: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/vi.yml b/modules/meeting/config/locales/crowdin/vi.yml index 01da88f967e0..f3e0ac9e1209 100644 --- a/modules/meeting/config/locales/crowdin/vi.yml +++ b/modules/meeting/config/locales/crowdin/vi.yml @@ -57,9 +57,23 @@ vi: meeting_agenda_item: "Agenda item" meeting_agenda: "Các ý chính" meeting_minutes: "Phút" + meeting_section: "Section" activity: filter: meeting: "Những cuộc họp" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "Đã tham dự" description_invite: "invited" events: diff --git a/modules/meeting/config/locales/crowdin/zh-CN.yml b/modules/meeting/config/locales/crowdin/zh-CN.yml index fc0170413aba..378a137df41d 100644 --- a/modules/meeting/config/locales/crowdin/zh-CN.yml +++ b/modules/meeting/config/locales/crowdin/zh-CN.yml @@ -57,9 +57,23 @@ zh-CN: meeting_agenda_item: "议程项目" meeting_agenda: "议程" meeting_minutes: "会议记录" + meeting_section: "节" activity: filter: meeting: "会议" + item: + meeting_agenda_item: + duration: + added: "设为 %{value}" + added_html: "设为 %{value}" + removed: "已删除" + updated: "从 %{old_value} 更改为 %{value}" + updated_html: "从 %{old_value}改为 %{value}" + position: + updated: "重新排序" + work_package: + updated: "从 %{old_value} 更改为 %{value}" + updated_html: "从 %{old_value}改为 %{value}" description_attended: "已出席" description_invite: "已邀请" events: diff --git a/modules/meeting/config/locales/crowdin/zh-TW.yml b/modules/meeting/config/locales/crowdin/zh-TW.yml index a8a6a344c74e..fa238a3ac855 100644 --- a/modules/meeting/config/locales/crowdin/zh-TW.yml +++ b/modules/meeting/config/locales/crowdin/zh-TW.yml @@ -57,9 +57,23 @@ zh-TW: meeting_agenda_item: "議程項目" meeting_agenda: "會議大綱" meeting_minutes: "會議記錄" + meeting_section: "Section" activity: filter: meeting: "會議" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "從 %{old_value} 更改至 %{value}" + updated_html: "從 %{old_value} 更改至 %{value}" + position: + updated: "重新排列" + work_package: + updated: "從 %{old_value} 更改至 %{value}" + updated_html: "從 %{old_value} 更改至 %{value}" description_attended: "出席人員" description_invite: "已邀請" events: diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index e895b0aea35b..5a8a0ed125b3 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -69,10 +69,24 @@ en: meeting_agenda_item: "Agenda item" meeting_agenda: "Agenda" meeting_minutes: "Minutes" + meeting_section: "Section" activity: filter: meeting: "Meetings" + item: + meeting_agenda_item: + duration: + added: "set to %{value}" + added_html: "set to %{value}" + removed: "removed" + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" + position: + updated: "reordered" + work_package: + updated: "changed from %{old_value} to %{value}" + updated_html: "changed from %{old_value} to %{value}" description_attended: "attended" description_invite: "invited" diff --git a/modules/openid_connect/config/locales/crowdin/ar.yml b/modules/openid_connect/config/locales/crowdin/ar.yml index 7ad37fa0866f..f6f2f5313745 100644 --- a/modules/openid_connect/config/locales/crowdin/ar.yml +++ b/modules/openid_connect/config/locales/crowdin/ar.yml @@ -1,32 +1,32 @@ ar: plugin_openproject_openid_connect: name: "OpenProject OpenID Connect" - description: "Adds OmniAuth OpenID Connect strategy providers to OpenProject." + description: "يضيف مزودي استراتيجية OmniAuth OpenID Connect إلى OpenProject." logout_warning: > - You have been logged out. The contents of any form you submit may be lost. Please [log in]. + لقد تم تسجيل خروجك. قد يتم فقدان محتويات أي نموذج تقوم بإرساله. يرجى [تسجيل الدخول]. activemodel: attributes: openid_connect/provider: name: الاسم - display_name: Display name + display_name: اسم العرض identifier: المعرّف - secret: Secret - scope: Scope + secret: السر + scope: النطاق limit_self_registration: Limit self registration openid_connect: - menu_title: OpenID providers + menu_title: مزودو OpenID providers: - label_add_new: Add a new OpenID provider - label_edit: Edit OpenID provider %{name} - no_results_table: No providers have been defined yet. - plural: OpenID providers - singular: OpenID provider + label_add_new: إضافة مزود OpenID جديد + label_edit: تعديل موفر OpenID %{name} + no_results_table: لم يتم تعريف أى مزودين حتى الآن + plural: مزودو OpenID + singular: مزود OpenID setting_instructions: azure_deprecation_warning: > - The configured Azure app points to a deprecated API from Azure. Please create a new Azure app to ensure the functionality in future. + تطبيق Azure الذي تم إعدادة يشير إلى واجهة برمجة تطبيقات غير معتمدة من Azure. يرجي انشاء تطبيق Azure جديد لضمان عمله مستقبلا. azure_graph_api: > - Use the graph.microsoft.com userinfo endpoint to request userdata. This should be the default unless you have an older azure application. + استخدم نقطة النهاية graph.microsoft.com userinfo لطلب بيانات المستخدم. يجب أن يكون هذا هو الإختيار الافتراضي إلا إذا كان لديك تطبيق Azure قديم. azure_tenant_html: > - Set the tenant of your Azure endpoint. This will control who gets access to the OpenProject instance. For more information, please see our user guide on Azure OpenID connect. + قم بإعداد Azure Tenant الخاص بك. حيث سيتم التحكم في من بإمكانه الوصول الى نسخة OpenProject. gl. لمزيد من المعلومات يرجي الإطلاع على دليل المستخدم الخاص بنا على Azure OpenID connect. limit_self_registration: > If enabled users can only register using this provider if the self registration setting allows for it. diff --git a/modules/openid_connect/config/locales/crowdin/no.yml b/modules/openid_connect/config/locales/crowdin/no.yml index a6aaeb100ef3..b019b1bda92b 100644 --- a/modules/openid_connect/config/locales/crowdin/no.yml +++ b/modules/openid_connect/config/locales/crowdin/no.yml @@ -1,7 +1,7 @@ "no": plugin_openproject_openid_connect: name: "OpenProject OpenID tilkobling" - description: "Legger til OmniAuth OpenID Connect strategileverandører til Openproject." + description: "Legger til strategileverandører for OmniAuth OpenID Connect til OpenProject." logout_warning: > Du har blitt logget ut. Innholdet kan gå tapt. Vennligst [logg in]. activemodel: @@ -10,23 +10,23 @@ name: Navn display_name: Visningsnavn identifier: Identifikator - secret: Hemmelig + secret: Hemmelig nøkkel scope: Omfang limit_self_registration: Begrens egenregistrering openid_connect: - menu_title: OpenID leverandører + menu_title: OpenID-leverandører providers: label_add_new: Legg til ny OpenID-leverandør label_edit: Rediger OpenID-leverandør %{name} no_results_table: Ingen leverandører har blitt definert ennå. - plural: OpenID leverandører + plural: OpenID-leverandører singular: OpenID-leverandør setting_instructions: azure_deprecation_warning: > - Den konfigurerte Asur-appen peker på et utdatert API fra Azure. Vennligst lag en ny Azure app for å sikre funksjonaliteten i fremtiden. + Den konfigurerte Azure-appen peker på et utdatert API fra Azure. Vennligst lag en ny Azure-app for å sikre funksjonaliteten i fremtiden. azure_graph_api: > Bruk «graph.microsoft.com»-brukerinfo til å be om brukerdata. Dette skal være standard med mindre du har en eldre Azure-applikasjon. azure_tenant_html: > - Sett leverandøren av ditt azure endepunkt. Dette vil kontrollere hvem som får tilgang til OpenProject instansen. For mer informasjon, se vår brukerveiledning på Azure OpenID tilkobling. + Sett leverandøren av ditt Azure-endepunkt. Dette vil kontrollere hvem som får tilgang til OpenProject-instansen. For mer informasjon, se vår brukerveiledning på Azure OpenID-tilkobling. limit_self_registration: > Hvis aktivert kan brukere bare registrere seg ved hjelp av denne leverandøren hvis selvregistreringsinnstillingen tillater det. diff --git a/modules/storages/app/views/storages/admin/storages/index.html.erb b/modules/storages/app/views/storages/admin/storages/index.html.erb index b2c6cd63a985..db38dbc49303 100644 --- a/modules/storages/app/views/storages/admin/storages/index.html.erb +++ b/modules/storages/app/views/storages/admin/storages/index.html.erb @@ -6,32 +6,41 @@ I18n.t("storages.label_storage") end %> -<%= render(Primer::OpenProject::PageHeader.new(border_bottom: 0)) do |header| %> +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> <% header.with_title { t("external_file_storages") } %> <% header.with_description { t("storages.page_titles.file_storages.subtitle") } %> <% header.with_breadcrumbs([{ href: admin_index_path, text: t("label_administration") }, { href: admin_settings_storages_path, text: t("project_module_storages") }, t("external_file_storages")]) %> +<% end %> + +<%= render(Primer::OpenProject::SubHeader.new) do |subheader| + subheader.with_action_component do + render(Primer::Alpha::ActionMenu.new(test_selector: 'storages-select-provider-action-menu', + anchor_align: :end)) do |menu| + menu.with_show_button(scheme: :primary, + aria: { label: I18n.t("storages.label_add_new_storage") }, + test_selector: "storages-create-new-provider-button") do |button| + button.with_leading_visual_icon(icon: :plus) + button.with_trailing_action_icon(icon: :"triangle-down") + I18n.t("storages.label_storage") + end - <%= header.with_action_menu(menu_arguments: { test_selector: 'storages-select-provider-action-menu', - anchor_align: :end }, - button_arguments: { scheme: :primary, - aria: { label: I18n.t("storages.label_add_new_storage") }, - button_block: button_block }) do |menu| - ::Storages::Storage::PROVIDER_TYPES.each do |provider_type| - short_provider_type = ::Storages::Storage.shorten_provider_type(provider_type) + ::Storages::Storage::PROVIDER_TYPES.each do |provider_type| + short_provider_type = ::Storages::Storage.shorten_provider_type(provider_type) - menu.with_item( - label: I18n.t("storages.provider_types.#{short_provider_type}.name"), - href: url_helpers.select_provider_admin_settings_storages_path(provider: short_provider_type) - ) do |item| - item.with_trailing_visual_icon( - icon: "op-enterprise-addons", - classes: "upsale-colored" - ) if ::Storages::Storage::one_drive_without_ee_token?(provider_type) + menu.with_item( + label: I18n.t("storages.provider_types.#{short_provider_type}.name"), + href: url_helpers.select_provider_admin_settings_storages_path(provider: short_provider_type) + ) do |item| + item.with_trailing_visual_icon( + icon: "op-enterprise-addons", + classes: "upsale-colored" + ) if ::Storages::Storage::one_drive_without_ee_token?(provider_type) + end end end - end %> -<% end %> + end +end %> <%= render(::Storages::Admin::StorageListComponent.new(@storages)) %> diff --git a/modules/storages/app/views/storages/storages_mailer/_health_status_notification.html.erb b/modules/storages/app/views/storages/storages_mailer/_health_status_notification.html.erb index f91d94bb9f13..5f8366dfed66 100644 --- a/modules/storages/app/views/storages/storages_mailer/_health_status_notification.html.erb +++ b/modules/storages/app/views/storages/storages_mailer/_health_status_notification.html.erb @@ -27,78 +27,76 @@ See COPYRIGHT and LICENSE files for more details. ++#%> - - <%= render layout: 'mailer/border_table' do %> - - - > - - <%= placeholder_cell('12px', vertical: true) %> - - <%= placeholder_cell('12px', vertical: true) %> - - - - <%= placeholder_cell('8px', vertical: false) %> - - - - <%= placeholder_cell('12px', vertical: true) %> - +<%= render layout: 'mailer/border_table' do %> + + - - <% end %> - + <% end %> +
    - > - - - - <%= placeholder_cell('8px', vertical: true) %> - - - -
    - <% if state == :unhealthy %> - <%= render Mailer::LabelComponent.new(scheme: :danger, text: 'Error') %> - <% else %> - <%= render Mailer::LabelComponent.new(scheme: :success, text: 'Healthy') %> - <% end %> - - <%= I18n.t("storages.provider_types.#{storage.short_provider_type}.name") %> - <%= storage.host %> -
    -
    <%= storage.name %>
    + > + + <%= placeholder_cell('12px', vertical: true) %> + + <%= placeholder_cell('12px', vertical: true) %> + + + + <%= placeholder_cell('8px', vertical: false) %> + + + + <%= placeholder_cell('12px', vertical: true) %> + + <%= placeholder_cell('12px', vertical: true) %> + + + + <%= placeholder_cell('8px', vertical: false) %> + + + + <%= placeholder_cell('12px', vertical: true) %> + + <%= placeholder_cell('12px', vertical: true) %> + + + <% if storage.provider_type == ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD && state == :unhealthy %> + <%= placeholder_cell('12px', vertical: true) %> - - - <%= placeholder_cell('8px', vertical: false) %> - - - - <%= placeholder_cell('12px', vertical: true) %> + <%= placeholder_cell('12px', vertical: true) %> - - <% if storage.provider_type == ::Storages::Storage::PROVIDER_TYPE_NEXTCLOUD && state == :unhealthy %> - - <%= placeholder_cell('12px', vertical: true) %> - - - - <%= placeholder_cell('12px', vertical: true) %> - - <% end %> -
    + > + + + + <%= placeholder_cell('8px', vertical: true) %> + + + +
    + <% if state == :unhealthy %> + <%= render Mailer::LabelComponent.new(scheme: :danger, text: 'Error') %> + <% else %> + <%= render Mailer::LabelComponent.new(scheme: :success, text: 'Healthy') %> + <% end %> + + <%= I18n.t("storages.provider_types.#{storage.short_provider_type}.name") %> - <%= storage.uri %> +
    +
    <%= storage.name %>
    + <% if state == :unhealthy %> + <%= reason %> <%= I18n.t('mail.storages.health.unhealthy.since') %> <%= format_time(storage.health_changed_at) %> + <% else %> + <%= reason %> <%= I18n.t('mail.storages.health.healthy.solved_at') %> <%= format_time(storage.health_changed_at) %> + <% end %> +
    - <% if state == :unhealthy %> - <%= reason %> <%= I18n.t('mail.storages.health.unhealthy.since') %> <%= format_time(storage.health_changed_at) %> - <% else %> - <%= reason %> <%= I18n.t('mail.storages.health.healthy.solved_at') %> <%= format_time(storage.health_changed_at) %> - <% end %> + <%= I18n.t('mail.storages.health.unhealthy.troubleshooting.text') %> + <%= I18n.t('mail.storages.health.unhealthy.troubleshooting.link_text') %>.
    - <%= I18n.t('mail.storages.health.unhealthy.troubleshooting.text') %> - <%= I18n.t('mail.storages.health.unhealthy.troubleshooting.link_text') %>. -
    -
    + + +<% end %> > diff --git a/modules/storages/app/workers/storages/health_status_mailer_job.rb b/modules/storages/app/workers/storages/health_status_mailer_job.rb index d765e8746104..01b3ba5df0ae 100644 --- a/modules/storages/app/workers/storages/health_status_mailer_job.rb +++ b/modules/storages/app/workers/storages/health_status_mailer_job.rb @@ -50,7 +50,7 @@ def perform(storage:) return if storage.health_healthy? admin_users.each do |admin| - ::Storages::StoragesMailer.notify_unhealthy(admin, storage).deliver_later + StoragesMailer.notify_unhealthy(admin, storage).deliver_later end HealthStatusMailerJob.schedule(storage:) diff --git a/modules/storages/spec/features/storages/admin/create_storage_spec.rb b/modules/storages/spec/features/storages/admin/create_storage_spec.rb index 08434f5cd136..620e1a3eee7e 100644 --- a/modules/storages/spec/features/storages/admin/create_storage_spec.rb +++ b/modules/storages/spec/features/storages/admin/create_storage_spec.rb @@ -50,8 +50,8 @@ expect(page).to be_axe_clean.within "#content" - within(".PageHeader-titleBar") do - click_on("Storage") + within(".SubHeader") do + page.find_test_selector("storages-create-new-provider-button").click within_test_selector("storages-select-provider-action-menu") { click_on("Nextcloud") } end @@ -193,8 +193,8 @@ it "renders enterprise icon and redirects to upsale", :webmock do visit admin_settings_storages_path - within(".PageHeader-titleBar") do - click_on("Storage") + within(".SubHeader") do + page.find_test_selector("storages-create-new-provider-button").click within_test_selector("storages-select-provider-action-menu") do expect(page).to have_css(".octicon-op-enterprise-addons") @@ -213,9 +213,8 @@ expect(page).to be_axe_clean.within "#content" - within(".PageHeader-titleBar") do - click_on("Storage") - + within(".SubHeader") do + page.find_test_selector("storages-create-new-provider-button").click within_test_selector("storages-select-provider-action-menu") { click_on("OneDrive/SharePoint") } end diff --git a/modules/webhooks/config/locales/crowdin/no.yml b/modules/webhooks/config/locales/crowdin/no.yml index 796eb300644c..47bc3bb3c2fe 100644 --- a/modules/webhooks/config/locales/crowdin/no.yml +++ b/modules/webhooks/config/locales/crowdin/no.yml @@ -5,14 +5,14 @@ activerecord: attributes: webhooks/webhook: - url: 'Nyttelast URL' - secret: 'Hemmelig signatur' + url: 'Nyttelast-URL' + secret: 'Hemmelig signaturnøkkel' events: 'Hendelser' projects: 'Aktiverte prosjekter' webhooks/log: event_name: 'Navn på hendelse' - url: 'Nyttelast URL' - response_code: 'Svar kode' + url: 'Nyttelast-URL' + response_code: 'Svarkode' response_body: 'Svar' models: webhooks/outgoing_webhook: "Utgående webhook" @@ -21,26 +21,26 @@ plural: Webhooks resources: time_entry: - name: "Tid oppføring" + name: "Timeføring" outgoing: no_results_table: Ingen webhooks har blitt definert ennå. label_add_new: Legg til ny webhook label_edit: Rediger webhook - label_event_resources: Event ressurser + label_event_resources: Hendelsesressurser events: created: "Opprettet" updated: "Oppdatert" explanation: text: > - Ved forekomst av en hendelse som oppretting av en arbeidspakke eller en oppdatering fra et prosjekt, OpenProject vil sende en POST-forespørsel til de konfigurerte nettendepunktene. Ofte blir arrangementet sendt etter at %{link} er godkjent. + Ved forekomst av en hendelse som oppretting av en arbeidspakke eller en oppdatering fra et prosjekt, vil OpenProject sende en POST-forespørsel til de konfigurerte nettendepunktene. Ofte blir hendelsen sendt etter at %{link} er godkjent. link: konfigurert summeringsperiode status: - enabled: 'Webhooks aktivert' - disabled: 'Webhooks er deaktivert' - enabled_text: 'webhooken sender ut nyttelaster for de definerte hendelsene nedenfor.' + enabled: 'Webhook er aktivert' + disabled: 'Webhook er deaktivert' + enabled_text: 'Webhooken sender ut nyttelaster for de definerte hendelsene nedenfor.' disabled_text: 'Klikk på rediger-knappen for å aktivere webhook.' deliveries: - no_results_table: Ingen leveranser er gjennomført for dette webhooket de siste dagene. + no_results_table: Ingen leveranser er gjennomført for denne webhooken de siste dagene. title: 'Nylige leveranser' time: 'Tid for levering' form: @@ -51,7 +51,7 @@ placeholder: 'Valgfri beskrivelse for webhook.' enabled: description: > - Når merket av, vil webhook utløse på de valgte hendelsene. Fjern avmerkingen for å deaktivere webhook. + Når merket av, vil webhooken utløses for de valgte hendelsene. Fjern merkingen for å deaktivere webhooken. events: title: 'Aktiverte hendelser' project_ids: @@ -63,4 +63,4 @@ title: 'Valgte prosjekter' secret: description: > - Hvis angitt, brukes denne hemmelige verdien av OpenProject for å signere webhook nyttelasten. + Hvis angitt, brukes denne hemmelige verdien av OpenProject for å signere webhook-nyttelasten. diff --git a/packaging/addons/openproject/bin/postinstall b/packaging/addons/openproject/bin/postinstall index 403f26a16ac2..7192cf54dc93 100755 --- a/packaging/addons/openproject/bin/postinstall +++ b/packaging/addons/openproject/bin/postinstall @@ -10,6 +10,11 @@ CLI="${APP_NAME}" ${CLI} config:set OPENPROJECT_SEED_ADMIN_USER_MAIL="$(wiz_get "openproject/admin_email" || wiz_get "smtp/admin_email")" ${CLI} config:set RECOMPILE_RAILS_ASSETS="true" +# Convert language pt to pt-BR (work packages #53374 and #55318) +if [ "$(wiz_get "openproject/default_language")" = "pt" ]; then + wiz_set "openproject/default_language" "pt-BR" +fi + # Set the configured default language # Will be unset at installation postinstall before restart to ensure the setting is writable ${CLI} config:set OPENPROJECT_DEFAULT_LANGUAGE="$(wiz_get "openproject/default_language" || echo "en")" diff --git a/script/github_pr_errors b/script/github_pr_errors index 4a7741037169..44c8fd7747fc 100755 --- a/script/github_pr_errors +++ b/script/github_pr_errors @@ -10,6 +10,7 @@ require "optparse" require "base64" require "pathname" require "pry" +require "time" require "yaml" require "httpx" @@ -274,6 +275,7 @@ class Report :head_branch, :head_sha, :commit_message, + :run_started_at, :merge_branch_sha end @@ -283,7 +285,7 @@ end # rubocop:disable Layout/LineLength # Looks like this in the job log: -# Process 28: TEST_ENV_NUMBER=28 RUBYOPT=-I/usr/local/bundle/bundler/gems/turbo_tests-3148ae6c3482/lib -r/usr/local/bundle/gems/bundler-2.5.10/lib/bundler/setup -W0 RSPEC_SILENCE_FILTER_ANNOUNCEMENTS=1 /usr/local/bundle/gems/bundler-2.5.10/exe/bundle exec rspec --seed 52674 --format TurboTests::JsonRowsFormatter --out tmp/test-pipes/subprocess-28 --format ParallelTests::RSpec::RuntimeLogger --out spec/support/turbo_runtime_features.log spec/features/api_docs/index_spec.rb spec/features/custom_fields/reorder_options_spec.rb spec/features/projects/projects_portfolio_spec.rb spec/features/projects/template_spec.rb spec/features/versions/edit_spec.rb spec/features/work_packages/details/markdown/description_editor_spec.rb spec/features/work_packages/table/hierarchy/hierarchy_parent_below_spec.rb spec/features/work_packages/table/inline_create/inline_create_refresh_spec.rb spec/features/work_packages/table/invalid_query_spec.rb spec/features/work_packages/tabs/activity_revisions_spec.rb +# Process 28: TEST_ENV_NUMBER=28 RUBYOPT=-I/usr/local/bundle/bundler/gems/turbo_tests-3148ae6c3482/lib -r/usr/local/bundle/gems/bundler-2.5.11/lib/bundler/setup -W0 RSPEC_SILENCE_FILTER_ANNOUNCEMENTS=1 /usr/local/bundle/gems/bundler-2.5.11/exe/bundle exec rspec --seed 52674 --format TurboTests::JsonRowsFormatter --out tmp/test-pipes/subprocess-28 --format ParallelTests::RSpec::RuntimeLogger --out spec/support/turbo_runtime_features.log spec/features/api_docs/index_spec.rb spec/features/custom_fields/reorder_options_spec.rb spec/features/projects/projects_portfolio_spec.rb spec/features/projects/template_spec.rb spec/features/versions/edit_spec.rb spec/features/work_packages/details/markdown/description_editor_spec.rb spec/features/work_packages/table/hierarchy/hierarchy_parent_below_spec.rb spec/features/work_packages/table/inline_create/inline_create_refresh_spec.rb spec/features/work_packages/table/invalid_query_spec.rb spec/features/work_packages/tabs/activity_revisions_spec.rb # rubocop:enable Layout/LineLength class TestsGroup attr_accessor :test_env_number, :seed, :files @@ -453,9 +455,11 @@ class Formatter report.head_branch = workflow_run["head_branch"] report.head_sha = workflow_run["head_sha"] report.commit_message = commit_message(workflow_run) + report.run_started_at = Time.parse(workflow_run["run_started_at"]).utc warn " Branch: #{report.head_branch.bold}" warn " Commit SHA: #{report.head_sha.bold}" warn " Commit message: #{report.commit_message.bold}" + warn " Run started at: #{report.run_started_at.localtime.to_s.bold}" display_pull_request_info(workflow_run) end diff --git a/spec/contracts/settings/working_days_and_hours_params_contract_spec.rb b/spec/contracts/settings/working_days_and_hours_params_contract_spec.rb new file mode 100644 index 000000000000..37fe7cdd54e9 --- /dev/null +++ b/spec/contracts/settings/working_days_and_hours_params_contract_spec.rb @@ -0,0 +1,120 @@ +#-- 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" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe Settings::WorkingDaysAndHoursParamsContract do + include_context "ModelContract shared context" + shared_let(:current_user) { create(:admin) } + let(:setting) { Setting } + let(:params) { { working_days: [1], hours_per_day: 8, days_per_week: 5, days_per_month: 20 } } + let(:contract) do + described_class.new(setting, current_user, params:) + end + + it_behaves_like "contract is valid for active admins and invalid for regular users" + + %i[working_days hours_per_day days_per_week days_per_month].each do |attribute| + context "without #{attribute}" do + let(:params) { { working_days: [1], hours_per_day: 8, days_per_week: 5, days_per_month: 20 }.except(attribute) } + + include_examples "contract is invalid", base: :"#{attribute}_are_missing" + end + end + + context "with an ApplyWorkingDaysChangeJob already existing", + with_good_job: WorkPackages::ApplyWorkingDaysChangeJob do + let(:params) { { working_days: [1, 2, 3], hours_per_day: 8, days_per_week: 5, days_per_month: 20 } } + + before do + WorkPackages::ApplyWorkingDaysChangeJob + .set(wait: 10.minutes) # GoodJob executes inline job without wait immediately + .perform_later(user_id: current_user.id, + previous_non_working_days: [], + previous_working_days: [1, 2, 3, 4]) + end + + include_examples "contract is invalid", base: :previous_working_day_changes_unprocessed + end + + context "when days_per_week and days_per_month aren't consistent with each other" do + # There are 4 weeks per month on average, so 10 days per month in non-sensical given 5 days per week + let(:params) { { working_days: [1], hours_per_day: 8, days_per_week: 5, days_per_month: 10 } } + + include_examples "contract is invalid", base: :days_per_week_and_days_per_month_are_inconsistent + end + + describe "0 durations" do + context "when hours_per_day is 0" do + let(:params) { { working_days: [1], hours_per_day: 0, days_per_week: 5, days_per_month: 20 } } + + include_examples "contract is invalid", base: :durations_are_not_positive_numbers + end + + # These two are correlated. Making only one of them 0 will also + # add the "incosistent" error tested for above. + context "when days_per_week or days_per_month is 0" do + let(:params) { { working_days: [1], hours_per_day: 8, days_per_week: 0, days_per_month: 0 } } + + include_examples "contract is invalid", base: :durations_are_not_positive_numbers + end + + context "when all durations are 0" do + let(:params) { { working_days: [1], hours_per_day: 0, days_per_week: 0, days_per_month: 0 } } + + include_examples "contract is invalid", base: :durations_are_not_positive_numbers + end + end + + describe "Text durations" do + let(:params) { { working_days: [1], hours_per_day: "blah", days_per_week: "5", days_per_month: "20" } } + + include_examples "contract is invalid", base: :durations_are_not_positive_numbers + end + + describe "Negative durations" do + let(:params) { { working_days: [1], hours_per_day: -2, days_per_week: -5, days_per_month: -20 } } + + include_examples "contract is invalid", base: :durations_are_not_positive_numbers + end + + describe "Out-of-bounds durations" do + context "when hours_per_day is greater than 24" do + let(:params) { { working_days: [1], hours_per_day: 25, days_per_week: 5, days_per_month: 20 } } + + include_examples "contract is invalid", base: :hours_per_day_is_out_of_bounds + end + + context "when days_per_week is greater than 7 and days_per_month is greater than 31" do + let(:params) { { working_days: [1], hours_per_day: 8, days_per_week: 8, days_per_month: 32 } } + + include_examples "contract is invalid", base: %i[days_per_week_is_out_of_bounds days_per_month_is_out_of_bounds] + end + end +end diff --git a/spec/controllers/admin/settings/work_packages_settings_controller_spec.rb b/spec/controllers/admin/settings/work_packages_settings_controller_spec.rb index 93d9a06c2824..0f162cd8b9f9 100644 --- a/spec/controllers/admin/settings/work_packages_settings_controller_spec.rb +++ b/spec/controllers/admin/settings/work_packages_settings_controller_spec.rb @@ -54,7 +54,7 @@ } } expect(Setting.work_package_done_ratio).to eq("status") - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .to have_been_enqueued.with(cause_type: "progress_mode_changed_to_status_based") perform_enqueued_jobs @@ -76,7 +76,7 @@ work_package_done_ratio: "status" } } - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .not_to have_been_enqueued end end @@ -93,7 +93,7 @@ work_package_done_ratio: "field" } } - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .not_to have_been_enqueued end end diff --git a/spec/controllers/admin/settings/working_days_settings_controller_spec.rb b/spec/controllers/admin/settings/working_days_and_hours_settings_controller_spec.rb similarity index 86% rename from spec/controllers/admin/settings/working_days_settings_controller_spec.rb rename to spec/controllers/admin/settings/working_days_and_hours_settings_controller_spec.rb index 8063cf1fd809..38e753137e99 100644 --- a/spec/controllers/admin/settings/working_days_settings_controller_spec.rb +++ b/spec/controllers/admin/settings/working_days_and_hours_settings_controller_spec.rb @@ -28,18 +28,21 @@ require "spec_helper" -RSpec.describe Admin::Settings::WorkingDaysSettingsController do +RSpec.describe Admin::Settings::WorkingDaysAndHoursSettingsController do shared_let(:user) { create(:admin) } current_user { user } - require_admin_and_render_template("working_days_settings") + require_admin_and_render_template("working_days_and_hours_settings") describe "update" do let(:working_days) { [*"1".."7"] } let(:non_working_days_attributes) { {} } + let(:hours_per_day) { 4 } + let(:days_per_week) { 5 } + let(:days_per_month) { 20 } let(:params) do - { settings: { working_days:, non_working_days_attributes: } } + { settings: { working_days:, non_working_days_attributes:, hours_per_day:, days_per_week:, days_per_month: } } end subject { patch "update", params: } @@ -91,7 +94,8 @@ expect(assigns(:modified_non_working_days)).to contain_exactly( hash_including("name" => "Christmas Eve", "date" => "2022-12-24"), hash_including("name" => "Christmas Eve2", "date" => "2022-12-24"), - hash_including(nwd_to_delete.as_json(only: %i[id name date]).merge("_destroy" => true)) + hash_including(nwd_to_delete.as_json(only: %i[id name + date]).merge("_destroy" => true)) ) end diff --git a/spec/controllers/statuses_controller_spec.rb b/spec/controllers/statuses_controller_spec.rb index da8e941e2c8e..a4058d23a443 100644 --- a/spec/controllers/statuses_controller_spec.rb +++ b/spec/controllers/statuses_controller_spec.rb @@ -143,9 +143,48 @@ it "does not start any jobs to update work packages % complete values" do expect(status.reload).to have_attributes(default_done_ratio: new_default_done_ratio) - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .not_to have_been_enqueued end + + context "when also marking a status as excluded from totals calculations" do + before_all do + status.update_columns(name: "Rejected", + default_done_ratio: 70) + end + + shared_let(:status_new) { create(:status, name: "New", default_done_ratio: "0") } + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | New | | | | 20h | 15h | 25% + child | Rejected | 10h | 5h | 50% | | | + other child | New | 10h | 10h | 0% | | | + TABLE + + let(:status_params) do + { default_done_ratio: new_default_done_ratio, + excluded_from_totals: true } + end + + it "starts a job to update totals of work packages having excluded children" do + expect(status.reload).to have_attributes(excluded_from_totals: true) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) + .to have_been_enqueued.with(cause_type: "status_changed", + status_name: status.name, + status_id: status.id, + changes: { "excluded_from_totals" => [false, true] }) + + perform_enqueued_jobs + + expect_work_packages([parent, child, other_child], <<~TABLE) + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | New | | | | 10h | 10h | 0% + child | Rejected | 10h | 5h | 50% | | | + other child | New | 10h | 10h | 0% | | | + TABLE + expect(parent.last_journal.details["cause"].last).to include("type" => "status_changed") + end + end end context "when in status-based mode", @@ -158,16 +197,16 @@ it "starts a job to update work packages % complete values" do old_default_done_ratio = status.default_done_ratio expect(status.reload).to have_attributes(default_done_ratio: new_default_done_ratio) - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) - .to have_been_enqueued.with(cause_type: "status_p_complete_changed", + expect(WorkPackages::Progress::ApplyStatusesChangeJob) + .to have_been_enqueued.with(cause_type: "status_changed", status_name: status.name, status_id: status.id, - change: [old_default_done_ratio, new_default_done_ratio]) + changes: { "default_done_ratio" => [old_default_done_ratio, new_default_done_ratio] }) perform_enqueued_jobs expect(work_package.reload.read_attribute(:done_ratio)).to eq(new_default_done_ratio) - expect(work_package.last_journal.details["cause"].last).to include("type" => "status_p_complete_changed") + expect(work_package.last_journal.details["cause"].last).to include("type" => "status_changed") end end @@ -175,16 +214,84 @@ let(:status_params) { { default_done_ratio: status.default_done_ratio } } it "does not start any jobs" do - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .not_to have_been_enqueued end end - context "when changing something else than the default % complete" do + context "when marking a status as excluded from totals calculations" do + before_all do + status.update_columns(name: "Rejected", + default_done_ratio: 70) + end + + shared_let(:status_new) { create(:status, name: "New", default_done_ratio: "0") } + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | New | | | 0% | 20h | 16h | 20% + child | Rejected | 10h | 3h | 70% | | | + other child | New | 10h | 10h | 0% | | | + TABLE + + let(:status_params) { { excluded_from_totals: true } } + + it "starts a job to update totals of work packages having excluded children" do + expect(status.reload).to have_attributes(excluded_from_totals: true) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) + .to have_been_enqueued.with(cause_type: "status_changed", + status_name: status.name, + status_id: status.id, + changes: { "excluded_from_totals" => [false, true] }) + + perform_enqueued_jobs + + expect_work_packages([parent, child, other_child], <<~TABLE) + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | New | | | 0% | 10h | 10h | 0% + child | Rejected | 10h | 3h | 70% | | | + other child | New | 10h | 10h | 0% | | | + TABLE + expect(parent.last_journal.details["cause"].last).to include("type" => "status_changed") + end + + context "when also changing the default % complete of the status" do + let(:new_default_done_ratio) { 40 } + let(:status_params) { { excluded_from_totals: true, default_done_ratio: new_default_done_ratio } } + + it "starts a job to update both total values and % complete of work packages" do + old_default_done_ratio = status.default_done_ratio + expect(status.reload).to have_attributes(default_done_ratio: new_default_done_ratio, + excluded_from_totals: true) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) + .to have_been_enqueued.with(cause_type: "status_changed", + status_name: status.name, + status_id: status.id, + changes: { "default_done_ratio" => [old_default_done_ratio, new_default_done_ratio], + "excluded_from_totals" => [false, true] }) + + perform_enqueued_jobs + + expect_work_packages([parent, child, other_child], <<~TABLE) + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | New | | | 0% | 10h | 10h | 0% + child | Rejected | 10h | 6h | 40% | | | + other child | New | 10h | 10h | 0% | | | + TABLE + + [parent, child].each do |work_package| + expect(work_package.journals.count).to eq(2) + expect(work_package.last_journal.details["cause"].last).to include("type" => "status_changed") + end + expect(other_child.journals.count).to eq(1) # this one should not have changed + end + end + end + + context "when changing something else than default % complete or exclude from totals" do let(:status_params) { { name: "Another status name" } } it "does not start any jobs" do - expect(WorkPackages::Progress::ApplyStatusesPCompleteJob) + expect(WorkPackages::Progress::ApplyStatusesChangeJob) .not_to have_been_enqueued end end diff --git a/spec/factories/status_factory.rb b/spec/factories/status_factory.rb index ddaba8fc5872..586a9082c566 100644 --- a/spec/factories/status_factory.rb +++ b/spec/factories/status_factory.rb @@ -31,11 +31,21 @@ sequence(:name) { |n| "status #{n}" } is_closed { false } is_readonly { false } + excluded_from_totals { false } + + trait :excluded_from_totals do + excluded_from_totals { true } + end factory :closed_status do is_closed { true } end + factory :rejected_status do + excluded_from_totals + name { "Rejected" } + end + factory :default_status do is_default { true } end diff --git a/spec/features/activities/work_package_activity_spec.rb b/spec/features/activities/work_package_activity_spec.rb index 7604f302926e..3eab179c7920 100644 --- a/spec/features/activities/work_package_activity_spec.rb +++ b/spec/features/activities/work_package_activity_spec.rb @@ -58,13 +58,13 @@ wp_page.expect_and_dismiss_toaster(message: "Successful update.") end - it "displays changed attributes in the activity tab" do + it "displays changed attributes in the activity tab", :aggregate_failures do within("activity-entry", text: admin.name) do expect(page).to have_list_item(text: "% Complete set to 50%") - expect(page).to have_list_item(text: "Work set to 10.00") - expect(page).to have_list_item(text: "Remaining work set to 5.00") - expect(page).to have_list_item(text: "Total work set to 20.00") - expect(page).to have_list_item(text: "Total remaining work set to 8.00") + expect(page).to have_list_item(text: "Work set to 1d 2h") + expect(page).to have_list_item(text: "Remaining work set to 5h") + expect(page).to have_list_item(text: "Total work set to 2d 4h") + expect(page).to have_list_item(text: "Total remaining work set to 1d") expect(page).to have_list_item(text: "Total % complete set to 60%") end end diff --git a/spec/features/admin/project_custom_fields/project_mappings_spec.rb b/spec/features/admin/project_custom_fields/project_mappings_spec.rb index 2491b114c14a..93f226dd3f5f 100644 --- a/spec/features/admin/project_custom_fields/project_mappings_spec.rb +++ b/spec/features/admin/project_custom_fields/project_mappings_spec.rb @@ -82,6 +82,24 @@ end end + it "allows to link a project" do + project = create(:project) + subproject = create(:project, parent: project) + click_on "Add projects" + + within_test_selector("settings--new-project-custom-field-mapping-component") do + autocompleter = page.find(".op-project-autocompleter") + autocompleter.fill_in with: project.name + find(".ng-option-label", text: project.name).click + check "Include sub-projects" + + click_on "Add" + end + + expect(page).to have_text(project.name) + expect(page).to have_text(subproject.name) + end + context "and the project custom field is required" do shared_let(:project_custom_field) { create(:project_custom_field, is_required: true) } diff --git a/spec/features/admin/working_days_spec.rb b/spec/features/admin/working_days_spec.rb index 23c605c5f76e..516f5900b1f0 100644 --- a/spec/features/admin/working_days_spec.rb +++ b/spec/features/admin/working_days_spec.rb @@ -47,7 +47,7 @@ current_user { admin } before do - visit admin_settings_working_days_path + visit admin_settings_working_days_and_hours_path end describe "week days" do @@ -284,7 +284,7 @@ def working_days_setting # rubocop:disable RSpec/AnyInstance allow_any_instance_of(NonWorkingDay) .to receive(:errors) - .and_return(errors) + .and_return(errors) # rubocop:enable RSpec/AnyInstance delete_button = page.first(".op-non-working-days-list--delete-icon .icon-delete", visible: :all) diff --git a/spec/features/notifications/reminder_mail_spec.rb b/spec/features/notifications/reminder_mail_spec.rb index d2e8dbde9f04..aa7eb9f0aa8e 100644 --- a/spec/features/notifications/reminder_mail_spec.rb +++ b/spec/features/notifications/reminder_mail_spec.rb @@ -2,13 +2,13 @@ require_relative "../users/notifications/shared_examples" RSpec.describe "Reminder email sending", js: false do - let!(:project) { create(:project, members: { current_user => role }) } - let!(:mute_project) { create(:project, members: { current_user => role }) } + let!(:project) { create(:project, members: { receiving_user => role }) } + let!(:mute_project) { create(:project, members: { receiving_user => role }) } let(:role) { create(:project_role, permissions: %i[view_work_packages]) } let(:other_user) { create(:user) } let(:work_package) { create(:work_package, project:) } - let(:watched_work_package) { create(:work_package, project:, watcher_users: [current_user]) } - let(:involved_work_package) { create(:work_package, project:, assigned_to: current_user) } + let(:watched_work_package) { create(:work_package, project:, watcher_users: [receiving_user]) } + let(:involved_work_package) { create(:work_package, project:, assigned_to: receiving_user) } # GoodJob::Job#cron_at is used for scheduling the reminder mails. # It needs to be within a time frame eligible for sending out mails for the chosen # time zone. For the time zone Hawaii (UTC-10) this means 8:00:00 as the job has a cron tab to be run every 15 min. @@ -27,13 +27,7 @@ # between the changes to a work package and the reminder mail being sent out. let(:work_package_update_time) { ActiveSupport::TimeZone["Pacific/Honolulu"].parse("2021-09-30T01:50:34").utc } - around do |example| - Timecop.travel(work_package_update_time) do - example.run - end - end - - current_user do + let!(:receiving_user) do create( :user, preferences: { @@ -61,25 +55,29 @@ ) end - before do - watched_work_package - work_package - involved_work_package + around do |example| + Timecop.travel(work_package_update_time) do + example.run + end + end + before do ActiveJob::Base.disable_test_adapter - - scheduled_job end it "sends a digest mail based on the configuration", with_settings: { journal_aggregation_time_minutes: 0 } do # Perform some actions the user listens to User.execute_as other_user do + watched_work_package + work_package + involved_work_package + note = <<~NOTE Hey - @#{current_user.name} + data-text="@#{receiving_user.name}"> + @#{receiving_user.name} NOTE @@ -93,10 +91,15 @@ involved_work_package.save! end - 2.times { GoodJob.perform_inline } + GoodJob.perform_inline + scheduled_job + GoodJob.perform_inline expect(ActionMailer::Base.deliveries.length).to be 1 + # 3 work package created + # 3 times updated (1 for each work package) + # One of those was a mention for which the user opted out to not receive immediate notifications expect(ActionMailer::Base.deliveries.first.subject) - .to eql "OpenProject - 1 unread notification including a mention" + .to eql "OpenProject - 6 unread notifications including a mention" end end diff --git a/spec/features/types/form_configuration_spec.rb b/spec/features/types/form_configuration_spec.rb index 2b8c93a0a32c..fd4751c28241 100644 --- a/spec/features/types/form_configuration_spec.rb +++ b/spec/features/types/form_configuration_spec.rb @@ -227,7 +227,7 @@ wp_page.expect_group("Estimates and progress") do wp_page.expect_attributes estimated_time: "-" - wp_page.expect_attributes spent_time: "0 h" + wp_page.expect_attributes spent_time: "0h" end # New work package has the same configuration diff --git a/spec/features/work_packages/display_fields/spent_time_display_spec.rb b/spec/features/work_packages/display_fields/spent_time_display_spec.rb index ed7da8592b33..e5db5620d112 100644 --- a/spec/features/work_packages/display_fields/spent_time_display_spec.rb +++ b/spec/features/work_packages/display_fields/spent_time_display_spec.rb @@ -85,7 +85,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z end.to change(TimeEntry, :count).by(1) # the value is updated automatically - spent_time_field.expect_display_value "1 h" + spent_time_field.expect_display_value "1h" TimeEntry.last.tap do |te| expect(te.work_package).to eq(work_package) @@ -112,7 +112,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z log_time_via_modal log_for_user: other_user # the value is updated automatically - spent_time_field.expect_display_value "1 h" + spent_time_field.expect_display_value "1h" time_entry = TimeEntry.last expect(time_entry.user).to eq other_user @@ -128,7 +128,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z log_time_via_modal # the value is updated automatically - spent_time_field.expect_display_value "1 h" + spent_time_field.expect_display_value "1h" end context "with a user with non-one unit numbers", with_settings: { available_languages: %w[en ja] } do @@ -145,7 +145,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z log_time_via_modal # the value is updated automatically - spent_time_field.expect_display_value "1 h" + spent_time_field.expect_display_value "1h" end end end @@ -164,7 +164,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z it "shows no logging button within the display field" do spent_time_field.time_log_icon_visible false - spent_time_field.expect_display_value "0 h" + spent_time_field.expect_display_value "0h" end end @@ -188,7 +188,7 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z log_time_via_modal user_field_visible: false # the value is updated automatically - spent_time_field.expect_display_value "1 h" + spent_time_field.expect_display_value "1h" end end @@ -213,8 +213,8 @@ def log_time_via_modal(user_field_visible: true, log_for_user: nil, date: Time.z log_time_via_modal - expect(page).to have_css("tr:nth-of-type(1) .wp-table--cell-td.spentTime", text: "1 h") - expect(page).to have_css("tr:nth-of-type(2) .wp-table--cell-td.spentTime", text: "0 h") + expect(page).to have_css("tr:nth-of-type(1) .wp-table--cell-td.spentTime", text: "1h") + expect(page).to have_css("tr:nth-of-type(2) .wp-table--cell-td.spentTime", text: "0h") end end end diff --git a/spec/features/work_packages/display_fields/work_display_spec.rb b/spec/features/work_packages/display_fields/work_display_spec.rb index 2770e732fb37..ddfee0c921b0 100644 --- a/spec/features/work_packages/display_fields/work_display_spec.rb +++ b/spec/features/work_packages/display_fields/work_display_spec.rb @@ -91,7 +91,7 @@ child | 3h | TABLE - include_examples "work display", expected_text: "1 h·Σ 4 h" + include_examples "work display", expected_text: "1h·Σ 4h" end context "with just work" do @@ -101,7 +101,7 @@ child | 0h | TABLE - include_examples "work display", expected_text: "1 h" + include_examples "work display", expected_text: "1h" end context "with just total work with (parent work 0 h)" do @@ -111,7 +111,7 @@ child | 3h | TABLE - include_examples "work display", expected_text: "0 h·Σ 3 h" + include_examples "work display", expected_text: "0h·Σ 3h" end context "with just total work (parent work unset)" do @@ -121,7 +121,7 @@ child | 3h | TABLE - include_examples "work display", expected_text: "-·Σ 3 h" + include_examples "work display", expected_text: "-·Σ 3h" end context "with neither work nor total work (both 0 h)" do @@ -131,7 +131,7 @@ child | 0h | TABLE - include_examples "work display", expected_text: "0 h" + include_examples "work display", expected_text: "0h" end context "with just total work being 0h" do @@ -141,7 +141,7 @@ child | 0h | TABLE - include_examples "work display", expected_text: "-·Σ 0 h" + include_examples "work display", expected_text: "-·Σ 0h" end context "with neither work nor total work (both unset)" do @@ -172,11 +172,11 @@ wp_table.visit_query query # parent - expect(page).to have_content("5 h·Σ 20 h") - expect(page).to have_link("Σ 20 h") + expect(page).to have_content("5h·Σ 2d 4h") + expect(page).to have_link("Σ 2d 4h") # child 2 - expect(page).to have_content("3 h·Σ 15 h") - expect(page).to have_link("Σ 15 h") + expect(page).to have_content("3h·Σ 1d 7h") + expect(page).to have_link("Σ 1d 7h") end context "when clicking the link of a top parent" do @@ -185,7 +185,7 @@ end it "shows a work package table with a parent filter to list the direct children" do - click_on("Σ 20 h") + click_on("Σ 2d 4h") wp_table.expect_work_package_count(4) wp_table.expect_work_package_listed(parent, child1, child2, child3) @@ -202,8 +202,8 @@ end it "shows also all ancestors in the work package table" do - expect(page).to have_content("Work\n3 h·Σ 15 h") - click_on("Σ 15 h") + expect(page).to have_content("Work\n3h·Σ 1d 7h") + click_on("Σ 1d 7h") wp_table.expect_work_package_count(3) wp_table.expect_work_package_listed(parent, child2, grand_child21) diff --git a/spec/features/work_packages/edit_work_package_spec.rb b/spec/features/work_packages/edit_work_package_spec.rb index 8e38c7247872..07d5d8bf2e47 100644 --- a/spec/features/work_packages/edit_work_package_spec.rb +++ b/spec/features/work_packages/edit_work_package_spec.rb @@ -143,8 +143,8 @@ def visit! responsible: manager.name, assignee: manager.name, combinedDate: "03/04/2013 - 03/20/2013", - estimatedTime: "10 h", - remainingTime: "7 h", + estimatedTime: "1d 2h", + remainingTime: "7h", percentageDone: "30%", subject: "a new subject", description: "a new description", diff --git a/spec/features/work_packages/index_sums_spec.rb b/spec/features/work_packages/index_sums_spec.rb index 5c894d85da34..896ea730cb85 100644 --- a/spec/features/work_packages/index_sums_spec.rb +++ b/spec/features/work_packages/index_sums_spec.rb @@ -144,14 +144,16 @@ wp_table.expect_work_package_listed work_package1, work_package2 # Expect the total sums row - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "25 h") - expect(row).to have_css(".remainingTime", text: "12.5 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") - expect(row).to have_css(".laborCosts", text: "15.00 EUR") - expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "3d 1h") + expect(row).to have_css(".remainingTime", text: "1d 4h 30m") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") + expect(row).to have_css(".laborCosts", text: "15.00 EUR") + expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(row).to have_css(".overallCosts", text: "22.50 EUR") + end end # Update the sum @@ -160,14 +162,16 @@ wp_table.edit_field(work_package1, :remainingTime) .update "12" - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "35 h") - expect(row).to have_css(".remainingTime", text: "19.5 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") - expect(row).to have_css(".laborCosts", text: "15.00 EUR") - expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "4d 3h") + expect(row).to have_css(".remainingTime", text: "2d 3h 30m") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") + expect(row).to have_css(".laborCosts", text: "15.00 EUR") + expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(row).to have_css(".overallCosts", text: "22.50 EUR") + end end # Enable groups @@ -179,32 +183,38 @@ first_sum_row, second_sum_row = *find_all(:row, "Sum") # First status row - expect(first_sum_row).to have_css(".estimatedTime", text: "20 h") - expect(first_sum_row).to have_css(".remainingTime", text: "12 h") - expect(first_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "5") - expect(first_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "5.5") - expect(first_sum_row).to have_css(".laborCosts", text: "15.00 EUR") - expect(first_sum_row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(first_sum_row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + expect(first_sum_row).to have_css(".estimatedTime", text: "2d 4h") + expect(first_sum_row).to have_css(".remainingTime", text: "1d 4h") + expect(first_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "5") + expect(first_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "5.5") + expect(first_sum_row).to have_css(".laborCosts", text: "15.00 EUR") + expect(first_sum_row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(first_sum_row).to have_css(".overallCosts", text: "22.50 EUR") + end # Second status row - expect(second_sum_row).to have_css(".estimatedTime", text: "15 h") - expect(second_sum_row).to have_css(".remainingTime", text: "7.5 h") - expect(second_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "7") - expect(second_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "7.7") - expect(second_sum_row).to have_css(".laborCosts", text: "", exact_text: true) - expect(second_sum_row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs - expect(second_sum_row).to have_css(".overallCosts", text: "", exact_text: true) + aggregate_failures do + expect(second_sum_row).to have_css(".estimatedTime", text: "1d 7h") + expect(second_sum_row).to have_css(".remainingTime", text: "7h 30m") + expect(second_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "7") + expect(second_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "7.7") + expect(second_sum_row).to have_css(".laborCosts", text: "", exact_text: true) + expect(second_sum_row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs + expect(second_sum_row).to have_css(".overallCosts", text: "", exact_text: true) + end # Total sums row is unchanged - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "35 h") - expect(row).to have_css(".remainingTime", text: "19.5 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") - expect(row).to have_css(".laborCosts", text: "15.00 EUR") - expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "4d 3h") + expect(row).to have_css(".remainingTime", text: "2d 3h 30m") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") + expect(row).to have_css(".laborCosts", text: "15.00 EUR") + expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(row).to have_css(".overallCosts", text: "22.50 EUR") + end end # Collapsing groups will also hide the sums row @@ -270,14 +280,16 @@ wp_table.expect_work_package_listed work_package1, work_package2, work_package3, work_package4 # Expect the total sums row without filtering - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "50 h") - expect(row).to have_css(".remainingTime", text: "25 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "24") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "26.4") - expect(row).to have_css(".laborCosts", text: "40.00 EUR") - expect(row).to have_css(".materialCosts", text: "18.00 EUR") # Unit costs - expect(row).to have_css(".overallCosts", text: "58.00 EUR") + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "1w 1d 2h") + expect(row).to have_css(".remainingTime", text: "3d 1h") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "24") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "26.4") + expect(row).to have_css(".laborCosts", text: "40.00 EUR") + expect(row).to have_css(".materialCosts", text: "18.00 EUR") # Unit costs + expect(row).to have_css(".overallCosts", text: "58.00 EUR") + end end # Filter @@ -289,14 +301,16 @@ expect(page).to have_row("WorkPackage", count: 2) # works because the subject name includes "WorkPackage" # Expect the total sums row to have changed - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "30 h") - expect(row).to have_css(".remainingTime", text: "15 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "14") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "15.4") - expect(row).to have_css(".laborCosts", text: "", exact_text: true) - expect(row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs - expect(row).to have_css(".overallCosts", text: "", exact_text: true) + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "3d 6h") + expect(row).to have_css(".remainingTime", text: "1d 7h") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "14") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "15.4") + expect(row).to have_css(".laborCosts", text: "", exact_text: true) + expect(row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs + expect(row).to have_css(".overallCosts", text: "", exact_text: true) + end end # Filter by status open @@ -313,32 +327,38 @@ first_sum_row, second_sum_row = *find_all(:row, "Sum") # First status row - expect(first_sum_row).to have_css(".estimatedTime", text: "10 h") - expect(first_sum_row).to have_css(".remainingTime", text: "5 h") - expect(first_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "5") - expect(first_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "5.5") - expect(first_sum_row).to have_css(".laborCosts", text: "15.00 EUR") - expect(first_sum_row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(first_sum_row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + expect(first_sum_row).to have_css(".estimatedTime", text: "1d 2h") + expect(first_sum_row).to have_css(".remainingTime", text: "5h") + expect(first_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "5") + expect(first_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "5.5") + expect(first_sum_row).to have_css(".laborCosts", text: "15.00 EUR") + expect(first_sum_row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(first_sum_row).to have_css(".overallCosts", text: "22.50 EUR") + end # Second status row - expect(second_sum_row).to have_css(".estimatedTime", text: "15 h") - expect(second_sum_row).to have_css(".remainingTime", text: "7.5 h") - expect(second_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "7") - expect(second_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "7.7") - expect(second_sum_row).to have_css(".laborCosts", text: "", exact_text: true) - expect(second_sum_row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs - expect(second_sum_row).to have_css(".overallCosts", text: "", exact_text: true) + aggregate_failures do + expect(second_sum_row).to have_css(".estimatedTime", text: "1d 7h") + expect(second_sum_row).to have_css(".remainingTime", text: "7h 30m") + expect(second_sum_row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "7") + expect(second_sum_row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "7.7") + expect(second_sum_row).to have_css(".laborCosts", text: "", exact_text: true) + expect(second_sum_row).to have_css(".materialCosts", text: "", exact_text: true) # Unit costs + expect(second_sum_row).to have_css(".overallCosts", text: "", exact_text: true) + end # Total sum - within(:row, "Total sum") do |row| - expect(row).to have_css(".estimatedTime", text: "25 h") - expect(row).to have_css(".remainingTime", text: "12.5 h") - expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") - expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") - expect(row).to have_css(".laborCosts", text: "15.00 EUR") - expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs - expect(row).to have_css(".overallCosts", text: "22.50 EUR") + aggregate_failures do + within(:row, "Total sum") do |row| + expect(row).to have_css(".estimatedTime", text: "3d 1h") + expect(row).to have_css(".remainingTime", text: "1d 4h 30m") + expect(row).to have_css(".#{int_cf.attribute_name(:camel_case)}", text: "12") + expect(row).to have_css(".#{float_cf.attribute_name(:camel_case)}", text: "13.2") + expect(row).to have_css(".laborCosts", text: "15.00 EUR") + expect(row).to have_css(".materialCosts", text: "7.50 EUR") # Unit costs + expect(row).to have_css(".overallCosts", text: "22.50 EUR") + end end end end diff --git a/spec/features/work_packages/progress_modal_spec.rb b/spec/features/work_packages/progress_modal_spec.rb index fee8996468c3..f1fb3f7ce36f 100644 --- a/spec/features/work_packages/progress_modal_spec.rb +++ b/spec/features/work_packages/progress_modal_spec.rb @@ -340,8 +340,7 @@ def update_work_package_with(work_package, attributes) context "with all values set" do before { update_work_package_with(work_package, estimated_hours: 10.0, remaining_hours: 2.12345) } - it "populates fields with correctly values formatted " \ - "with the minimum fractional part if present, and 2 decimals max" do + it "populates fields with correctly values formatted" do work_package_table.visit_query(progress_query) work_package_table.expect_work_package_listed(work_package) @@ -351,8 +350,8 @@ def update_work_package_with(work_package, attributes) work_edit_field.activate! - work_edit_field.expect_modal_field_value("10") - remaining_work_edit_field.expect_modal_field_value("2.12") + work_edit_field.expect_modal_field_value("1d 2h") + remaining_work_edit_field.expect_modal_field_value("2h 7m") percent_complete_edit_field.expect_modal_field_value("78", readonly: true) end end @@ -365,7 +364,7 @@ def update_work_package_with(work_package, attributes) work_package.save(validate: false) end - it "does not lose precision due to conversion from ISO duration to hours" do + it "does not lose precision due to conversion from ISO duration to hours (rounded to closest minute)" do work_package_table.visit_query(progress_query) work_package_table.expect_work_package_listed(work_package) @@ -382,10 +381,11 @@ def update_work_package_with(work_package, attributes) expect(work_package.estimated_hours).to eq(2.56) expect(work_package.remaining_hours).to eq(0.28) - # work should be displayed as 2.56, and remaining work as 0.28 + # work should be displayed as "2h 34m" ("2h 33m 36s" rounded to minutes), + # and remaining work as "17m" ("16m 48s" rounded to minutes) work_edit_field.activate! - work_edit_field.expect_modal_field_value("2.56") - remaining_work_edit_field.expect_modal_field_value("0.28") + work_edit_field.expect_modal_field_value("2h 34m") + remaining_work_edit_field.expect_modal_field_value("17m") end end @@ -522,7 +522,7 @@ def update_work_package_with(work_package, attributes) specify "Case 2: when work is set to 12h, " \ "remaining work is automatically set to 6h " \ "and subsequently work is set to 14h, " \ - "remaining work updates to 8h" do + "remaining work updates to 1d" do work_package_table.visit_query(progress_query) work_package_table.expect_work_package_listed(work_package) @@ -534,11 +534,11 @@ def update_work_package_with(work_package, attributes) work_edit_field.set_value("12") page.driver.wait_for_network_idle # Wait for live-update to finish - remaining_work_edit_field.expect_modal_field_value("6") + remaining_work_edit_field.expect_modal_field_value("6h") work_edit_field.set_value("14") page.driver.wait_for_network_idle # Wait for live-update to finish - remaining_work_edit_field.expect_modal_field_value("8") + remaining_work_edit_field.expect_modal_field_value("1d") end specify "Case 3: when work is set to 2h, " \ @@ -556,11 +556,11 @@ def update_work_package_with(work_package, attributes) work_edit_field.set_value("2") page.driver.wait_for_network_idle # Wait for live-update to finish - remaining_work_edit_field.expect_modal_field_value("0") + remaining_work_edit_field.expect_modal_field_value("0h") work_edit_field.set_value("12") page.driver.wait_for_network_idle # Wait for live-update to finish - remaining_work_edit_field.expect_modal_field_value("6") + remaining_work_edit_field.expect_modal_field_value("6h") end end end diff --git a/spec/features/work_packages/remaining_time_spec.rb b/spec/features/work_packages/remaining_time_spec.rb index 7d253396f69b..599d999fec3f 100644 --- a/spec/features/work_packages/remaining_time_spec.rb +++ b/spec/features/work_packages/remaining_time_spec.rb @@ -50,7 +50,7 @@ # need to update work first to enable the remaining work field wp_page.update_attributes estimatedTime: "200" # rubocop:disable Rails/ActiveRecordAliases wp_page.update_attributes remainingTime: "125" # rubocop:disable Rails/ActiveRecordAliases - wp_page.expect_attributes remainingTime: "125 h" + wp_page.expect_attributes remainingTime: "3w 5h" work_package.reload expect(work_package.remaining_hours).to eq 125.0 @@ -70,8 +70,8 @@ # need to update work first to enable the remaining work field wp_table_page.update_work_package_attributes work_package, estimatedTime: "200" wp_table_page.update_work_package_attributes work_package, remainingTime: "125" - wp_table_page.expect_work_package_with_attributes work_package, remainingTime: "125 h" - wp_table_page.expect_sums_row_with_attributes remainingTime: "125 h" + wp_table_page.expect_work_package_with_attributes work_package, remainingTime: "3w 5h" + wp_table_page.expect_sums_row_with_attributes remainingTime: "3w 5h" work_package.reload expect(work_package.remaining_hours).to eq 125.0 diff --git a/spec/lib/api/v3/statuses/status_representer_spec.rb b/spec/lib/api/v3/statuses/status_representer_spec.rb index 799a822cc116..e704992c0710 100644 --- a/spec/lib/api/v3/statuses/status_representer_spec.rb +++ b/spec/lib/api/v3/statuses/status_representer_spec.rb @@ -30,9 +30,9 @@ RSpec.describe API::V3::Statuses::StatusRepresenter do let(:status) { build_stubbed(:status) } - let(:representer) { described_class.new(status, current_user: double("current_user")) } + let(:representer) { described_class.new(status, current_user: instance_double(User)) } - context "generation" do + describe "generation" do subject(:generated) { representer.to_json } it { is_expected.to include_json("Status".to_json).at_path("_type") } @@ -43,6 +43,7 @@ it { is_expected.to have_json_path("isClosed") } it { is_expected.to have_json_path("isDefault") } it { is_expected.to have_json_path("isReadonly") } + it { is_expected.to have_json_path("excludedFromTotals") } it { is_expected.to have_json_path("position") } it { is_expected.to have_json_path("defaultDoneRatio") } @@ -52,6 +53,7 @@ it { is_expected.to be_json_eql(status.is_closed.to_json).at_path("isClosed") } it { is_expected.to be_json_eql(status.is_default.to_json).at_path("isDefault") } it { is_expected.to be_json_eql(status.is_readonly.to_json).at_path("isReadonly") } + it { is_expected.to be_json_eql(status.excluded_from_totals.to_json).at_path("excludedFromTotals") } it { is_expected.to be_json_eql(status.position.to_json).at_path("position") } it { @@ -74,12 +76,15 @@ describe "caching" do it "is based on the representer's cache_key" do - expect(OpenProject::Cache) + allow(OpenProject::Cache) .to receive(:fetch) - .with(representer.json_cache_key) .and_call_original representer.to_json + + expect(OpenProject::Cache) + .to have_received(:fetch) + .with(representer.json_cache_key) end describe "#json_cache_key" do @@ -98,7 +103,7 @@ end it "changes when the status is updated" do - status.updated_at = Time.now + 20.seconds + status.updated_at = 20.seconds.from_now expect(representer.json_cache_key) .not_to eql former_cache_key diff --git a/spec/lib/journal_formatter/cause_spec.rb b/spec/lib/journal_formatter/cause_spec.rb index f85369703a05..1729d4e0b254 100644 --- a/spec/lib/journal_formatter/cause_spec.rb +++ b/spec/lib/journal_formatter/cause_spec.rb @@ -36,14 +36,15 @@ shared_let(:work_package) { create(:work_package) } let(:instance) { described_class.new(build(:work_package_journal)) } - let(:link) do + let(:work_package_html_link) do link_to_work_package(work_package, all_link: true) end + let(:work_package_raw_link) { "##{work_package.id}" } # we need to tell the url_helper that there is not controller to get url_options so that we can call link_to let(:controller) { nil } - subject do + def render(cause, html:) if Journal::VALID_CAUSE_TYPES.exclude?(cause["type"]) raise "#{cause['type'].inspect} is not a valid cause type from Journal::VALID_CAUSE_TYPES. " \ "Please use one of #{Journal::VALID_CAUSE_TYPES}" @@ -52,197 +53,211 @@ instance.render("cause", [nil, cause], html:) end + RSpec::Matchers.define :render_html_variant do |expected| + match do |cause| + @cause = cause + @actual = render(cause, html: true) + values_match? expected, @actual + end + + description do + "render HTML variant with #{surface_descriptions_in(expected).inspect}" + end + + failure_message do |actual| + "expected #{@cause} to render HTML variant as expected\n " \ + "expected: #{surface_descriptions_in(expected).inspect}\n " \ + "actual: #{actual.inspect}" + end + end + + RSpec::Matchers.define :render_raw_variant do |expected| + match do |cause| + @cause = cause + @actual = render(cause, html: false) + values_match? expected, @actual + end + + description do + "render raw text variant with #{surface_descriptions_in(expected).inspect}" + end + + failure_message do |actual| + "expected #{@cause} to render raw text variant as expected\n " \ + "expected: #{surface_descriptions_in(expected).inspect}\n " \ + "actual: #{actual.inspect}" + end + end + + shared_examples "XSS-proof rendering of status name" do + before do + cause["status_name"] = "" + end + + it "escapes the status name when rendering HTML" do + expect(cause).to render_html_variant(a_string_including("<script>alert('xss')</script>")) + end + + it "does not escape the status name when rendering raw text" do + expect(cause).to render_raw_variant(a_string_including("")) + end + end + context "when the change was caused by a change to the parent" do - let(:cause) do + subject(:cause) do { "type" => "work_package_parent_changed_times", "work_package_id" => work_package.id } end - context "when rendering HTML variant" do - let(:html) { true } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_parent_changed_times', link:)}" - end + context "when the user is able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + link = work_package_html_link + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_parent_changed_times', link:)}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + link = work_package_raw_link + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_parent_changed_times', link:)}" + ) end end - context "when rendering raw variant" do - let(:html) { false } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - let(:link) { "##{work_package.id}" } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_parent_changed_times', link:)}" - end + context "when the user is not able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) end end end context "when the change was caused by a change to a predecessor" do - let(:cause) do + subject(:cause) do { "type" => "work_package_predecessor_changed_times", "work_package_id" => work_package.id } end - context "when rendering HTML variant" do - let(:html) { true } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_predecessor_changed_times', link:)}" - end + context "when the user is able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + link = work_package_html_link + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_predecessor_changed_times', link:)}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + link = work_package_raw_link + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_predecessor_changed_times', link:)}" + ) end end - context "when rendering raw variant" do - let(:html) { false } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - let(:link) { "##{work_package.id}" } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_predecessor_changed_times', link:)}" - end + context "when the user is not able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) end end end context "when the change was caused by a change to a child" do - let(:cause) do + subject(:cause) do { "type" => "work_package_children_changed_times", "work_package_id" => work_package.id } end - context "when rendering HTML variant" do - let(:html) { true } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_children_changed_times', link:)}" - end + context "when the user is able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + link = work_package_html_link + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_children_changed_times', link:)}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + link = work_package_raw_link + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.work_package_children_changed_times', link:)}" + ) end end - context "when rendering raw variant" do - let(:html) { false } - - context "when the user is able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.where(id: work_package.id)) - end - - let(:link) { "##{work_package.id}" } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.work_package_children_changed_times', link:)}" - end + context "when the user is not able to access the related work package" do + before do + allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) end - context "when the user is not able to access the related work package" do - before do - allow(WorkPackage).to receive(:visible).with(User.current).and_return(WorkPackage.none) - end + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" - end + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.unaccessable_work_package_changed')}" + ) end end end context "when the change was caused by working day changes" do - let(:cause) do + subject(:cause) do { "type" => "working_days_changed", "changed_days" => { @@ -259,233 +274,261 @@ } end - context "when rendering HTML variant" do - let(:html) { true } + it do + changes = [ + I18n.t("journals.cause_descriptions.working_days_changed.days.non_working", day: WeekDay.find_by!(day: 2).name), + I18n.t("journals.cause_descriptions.working_days_changed.days.working", day: WeekDay.find_by!(day: 6).name), + I18n.t("journals.cause_descriptions.working_days_changed.dates.working", date: I18n.l(Date.new(2023, 1, 1))), + I18n.t("journals.cause_descriptions.working_days_changed.dates.non_working", date: I18n.l(Date.new(2023, 12, 24))) + ].join(", ") + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.working_days_changed.changed', changes:)}" + ) + end + + it do + changes = [ + I18n.t("journals.cause_descriptions.working_days_changed.days.non_working", day: WeekDay.find_by!(day: 2).name), + I18n.t("journals.cause_descriptions.working_days_changed.days.working", day: WeekDay.find_by!(day: 6).name), + I18n.t("journals.cause_descriptions.working_days_changed.dates.working", date: I18n.l(Date.new(2023, 1, 1))), + I18n.t("journals.cause_descriptions.working_days_changed.dates.non_working", date: I18n.l(Date.new(2023, 12, 24))) + ].join(", ") + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.dates_changed')} " \ + "#{I18n.t('journals.cause_descriptions.working_days_changed.changed', changes:)}" + ) + end + end - it do - changes = [ - I18n.t("journals.cause_descriptions.working_days_changed.days.non_working", day: WeekDay.find_by!(day: 2).name), - I18n.t("journals.cause_descriptions.working_days_changed.days.working", day: WeekDay.find_by!(day: 6).name), - I18n.t("journals.cause_descriptions.working_days_changed.dates.working", date: I18n.l(Date.new(2023, 1, 1))), - I18n.t("journals.cause_descriptions.working_days_changed.dates.non_working", date: I18n.l(Date.new(2023, 12, 24))) - ].join(", ") - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.working_days_changed.changed', changes:)}" - end + context "when a change of status % complete is the cause" do + shared_let(:status) { create(:status, name: "In progress", default_done_ratio: 40) } + subject(:cause) do + { + "type" => "status_changed", + "status_name" => status.name, + "status_id" => status.id, + "status_changes" => { "default_done_ratio" => [20, 40] } + } end - context "when rendering raw variant" do - let(:html) { false } + it do + expect(cause).to render_html_variant("Status 'In progress' " \ + "% complete changed from 20% to 40%") + end - it do - changes = [ - I18n.t("journals.cause_descriptions.working_days_changed.days.non_working", day: WeekDay.find_by!(day: 2).name), - I18n.t("journals.cause_descriptions.working_days_changed.days.working", day: WeekDay.find_by!(day: 6).name), - I18n.t("journals.cause_descriptions.working_days_changed.dates.working", date: I18n.l(Date.new(2023, 1, 1))), - I18n.t("journals.cause_descriptions.working_days_changed.dates.non_working", date: I18n.l(Date.new(2023, 12, 24))) - ].join(", ") - expect(subject).to eq "#{I18n.t('journals.caused_changes.dates_changed')} " \ - "#{I18n.t('journals.cause_descriptions.working_days_changed.changed', changes:)}" - end + it do + expect(cause).to render_raw_variant("Status 'In progress' % complete changed from 20% to 40%") end + + it_behaves_like "XSS-proof rendering of status name" end - context "when a change of status % complete is the cause" do - shared_let(:status) { create(:status, name: "In progress", default_done_ratio: 40) } - let(:cause) do + context "when a status being excluded from totals is the cause" do + shared_let(:status) { create(:status, name: "Rejected") } + subject(:cause) do { - "type" => "status_p_complete_changed", + "type" => "status_changed", "status_name" => status.name, "status_id" => status.id, - "status_p_complete_change" => [20, 40] + "status_changes" => { "excluded_from_totals" => [false, true] } } end - context "when rendering HTML variant" do - let(:html) { true } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.status_p_complete_changed')} " \ - "% complete value for status 'In progress' changed from 20% to 40%" - end + it do + expect(cause).to render_html_variant( + "Status 'Rejected' now excluded from hierarchy totals" + ) + end - it "escapes the status name" do - cause["status_name"] = "" - expect(subject).to eq "#{I18n.t('journals.caused_changes.status_p_complete_changed')} " \ - "% complete value for status '<script>alert('xss')</script>' " \ - "changed from 20% to 40%" - end + it do + expect(cause).to render_raw_variant( + "Status 'Rejected' now excluded from hierarchy totals" + ) end - context "when rendering raw variant" do - let(:html) { false } + it_behaves_like "XSS-proof rendering of status name" + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.status_p_complete_changed')} " \ - "% complete value for status 'In progress' changed from 20% to 40%" - end + context "when a status being no longer excluded from totals is the cause" do + shared_let(:status) { create(:status, name: "Rejected") } + subject(:cause) do + { + "type" => "status_changed", + "status_name" => status.name, + "status_id" => status.id, + "status_changes" => { "excluded_from_totals" => [true, false] } + } + end - it "does not escape the status name" do - cause["status_name"] = "" - expect(subject).to eq "#{I18n.t('journals.caused_changes.status_p_complete_changed')} " \ - "% complete value for status '' changed from 20% to 40%" - end + it do + expect(cause).to render_html_variant( + "Status 'Rejected' now included in hierarchy totals" + ) end + + it do + expect(cause).to render_raw_variant( + "Status 'Rejected' now included in hierarchy totals" + ) + end + + it_behaves_like "XSS-proof rendering of status name" end context "when a change of progress calculation mode to status-based is the cause" do - let(:cause) do + subject(:cause) do { "type" => "progress_mode_changed_to_status_based" } end - context "when rendering HTML variant" do - let(:html) { true } + it do + expect(cause).to render_html_variant( + "Progress calculation updated " \ + "Progress calculation mode set to status-based" + ) + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.progress_mode_changed_to_status_based')} " \ - "Progress calculation mode set to status-based" - end + it do + expect(cause).to render_raw_variant( + "Progress calculation updated " \ + "Progress calculation mode set to status-based" + ) end + end - context "when rendering raw variant" do - let(:html) { false } + context "when both a change of status % complete and excluded from totals is the cause" do + shared_let(:status) { create(:status, name: "In progress", default_done_ratio: 40) } + subject(:cause) do + { + "type" => "status_changed", + "status_name" => status.name, + "status_id" => status.id, + "status_changes" => { "default_done_ratio" => [20, 40], + "excluded_from_totals" => [false, true] } + } + end - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.progress_mode_changed_to_status_based')} " \ - "Progress calculation mode set to status-based" - end + it do + expect(cause).to render_html_variant("Status 'In progress' " \ + "% complete changed from 20% to 40% and now excluded from hierarchy totals") + end + + it do + expect(cause).to render_raw_variant("Status 'In progress' " \ + "% complete changed from 20% to 40% and now excluded from hierarchy totals") end + + it_behaves_like "XSS-proof rendering of status name" end context "when cause is a system update: change of progress calculation mode from disabled to work-based" do - let(:cause) do + subject(:cause) do { "type" => "system_update", "feature" => "progress_calculation_adjusted_from_disabled_mode" } end - context "when rendering HTML variant" do - let(:html) { true } - - it do - href = OpenProject::Static::Links.links[:blog_article_progress_changes][:href] - expect(subject).to eq "OpenProject system update: Progress calculation automatically " \ - "set to work-based mode and adjusted with version update." - end + it do + href = OpenProject::Static::Links.links[:blog_article_progress_changes][:href] + expect(cause).to render_html_variant( + "OpenProject system update: Progress calculation automatically " \ + "set to work-based mode and adjusted with version update." + ) end - context "when rendering raw variant" do - let(:html) { false } - - it do - expect(subject).to eq "OpenProject system update: Progress calculation automatically " \ - "set to work-based mode and adjusted with version update." - end + it do + expect(cause).to render_raw_variant( + "OpenProject system update: Progress calculation automatically " \ + "set to work-based mode and adjusted with version update." + ) end end context "when cause is a system update: progress calculation adjusted" do - let(:cause) do + subject(:cause) do { "type" => "system_update", "feature" => "progress_calculation_adjusted" } end - context "when rendering HTML variant" do - let(:html) { true } - - it do - href = OpenProject::Static::Links.links[:blog_article_progress_changes][:href] - expect(subject).to eq "OpenProject system update: Progress calculation automatically " \ - "adjusted with version update." - end + it do + href = OpenProject::Static::Links.links[:blog_article_progress_changes][:href] + expect(cause).to render_html_variant( + "OpenProject system update: Progress calculation automatically " \ + "adjusted with version update." + ) end - context "when rendering raw variant" do - let(:html) { false } - - it do - expect(subject).to eq "OpenProject system update: Progress calculation automatically " \ - "adjusted with version update." - end + it do + expect(cause).to render_raw_variant( + "OpenProject system update: Progress calculation automatically adjusted with version update." + ) end context "with previous feature key 'progress_calculation_changed'" do - let(:cause) do - { - "type" => "system_update", - "feature" => "progress_calculation_changed" - } + before do + cause["feature"] = "progress_calculation_changed" end - let(:html) { false } it "is rendered like 'progress_calculation_adjusted'" do - expect(subject).to eq "OpenProject system update: Progress calculation automatically " \ - "adjusted with version update." + expect(cause).to render_raw_variant( + "OpenProject system update: Progress calculation automatically adjusted with version update." + ) end end end context "when cause is a system update: totals removed from childless work packages" do - let(:cause) do + subject(:cause) do { "type" => "system_update", "feature" => "totals_removed_from_childless_work_packages" } end - context "when rendering HTML variant" do - let(:html) { true } - - it do - href = OpenProject::Static::Links.links[:release_notes_14_0_1][:href] - expect(subject).to eq "OpenProject system update: Work and progress totals " \ - "automatically removed for non-parent work packages with " \ - "version update. " \ - "This is a maintenance task and can be safely ignored." - end + it do + href = OpenProject::Static::Links.links[:release_notes_14_0_1][:href] + expect(cause).to render_html_variant( + "OpenProject system update: Work and progress totals " \ + "automatically removed for non-parent work packages with " \ + "version update. " \ + "This is a maintenance task and can be safely ignored." + ) end - context "when rendering raw variant" do - let(:html) { false } - - it do - expect(subject).to eq "OpenProject system update: Work and progress totals " \ - "automatically removed for non-parent work packages with " \ - "version update. " \ - "This is a maintenance task and can be safely ignored." - end + it do + expect(cause).to render_raw_variant( + "OpenProject system update: Work and progress totals " \ + "automatically removed for non-parent work packages with version update. " \ + "This is a maintenance task and can be safely ignored." + ) end end context "when the change was caused by a system update" do - let(:cause) do + subject(:cause) do { "type" => "system_update", "feature" => "file_links_journal" } end - context "when rendering HTML variant" do - let(:html) { true } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.system_update')} " \ - "#{I18n.t('journals.cause_descriptions.system_update.file_links_journal')}" - end + it do + expect(cause).to render_html_variant( + "#{I18n.t('journals.caused_changes.system_update')} " \ + "#{I18n.t('journals.cause_descriptions.system_update.file_links_journal')}" + ) end - context "when rendering raw variant" do - let(:html) { false } - - it do - expect(subject).to eq "#{I18n.t('journals.caused_changes.system_update')} " \ - "#{I18n.t('journals.cause_descriptions.system_update.file_links_journal')}" - end + it do + expect(cause).to render_raw_variant( + "#{I18n.t('journals.caused_changes.system_update')} " \ + "#{I18n.t('journals.cause_descriptions.system_update.file_links_journal')}" + ) end end end diff --git a/spec/models/notifications/scopes/unsent_reminders_before_spec.rb b/spec/models/notifications/scopes/unsent_reminders_before_spec.rb index c739b814ec49..9d84b3ab0175 100644 --- a/spec/models/notifications/scopes/unsent_reminders_before_spec.rb +++ b/spec/models/notifications/scopes/unsent_reminders_before_spec.rb @@ -39,62 +39,76 @@ Time.current end - let(:notification) do + let!(:notification) do create(:notification, recipient: notification_recipient, read_ian: notification_read_ian, mail_reminder_sent: notification_mail_reminder_sent, + mail_alert_sent: notification_mail_alert_sent, created_at: notification_created_at) end let(:notification_mail_reminder_sent) { false } + let(:notification_mail_alert_sent) { false } let(:notification_read_ian) { false } let(:notification_created_at) { 10.minutes.ago } let(:notification_recipient) { recipient } - let!(:notifications) { notification } - - shared_examples_for "is empty" do - it "is empty" do - expect(scope) - .to be_empty - end - end - - context "with a unread and not reminded notification that was created before the time and for the user" do + context "with an unread, not alerted about and not reminded notification that was created before the time and for the user" do it "returns the notification" do - expect(scope) - .to contain_exactly(notification) + expect(scope).to contain_exactly(notification) end end - context "with a unread and not reminded notification that was created after the time and for the user" do + context "with a notification that was created after the time" do let(:notification_created_at) { 10.minutes.from_now } - it_behaves_like "is empty" + it { is_expected.to be_empty } end - context "with a unread and not reminded notification that was created before the time and for different user" do + context "with a notification that was created for different user" do let(:notification_recipient) { create(:user) } - it_behaves_like "is empty" + it { is_expected.to be_empty } end - context "with a unread and not reminded notification created before the time and for the user" do + context "with a notification reminded mark set to nil" do let(:notification_mail_reminder_sent) { nil } - it_behaves_like "is empty" + it { is_expected.to be_empty } end - context "with a unread but reminded notification created before the time and for the user" do + context "with a reminded notification" do let(:notification_mail_reminder_sent) { true } - it_behaves_like "is empty" + it { is_expected.to be_empty } + end + + context "with a notification read mark set to nil" do + let(:notification_read_ian) { nil } + + it "returns the notification" do + expect(scope).to contain_exactly(notification) + end end - context "with a read notification that was created before the time" do + context "with a read notification" do let(:notification_read_ian) { true } - it_behaves_like "is empty" + it { is_expected.to be_empty } + end + + context "with a notification alert mark set to nil" do + let(:notification_mail_alert_sent) { nil } + + it "returns the notification" do + expect(scope).to contain_exactly(notification) + end + end + + context "with a notification about which user was already alerted" do + let(:notification_mail_alert_sent) { true } + + it { is_expected.to be_empty } end end end diff --git a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb index f6fd8f60c8db..6078c2c63782 100644 --- a/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb +++ b/spec/models/work_packages/pdf_export/work_package_to_pdf_spec.rb @@ -36,10 +36,47 @@ t.attribute_groups.first.attributes.push(cf_disabled_in_project.attribute_name, cf_long_text.attribute_name) end end + let(:parent_project) do + create(:project, name: "Parent project") + end + let(:project_custom_field_bool) { create(:project_custom_field, :boolean, + name: "Boolean project custom field") } + let(:project_custom_field_string) { + create(:project_custom_field, :string, + name: "Secret string", default_value: "admin eyes only", + visible: false) + } + let(:project_custom_field_long_text) { + create(:project_custom_field, :text, + name: "Rich text project custom field", + default_value: "rich text field value" + ) + } let(:project) do create(:project, name: "Foo Bla. Report No. 4/2021 with/for Case 42", types: [type], + public: true, + status_code: "on_track", + active: true, + parent: parent_project, + custom_field_values: { + project_custom_field_bool.id => true, + project_custom_field_long_text.id => "foo", + }, + work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], + work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled + end + let(:forbidden_project) do + create(:project, + name: "Forbidden project", + types: [type], + id: 666, + identifier: "forbidden-project", + public: false, + status_code: "on_track", + active: true, + parent: parent_project, work_package_custom_fields: [cf_long_text, cf_disabled_in_project, cf_global_bool], work_package_custom_field_ids: [cf_long_text.id, cf_global_bool.id]) # cf_disabled_in_project.id is disabled end @@ -47,12 +84,20 @@ create(:user, member_with_permissions: { project => %w[view_work_packages export_work_packages] }) end + let(:another_user) do + create(:user, firstname: "Secret User") + end + let(:category) { create(:category, project:, name: "Demo") } + let(:version) { create(:version, project:) } let(:export_time) { DateTime.new(2023, 6, 30, 23, 59) } let(:export_time_formatted) { format_time(export_time, true) } let(:image_path) { Rails.root.join("spec/fixtures/files/image.png") } + let(:priority) { create(:priority_normal) } let(:image_attachment) { Attachment.new author: user, file: File.open(image_path) } let(:attachments) { [image_attachment] } - let(:cf_long_text) { create(:issue_custom_field, :text, name: "LongText") } + let(:cf_long_text_description) { "" } + let(:cf_long_text) { create(:issue_custom_field, :text, + name: "Work Package Custom Field Long Text") } let!(:cf_disabled_in_project) do # NOT enabled by project.work_package_custom_field_ids => NOT in PDF create(:float_wp_custom_field, name: "DisabledCustomField") @@ -60,13 +105,16 @@ let(:cf_global_bool) do create( :work_package_custom_field, + name: "Work Package Custom Field Boolean", field_format: "bool", is_for_all: true, default_value: true ) end - let(:work_package) do - description = <<~DESCRIPTION + let(:status) { create(:status, name: "random", is_default: true) } + let!(:parent_work_package) { create(:work_package, type:, subject: "Parent wp") } + let(:description) do + <<~DESCRIPTION **Lorem** _ipsum_ ~~dolor~~ `sit` [amet](https://example.com/), consetetur sadipscing elitr. @OpenProject Admin ![](/api/v3/attachments/#{image_attachment.id}/content) @@ -80,14 +128,32 @@

    Foo

    DESCRIPTION + end + let(:work_package) do create(:work_package, + id: 1, project:, type:, subject: "Work package 1", + start_date: "2024-05-30", + due_date: "2024-05-30", + created_at: export_time, + updated_at: export_time, + author: user, + assigned_to: user, + responsible: user, story_points: 1, + estimated_hours: 10, + done_ratio: 25, + remaining_hours: 9, + parent: parent_work_package, + priority:, + version:, + status:, + category:, description:, custom_values: { - cf_long_text.id => "foo", + cf_long_text.id => cf_long_text_description, cf_disabled_in_project.id => "6.25", cf_global_bool.id => true }).tap do |wp| @@ -96,6 +162,24 @@ .and_return attachments end end + let(:forbidden_work_package) do + create(:work_package, + id: 10, + project: forbidden_project, + type:, + subject: "forbidden Work package", + start_date: "2024-05-30", + due_date: "2024-05-30", + created_at: export_time, + updated_at: export_time, + author: another_user, + assigned_to: another_user + ).tap do |wp| + allow(wp) + .to receive(:attachments) + .and_return attachments + end + end let(:options) { {} } let(:exporter) do described_class.new(work_package, options) @@ -109,6 +193,16 @@ export.export! end end + let(:expected_details) do + ["#{type.name} ##{work_package.id} - #{work_package.subject}"] + + exporter.send(:attributes_data_by_wp, work_package) + .flat_map do |item| + value = get_column_value(item[:name]) + result = [item[:label].upcase] + result << value if value.present? + result + end + end def get_column_value(column_name) formatter = Exports::Register.formatter_for(WorkPackage, column_name, :pdf) @@ -125,30 +219,203 @@ def get_column_value(column_name) end describe "with a request for a PDF" do - it "contains correct data" do - details = exporter.send(:attributes_data_by_wp, work_package) - .flat_map do |item| - value = get_column_value(item[:name]) - result = [item[:label].upcase] - result << value if value.present? - result + describe "with rich text and images" do + let(:cf_long_text_description) { "foo" } + it "contains correct data" do + result = pdf[:strings] + expected_result = [ + *expected_details, + label_title(:description), + "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ", + "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin", + "Image Caption", + "Foo", + cf_long_text.name, "foo", + "1", export_time_formatted, project.name + ].flatten + # Joining the results for comparison since word wrapping leads to a different array for the same content + expect(result.join(" ")).to eq(expected_result.join(" ")) + expect(result.join(" ")).not_to include("DisabledCustomField") + expect(pdf[:images].length).to eq(2) + end + end + + describe "with embedded work package attributes" do + let(:supported_work_package_embeds) do + [ + ["assignee", user.name], + ["author", user.name], + ["category", category.name], + ["createdAt", export_time_formatted], + ["updatedAt", export_time_formatted], + ["estimatedTime", "10.0 h"], + ["remainingTime", "9.0 h"], + ["version", version.name], + ["responsible", user.name], + ["dueDate", "05/30/2024"], + ["spentTime", "0.0 h"], + ["startDate", "05/30/2024"], + ["parent", "#{type.name} ##{parent_work_package.id}: #{parent_work_package.name}"], + ["priority", priority.name], + ["project", project.name], + ["status", status.name], + ["subject", "Work package 1"], + ["type", type.name], + ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"] + ] + end + let(:supported_work_package_embeds_table) do + supported_work_package_embeds.map do |embed| + "" + end + end + let(:description) do + <<~DESCRIPTION + ## Work package attributes and labels +
    workPackageLabel:#{embed[0]}workPackageValue:#{embed[0]}
    #{supported_work_package_embeds_table} + + + +
    Custom field boolean + workPackageValue:1:"#{cf_global_bool.name}" +
    Custom field rich text + workPackageValue:1:"#{cf_long_text.name}" +
    No replacement of: + workPackageValue:1:assignee + workPackageLabel:assignee +
    + + `workPackageValue:2:assignee workPackageLabel:assignee` + + ``` + workPackageValue:3:assignee + workPackageLabel:assignee + ``` + + Work package not found: + workPackageValue:1234567890:assignee + Access denied: + workPackageValue:#{forbidden_work_package.id}:assignee + DESCRIPTION + end + it "contains resolved attributes and labels" do + result = pdf[:strings] + expected_result = [ + *expected_details, + label_title(:description), + "Work package attributes and labels", + supported_work_package_embeds.map do |embed| + [WorkPackage.human_attribute_name( + API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: work_package) + ), embed[1]] + end, + "Custom field boolean", I18n.t(:general_text_Yes), + "1", export_time_formatted, project.name, + "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]", + "No replacement of:", "workPackageValue:1:assignee", " ", "workPackageLabel:assignee", + "workPackageValue:2:assignee workPackageLabel:assignee", + "workPackageValue:3:assignee", "workPackageLabel:assignee", + "Work package not found: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "WorkPackage 1234567890"))}] ", + "Access denied: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "WorkPackage #{forbidden_work_package.id}"))}]", + "2", export_time_formatted, project.name + ].flatten + expect(result.join(" ")).to eq(expected_result.join(" ")) + end + end + + describe "with embedded project attributes" do + let(:supported_project_embeds) do + [ + ["active", I18n.t(:general_text_Yes)], + ["description", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], + ["identifier", project.identifier], + ["name", project.name], + ["status", I18n.t("activerecord.attributes.project.status_codes.#{project.status_code}")], + ["statusExplanation", "[#{I18n.t('export.macro.rich_text_unsupported')}]"], + ["parent", parent_project.name], + ["public", I18n.t(:general_text_Yes)] + ] + end + let(:supported_project_embeds_table) do + supported_project_embeds.map do |embed| + "projectLabel:#{embed[0]}projectValue:#{embed[0]}" + end + end + let(:description) do + <<~DESCRIPTION + ## Project attributes and labels + #{supported_project_embeds_table} + + + + +
    Custom field boolean + projectValue:"#{project_custom_field_bool.name}" +
    Custom field rich text + projectValue:"#{project_custom_field_long_text.name}" +
    Custom field hidden + projectValue:"#{project_custom_field_string.name}" +
    No replacement of: + projectValue:1:status + projectLabel:status +
    + + `projectValue:2:status projectLabel:status` + + ``` + projectValue:3:status + projectLabel:status + ``` + + Project by identifier: + projectValue:"#{project.identifier}":active + + Project not found: + projectValue:1234567890:active + Access denied: + projectValue:#{forbidden_project.id}:active + Access denied by identifier: + projectValue:"#{forbidden_project.identifier}":active + DESCRIPTION + end + it "contains resolved attributes and labels" do + result = pdf[:strings] + expected_result = [ + *expected_details, + label_title(:description), + "Project attributes and labels", + supported_project_embeds.map do |embed| + [Project.human_attribute_name( + API::Utilities::PropertyNameConverter.to_ar_name(embed[0].to_sym, context: project) + ), embed[1]] + end, + "Custom field boolean", I18n.t(:general_text_Yes), + "Custom field rich text", "[#{I18n.t('export.macro.rich_text_unsupported')}]", + "Custom field hidden", + + "No replacement of:", "projectValue:1:status", "projectLabel:status", + "projectValue:2:status projectLabel:status", + "projectValue:3:status", "projectLabel:status", + + "1", export_time_formatted, project.name, + + "Project by identifier:", " ", I18n.t(:general_text_Yes), + "Project not found: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "Project 1234567890"))}] ", + "Access denied: ", + "[#{I18n.t('export.macro.error', message: + I18n.t('export.macro.resource_not_found', resource: "Project #{forbidden_project.id}"))}] ", + "Access denied by identifier:", " ", "[Macro error, resource not found: Project", "forbidden-project]", + + "2", export_time_formatted, project.name, + ].flatten + expect(result.join(" ")).to eq(expected_result.join(" ")) end - # Joining the results for comparison since word wrapping leads to a different array for the same content - result = pdf[:strings].join(" ") - expected_result = [ - "#{type.name} ##{work_package.id} - #{work_package.subject}", - *details, - label_title(:description), - "Lorem", " ", "ipsum", " ", "dolor", " ", "sit", " ", - "amet", ", consetetur sadipscing elitr.", " ", "@OpenProject Admin", - "Image Caption", - "Foo", - "LongText", "foo", - "1", export_time_formatted, project.name - ].join(" ") - expect(result).to eq(expected_result) - expect(result).not_to include("DisabledCustomField") - expect(pdf[:images].length).to eq(2) end end end diff --git a/spec/services/duration_converter_spec.rb b/spec/services/duration_converter_spec.rb new file mode 100644 index 000000000000..ae41cc833a2f --- /dev/null +++ b/spec/services/duration_converter_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 DurationConverter do + describe ".parse" do + it "returns 0 when given 0 duration" do + expect(described_class.parse("0 hrs")).to eq(0) + end + + it "works with ChronicDuration defaults otherwise" do + expect(described_class.parse("5 hrs 30 mins")).to eq(5.5) + end + + it "assumes hours as the default unit for input if no other units given" do + expect(described_class.parse("5.75")).to eq(5.75) + end + + it "assumes the next logical unit if at least one unit is given" do + expect(described_class.parse("2h 15")).to eq(2.25) + expect(described_class.parse("1d 24")).to eq(32) + expect(described_class.parse("1w 1")).to eq(48) + expect(described_class.parse("1mo 1")).to eq(200) + expect(described_class.parse("1mo 1w 1d 1h 30")).to eq(209.5) + end + end + + describe ".output" do + it "returns nil when given nil" do + expect(described_class.output(nil)).to be_nil + end + + it "returns 0 h when given 0" do + expect(described_class.output(0)).to eq("0h") + end + + it "works with ChronicDuration defaults otherwise in :short format" do + expect(described_class.output(5.75)) + .to eq("5h 45m") + end + + it "ignores seconds and keep the nearest minute" do + expect(described_class.output(0.28)) + .to eq("17m") + expect(described_class.output(2.23)) + .to eq("2h 14m") + end + end +end diff --git a/spec/services/notifications/create_from_model_service_work_package_spec.rb b/spec/services/notifications/create_from_model_service_work_package_spec.rb index 6c28374eb004..c582162b355e 100644 --- a/spec/services/notifications/create_from_model_service_work_package_spec.rb +++ b/spec/services/notifications/create_from_model_service_work_package_spec.rb @@ -121,7 +121,7 @@ { read_ian: false, reason: :assigned, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -139,7 +139,7 @@ { read_ian: false, reason: :assigned, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -168,7 +168,7 @@ { read_ian: false, reason: :assigned, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -212,7 +212,7 @@ { read_ian: false, reason: :responsible, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -265,7 +265,7 @@ { read_ian: false, reason: :watched, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -283,7 +283,7 @@ { read_ian: false, reason: :watched, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -332,7 +332,7 @@ { read_ian: false, reason: :created, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -350,7 +350,7 @@ { read_ian: false, reason: :created, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -380,7 +380,7 @@ { read_ian: false, reason: :created, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -429,7 +429,7 @@ { read_ian: false, reason: :shared, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -477,7 +477,7 @@ { read_ian: false, reason: :created, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -533,7 +533,7 @@ { read_ian: false, reason: :commented, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -582,7 +582,7 @@ { read_ian: false, reason: :processed, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -631,7 +631,7 @@ { read_ian: false, reason: :prioritized, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -680,7 +680,7 @@ { read_ian: false, reason: :scheduled, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -729,7 +729,7 @@ { read_ian: false, reason: :scheduled, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -762,7 +762,7 @@ { read_ian: false, reason: :assigned, - mail_alert_sent: false, + mail_alert_sent: nil, mail_reminder_sent: false } end @@ -991,16 +991,7 @@ end let(:author) { recipient } - it_behaves_like "creates notification" do - let(:notification_channel_reasons) do - { - read_ian: false, - reason: :mentioned, - mail_alert_sent: false, - mail_reminder_sent: false - } - end - end + it_behaves_like "creates no notification" end context "when there is already a notification for the journal (because it was aggregated)" do @@ -1082,16 +1073,7 @@ end let(:author) { recipient } - it_behaves_like "creates notification" do - let(:notification_channel_reasons) do - { - read_ian: false, - reason: :mentioned, - mail_alert_sent: false, - mail_reminder_sent: false - } - end - end + it_behaves_like "creates no notification" end end diff --git a/spec/services/notifications/mail_service_mentioned_integration_spec.rb b/spec/services/notifications/mail_service_mentioned_integration_spec.rb index 22bb27e282ef..392b5b433390 100644 --- a/spec/services/notifications/mail_service_mentioned_integration_spec.rb +++ b/spec/services/notifications/mail_service_mentioned_integration_spec.rb @@ -75,7 +75,7 @@ def expect_assigned_notification expect(assigned_notification).to be_present expect(assigned_notification.recipient).to eq assignee expect(assigned_notification.read_ian).to be false - expect(assigned_notification.mail_alert_sent).to be false + expect(assigned_notification.mail_alert_sent).to be_nil end it "triggers only one mention notification mail when editing attributes afterwards" do diff --git a/spec/services/project_custom_field_project_mappings/bulk_create_service_spec.rb b/spec/services/project_custom_field_project_mappings/bulk_create_service_spec.rb new file mode 100644 index 000000000000..464f6c3663ba --- /dev/null +++ b/spec/services/project_custom_field_project_mappings/bulk_create_service_spec.rb @@ -0,0 +1,113 @@ +#-- 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 ProjectCustomFieldProjectMappings::BulkCreateService do + shared_let(:project_custom_field) { create(:project_custom_field) } + + context "with admin permissions" do + let(:user) { create(:admin) } + + context "with a single project" do + let(:project) { create(:project) } + let(:instance) { described_class.new(user:, project:, project_custom_field:) } + + it "creates the mappings" do + expect { instance.call }.to change(ProjectCustomFieldProjectMapping, :count).by(1) + + aggregate_failures "creates the mapping for the correct project and custom field" do + expect(ProjectCustomFieldProjectMapping.last.project).to eq(project) + expect(ProjectCustomFieldProjectMapping.last.project_custom_field).to eq(project_custom_field) + end + end + end + + context "with subprojects" do + let(:project) { create(:project) } + let!(:subproject) { create(:project, parent: project) } + let!(:subproject2) { create(:project, parent: subproject) } + + it "creates the mappings for the project and sub-projects" do + create_service = described_class.new(user:, project: project.reload, project_custom_field:, + include_sub_projects: true) + + expect { create_service.call }.to change(ProjectCustomFieldProjectMapping, :count).by(3) + + aggregate_failures "creates the mapping for the correct project and custom field" do + expect(ProjectCustomFieldProjectMapping.where(project_custom_field:).pluck(:project_id)) + .to contain_exactly(project.id, subproject.id, subproject2.id) + end + end + end + end + + context "with non-admin but sufficient permissions" do + let(:user) do + create(:user, + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + let(:project) { create(:project) } + let(:instance) { described_class.new(user:, project:, project_custom_field:) } + + it "creates the mappings" do + expect { instance.call }.to change(ProjectCustomFieldProjectMapping, :count).by(1) + + aggregate_failures "creates the mapping for the correct project and custom field" do + expect(ProjectCustomFieldProjectMapping.last.project).to eq(project) + expect(ProjectCustomFieldProjectMapping.last.project_custom_field).to eq(project_custom_field) + end + end + end + + context "without sufficient permissions" do + let(:user) do + create(:user, + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + ] + }) + end + let(:project) { create(:project) } + let(:instance) { described_class.new(user:, project:, project_custom_field:) } + + it "does not create the mappings" do + expect { instance.call }.not_to change(ProjectCustomFieldProjectMapping, :count) + expect(instance.call).to be_failure + end + end +end diff --git a/spec/services/settings/working_days_update_service_spec.rb b/spec/services/settings/working_days_and_hours_update_service_spec.rb similarity index 94% rename from spec/services/settings/working_days_update_service_spec.rb rename to spec/services/settings/working_days_and_hours_update_service_spec.rb index 597466215d65..cd7b8072c94e 100644 --- a/spec/services/settings/working_days_update_service_spec.rb +++ b/spec/services/settings/working_days_and_hours_update_service_spec.rb @@ -27,7 +27,7 @@ require "spec_helper" require_relative "shared/shared_call_examples" -RSpec.describe Settings::WorkingDaysUpdateService do +RSpec.describe Settings::WorkingDaysAndHoursUpdateService do let(:instance) do described_class.new(user:) end @@ -39,7 +39,7 @@ end let(:contract_success) { true } let(:params_contract) do - instance_double(Settings::WorkingDaysParamsContract, + instance_double(Settings::WorkingDaysAndHoursParamsContract, valid?: params_contract_success, errors: instance_double(ActiveModel::Error)) end @@ -55,20 +55,20 @@ # stub a setting definition allow(Setting) .to receive(:[]) - .and_call_original + .and_call_original allow(Setting) .to receive(:[]).with(setting_name) - .and_return(previous_setting_value) + .and_return(previous_setting_value) allow(Setting) .to receive(:[]=) # stub contract allow(Settings::UpdateContract) .to receive(:new) - .and_return(contract) - allow(Settings::WorkingDaysParamsContract) + .and_return(contract) + allow(Settings::WorkingDaysAndHoursParamsContract) .to receive(:new) - .and_return(params_contract) + .and_return(params_contract) end describe "#call" do @@ -87,7 +87,7 @@ expect(WorkPackages::ApplyWorkingDaysChangeJob) .to have_received(:perform_later) - .with(user_id: user.id, previous_working_days:, previous_non_working_days:) + .with(user_id: user.id, previous_working_days:, previous_non_working_days:) end end diff --git a/spec/services/work_packages/update_ancestors/loader_spec.rb b/spec/services/work_packages/update_ancestors/loader_spec.rb index 0b4f7c70bd24..8986890fcdbc 100644 --- a/spec/services/work_packages/update_ancestors/loader_spec.rb +++ b/spec/services/work_packages/update_ancestors/loader_spec.rb @@ -27,6 +27,17 @@ require "spec_helper" RSpec.describe WorkPackages::UpdateAncestors::Loader, type: :model do + shared_let(:user) { create(:user) } + shared_let(:project) { create(:project_with_types) } + shared_let(:included_status) { create(:status) } + shared_let(:excluded_status) { create(:rejected_status) } + + before_all do + set_factory_default(:project_with_types, project) + set_factory_default(:status, included_status) + set_factory_default(:user, user) + end + shared_let(:grandgrandparent) do create(:work_package, subject: "grandgrandparent") @@ -223,67 +234,78 @@ end end - describe "#descendants_of" do - def descendants_of_hash(hashed_work_package) - { "estimated_hours" => nil, - "id" => hashed_work_package.id, - "ignore_non_working_days" => false, - "parent_id" => hashed_work_package.parent_id, - "remaining_hours" => nil, - "schedule_manually" => false } - end + def work_package_struct(work_package) + attribute_names = WorkPackages::UpdateAncestors::Loader::WorkPackageLikeStruct.members.map(&:to_s) + attributes = work_package.attributes.slice(*attribute_names) + attributes[:status_excluded_from_totals] = false + WorkPackages::UpdateAncestors::Loader::WorkPackageLikeStruct.new(**attributes) + end + describe "#descendants_of" do context "for the work_package" do - it "is its child (as a hash)" do + it "is its child (as a struct)" do expect(instance.descendants_of(work_package)) - .to contain_exactly(descendants_of_hash(child)) + .to contain_exactly(work_package_struct(child)) + end + + context "with the child having a status not being excluded from totals calculation" do + before do + child.update(status: included_status) + end + + it "correctly responds true to #included_in_totals_calculation? like a WorkPackage instance" do + child = instance.descendants_of(work_package).first + expect(child.included_in_totals_calculation?).to be true + end + end + + context "with the child having a status being excluded from totals calculation" do + before do + child.update(status: excluded_status) + end + + it "correctly responds false to #included_in_totals_calculation? like a WorkPackage instance" do + child = instance.descendants_of(work_package).first + expect(child.included_in_totals_calculation?).to be false + end end end context "for the parent" do - it "is the work package, its child (as a hash) and its sibling (as a hash)" do + it "is the work package, its child (as a struct) and its sibling (as a struct)" do expect(instance.descendants_of(parent)) - .to contain_exactly(descendants_of_hash(child), work_package, descendants_of_hash(sibling)) + .to contain_exactly(work_package_struct(child), work_package, work_package_struct(sibling)) end end context "for the grandparent" do - it "is the parent, the work package, its child (as a hash) and its sibling (as a hash)" do + it "is the parent, the work package, its child (as a struct) and its sibling (as a struct)" do expect(instance.descendants_of(grandparent)) - .to contain_exactly(parent, work_package, descendants_of_hash(child), descendants_of_hash(sibling)) + .to contain_exactly(parent, work_package, work_package_struct(child), work_package_struct(sibling)) end end context "for the grandgrandparent (the root)" do - it "is the complete tree, partly as a hash and partly as the preloaded work packages" do + it "is the complete tree, partly as a struct and partly as the preloaded work packages" do expect(instance.descendants_of(grandgrandparent)) - .to contain_exactly(descendants_of_hash(grandparent_sibling), grandparent, parent, work_package, - descendants_of_hash(child), descendants_of_hash(sibling)) + .to contain_exactly(work_package_struct(grandparent_sibling), grandparent, parent, work_package, + work_package_struct(child), work_package_struct(sibling)) end end end describe "#children_of" do - def children_of_hash(hashed_work_package) - { "estimated_hours" => nil, - "id" => hashed_work_package.id, - "ignore_non_working_days" => false, - "parent_id" => hashed_work_package.parent_id, - "remaining_hours" => nil, - "schedule_manually" => false } - end - context "for the work_package" do - it "is its child (as a hash)" do + it "is its child (as a struct)" do expect(instance.children_of(work_package)) - .to contain_exactly(children_of_hash(child)) + .to contain_exactly(work_package_struct(child)) end end context "for the parent" do - it "is the work package and its sibling (as a hash)" do + it "is the work package and its sibling (as a struct)" do expect(instance.children_of(parent)) - .to contain_exactly(work_package, children_of_hash(sibling)) + .to contain_exactly(work_package, work_package_struct(sibling)) end end @@ -295,9 +317,9 @@ def children_of_hash(hashed_work_package) end context "for the grandgrandparent" do - it "is the grandparent and its sibling (as a hash)" do + it "is the grandparent and its sibling (as a struct)" do expect(instance.children_of(grandgrandparent)) - .to contain_exactly(children_of_hash(grandparent_sibling), grandparent) + .to contain_exactly(work_package_struct(grandparent_sibling), grandparent) end end end diff --git a/spec/services/work_packages/update_ancestors_service_spec.rb b/spec/services/work_packages/update_ancestors_service_spec.rb index b549fca80e5b..3ea159cd7a6d 100644 --- a/spec/services/work_packages/update_ancestors_service_spec.rb +++ b/spec/services/work_packages/update_ancestors_service_spec.rb @@ -34,16 +34,11 @@ shared_association_default(:priority) { create(:priority) } shared_association_default(:open_status, factory_name: :status) { create(:status, name: "Open", default_done_ratio: 0) } shared_let(:closed_status) { create(:closed_status, name: "Closed", default_done_ratio: 100) } + shared_let(:rejected_status) { create(:rejected_status, default_done_ratio: 20) } shared_let(:user) { create(:user) } - let(:estimated_hours) { [nil, nil, nil] } - let(:done_ratios) { [0, 0, 0] } - let(:statuses) { %i(open open open) } - let(:aggregate_done_ratio) { 0.0 } - let(:ignore_non_working_days) { [false, false, false] } - # In order to have dependent values computed, this leverages - # the SetAttributesService to mimick how attributes are set + # the SetAttributesService to mimic how attributes are set # on work packages prior to the UpdateAncestorsService being # executed. def set_attributes_on(work_package, attributes) @@ -54,82 +49,81 @@ def set_attributes_on(work_package, attributes) .call(attributes) end - describe "done_ratio/estimated_hours/remaining_hours propagation" do - context "when setting the status of a work package" do - context 'when using the "status-based" % complete mode', - with_settings: { work_package_done_ratio: "status" } do - def call_update_ancestors_service(work_package) - changed_attributes = work_package.changes.keys.map(&:to_sym) - described_class.new(user:, work_package:) - .call(changed_attributes) - end + def call_update_ancestors_service(work_package) + changed_attributes = work_package.changes.keys.map(&:to_sym) + described_class.new(user:, work_package:) + .call(changed_attributes) + end - context "with both parent and children having estimated hours set" do - shared_let(:parent) do - create(:work_package, - subject: "parent", - estimated_hours: 10.0, - remaining_hours: 10.0, - derived_estimated_hours: 15.0, - derived_remaining_hours: 15.0, - status: open_status) - end - shared_let(:child) do - create(:work_package, - subject: "child", - parent:, - estimated_hours: 5.0, - remaining_hours: 5.0, - status: open_status) - end + describe "work, remaining work, and % complete propagation" do + context 'when using the "status-based" progress calculation mode', + with_settings: { work_package_done_ratio: "status" } do + context "with both parent and children having work set" do + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | 10h | 15h | 10h | 15h | 0% | 0% + child | Open | 5h | | 5h | | 0% | + TABLE - context "when changing child status to a status with a default done ratio" do - %i[status status_id].each do |field| - context "with the #{field} field" do - it "recomputes child remaining work and updates ancestors total % complete accordingly" do - value = - case field - when :status then closed_status - when :status_id then closed_status.id - end - set_attributes_on(child, field => value) - call_update_ancestors_service(child) - - expect_work_packages([parent, child], <<~TABLE) - | subject | work | total work | remaining work | total remaining work | % complete | total % complete | - | parent | 10h | 15h | 10h | 10h | 0% | 33% | - | child | 5h | | 0h | | 100% | | - TABLE - end + context "when changing child status to a status with a default % complete ratio" do + %i[status status_id].each do |field| + context "with the #{field} field" do + it "recomputes child remaining work and updates ancestors total % complete accordingly" do + value = + case field + when :status then closed_status + when :status_id then closed_status.id + end + set_attributes_on(child, field => value) + call_update_ancestors_service(child) + + expect_work_packages([parent, child], <<~TABLE) + | subject | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete | + | parent | Open | 10h | 15h | 10h | 10h | 0% | 33% | + | child | Closed | 5h | | 0h | | 100% | | + TABLE end end end end - context "with parent having nothing set, and 2 children having values set (bug #54179)" do - let_work_packages(<<~TABLE) - hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete - parent | Open | | 15h | | 10h | 0% | 33% - child1 | Open | 10h | | 10h | | 0% | - child2 | Closed | 5h | | 0h | | 100% | - TABLE + context "when changing child status to a status excluded from totals calculation" do + before do + set_attributes_on(child, status: rejected_status) + call_update_ancestors_service(child) + end - context "when changing children to all have 100% complete" do - before do - set_attributes_on(child1, status: closed_status) - call_update_ancestors_service(child1) - end + it "still recomputes child remaining work and updates ancestors total % complete excluding it" do + expect_work_packages([parent, child], <<~TABLE) + | subject | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete | + | parent | Open | 10h | 10h | 10h | 10h | 0% | 0% | + | child | Rejected | 5h | | 4h | | 20% | | + TABLE + end + end + end - it "sets parent total % complete to 100% and its total remaining work to 0h, " \ - "and computes totals for the updated children too" do - table_work_packages.map(&:reload) - expect_work_packages(table_work_packages, <<~TABLE) - hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete - parent | Open | | 15h | | 0h | 0% | 100% - child1 | Closed | 10h | | 0h | | 100% | - child2 | Closed | 5h | | 0h | | 100% | - TABLE - end + context "with parent having nothing set, and 2 children having values set (bug #54179)" do + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | | 15h | | 10h | 0% | 33% + child1 | Open | 10h | | 10h | | 0% | + child2 | Closed | 5h | | 0h | | 100% | + TABLE + + context "when changing children to all have 100% complete" do + before do + set_attributes_on(child1, status: closed_status) + call_update_ancestors_service(child1) + end + + it "sets parent total % complete to 100% and its total remaining work to 0h" do + expect_work_packages(table_work_packages, <<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | | 15h | | 0h | 0% | 100% + child1 | Closed | 10h | | 0h | | 100% | + child2 | Closed | 5h | | 0h | | 100% | + TABLE end end end @@ -137,459 +131,228 @@ def call_update_ancestors_service(work_package) context "for the new ancestor chain" do shared_examples "attributes of parent having children" do - before do - children - end - it "is a success" do expect(subject) .to be_success end - it "updated one work package - the parent" do + it "updates one work package - the parent" do expect(subject.dependent_results.map(&:result)) .to contain_exactly(parent) end - it "has the expected derived done ratio" do + it "has the expected total % complete" do expect(subject.dependent_results.first.result.derived_done_ratio) - .to eq aggregate_done_ratio + .to eq expected_total_p_complete end - it "has the expected derived estimated_hours" do + it "has the expected total work" do expect(subject.dependent_results.first.result.derived_estimated_hours) - .to eq aggregate_estimated_hours + .to eq expected_total_work end - it "has the expected derived remaining_hours" do + it "has the expected total remaining work" do expect(subject.dependent_results.first.result.derived_remaining_hours) - .to eq aggregate_remaining_hours + .to eq expected_total_remaining_work end end - context "when on field-based mode for % complete" do - let(:children) do - (statuses.size - 1).downto(0).map do |i| - create(:work_package, - parent:, - subject: "child #{i}", - estimated_hours: estimated_hours[i], - remaining_hours: remaining_hours[i], - ignore_non_working_days:) - end + shared_context "when work is changed" do + subject do + # On work-based mode, changing estimated_hours + # entails done_ratio also changing when going + # through the SetAttributesService + described_class + .new(user:, + work_package: child1) + .call(%i(estimated_hours done_ratio)) end + end - shared_let(:parent) { create(:work_package, subject: "parent", status: open_status) } - - context "when estimated_hours is changed" do - subject do - # On field-based mode, changing estimated_hours - # entails done_ratio also changing when going - # through the SetAttributesService - described_class - .new(user:, - work_package: children.first) - .call(%i(estimated_hours done_ratio)) - end - - context "with no estimated hours and no progress" do - let(:estimated_hours) { [nil, nil, nil] } - let(:remaining_hours) { [nil, nil, nil] } - - it "is a success" do - expect(subject) - .to be_success - end - - it "does not update the parent" do - expect(subject.dependent_results) - .to be_empty - end - end + shared_context "when remaining work is changed" do + subject do + # On work-based mode, changing remaining work + # entails % complete also changing when going + # through the SetAttributesService + described_class + .new(user:, + work_package: child1) + .call(%i(remaining_hours done_ratio)) + end + end - context "with all tasks having estimated hours and some having progress done already" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 10.0, 10.0] - end - let(:remaining_hours) do - [0.0, 0.0, 10.0] - end + context "without any work or % complete being set" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | | | + child1 | | | + child2 | | | + child3 | | | + TABLE - let(:aggregate_estimated_hours) do - 30.0 - end - let(:aggregate_remaining_hours) do - 10.0 - end - let(:aggregate_done_ratio) do - 67 - end - end + for_each_context "when work is changed", + "when remaining work is changed" do + it "is a success" do + expect(subject) + .to be_success end - context "with all tasks having estimated hours and all having progress done already" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 10.0, 10.0] - end - let(:remaining_hours) do - [2.5, 2.5, 10.0] - end - - let(:aggregate_estimated_hours) do - 30.0 - end - let(:aggregate_remaining_hours) do - 15.0 - end - let(:aggregate_done_ratio) do - 50 - end - end + it "does not update the parent" do + expect(subject.dependent_results) + .to be_empty end + end + end - context "with all tasks having estimated hours and no tasks having any remaining hours" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 2.0, 3.0] - end - let(:remaining_hours) do - [0.0, 0.0, 0.0] - end + context "with all children tasks having work and remaining work set" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | | | + child1 | 10h | 0h | 100% + child2 | 10h | 0h | 100% + child3 | 10h | 10h | 0% + TABLE - let(:aggregate_estimated_hours) do - 15.0 - end - let(:aggregate_remaining_hours) do - 0.0 - end - let(:aggregate_done_ratio) do - 100 - end + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + let(:expected_total_work) do + 30.0 end - end - - context "with all tasks having estimated hours and no tasks having progress set" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 2.0, 3.0] - end - let(:remaining_hours) do - [nil, nil, nil] - end - - let(:aggregate_estimated_hours) do - 15.0 - end - let(:aggregate_remaining_hours) do - nil - end - let(:aggregate_done_ratio) do - nil - end + let(:expected_total_remaining_work) do + 10.0 end - end - - context "with some tasks having estimated hours and none having progress set" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [nil, nil, nil] - end - - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - nil - end - let(:aggregate_done_ratio) do - nil - end + let(:expected_total_p_complete) do + 67 end end + end + end - context "with some tasks having estimated hours and those that do also having progress done" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [2.5, nil, nil] - end - - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - 2.5 - end - let(:aggregate_done_ratio) do - 75 - end - end - end + context "with all children tasks having work and remaining work set (second example)" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | | | + child1 | 10h | 2.5h | 75% + child2 | 10h | 2.5h | 75% + child3 | 10h | 10h | 0% + TABLE - context "with the parent having estimated hours and progress" do - shared_let(:parent) do - create(:work_package, - subject: "parent", - estimated_hours: 10.0, - remaining_hours: 5.0) + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + let(:expected_total_work) do + 30.0 end - - context "and some tasks having estimated hours and some progress" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [2.5, nil, nil] - end - - # Parent's estimated and remaining hours are taken into account - let(:aggregate_estimated_hours) do - 20.0 - end - let(:aggregate_remaining_hours) do - 7.5 - end - let(:aggregate_done_ratio) do - 63 - end - end + let(:expected_total_remaining_work) do + 15.0 end - - context "and no tasks having estimated hours or progress" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [nil, nil, nil] - end - let(:remaining_hours) do - [nil, nil, nil] - end - - # Parent's estimated hours and remaining hours become the aggregated values - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - 5.0 - end - let(:aggregate_done_ratio) do - 50 - end - end + let(:expected_total_p_complete) do + 50 end end end + end - context "when remaining_hours is changed" do - subject do - # On field-based mode, changing remaining_hours - # entails done_ratio also changing when going - # through the SetAttributesService - described_class - .new(user:, - work_package: children.first) - .call(%i(remaining_hours done_ratio)) - end - - context "with no estimated hours and no progress" do - let(:estimated_hours) { [nil, nil, nil] } - let(:remaining_hours) { [nil, nil, nil] } - # let(:statuses) { %i(open open open) } + context "with all children tasks having work set to positive value, and having remaining work set to 0h" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | | | + child1 | 10h | 0h | 100% + child2 | 2h | 0h | 100% + child3 | 3h | 0h | 100% + TABLE - it "is a success" do - expect(subject) - .to be_success + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + let(:expected_total_work) do + 15.0 end - - it "does not update the parent" do - expect(subject.dependent_results) - .to be_empty + let(:expected_total_remaining_work) do + 0.0 end - end - - context "with all tasks having estimated hours and some having progress done already" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 10.0, 10.0] - end - let(:remaining_hours) do - [0.0, 0.0, 10.0] - end - - let(:aggregate_estimated_hours) do - 30.0 - end - let(:aggregate_remaining_hours) do - 10.0 - end - let(:aggregate_done_ratio) do - 67 - end + let(:expected_total_p_complete) do + 100 end end + end + end - context "with all tasks having estimated hours and all having progress done already" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 10.0, 10.0] - end - let(:remaining_hours) do - [2.5, 2.5, 10.0] - end + context "with some children tasks having work and remaining work set" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | | | + child1 | 10h | 2.5h | 75% + child2 | | | + child3 | | | + TABLE - let(:aggregate_estimated_hours) do - 30.0 - end - let(:aggregate_remaining_hours) do - 15.0 - end - let(:aggregate_done_ratio) do - 50 - end + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + let(:expected_total_work) do + 10.0 end - end - - context "with all tasks having estimated hours and no tasks having remaining hours" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 2.0, 3.0] - end - let(:remaining_hours) do - [0.0, 0.0, 0.0] - end - - let(:aggregate_estimated_hours) do - 15.0 - end - let(:aggregate_remaining_hours) do - 0.0 - end - let(:aggregate_done_ratio) do - 100 - end + let(:expected_total_remaining_work) do + 2.5 end - end - - context "with all tasks having estimated hours and no tasks having progress set" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, 2.0, 3.0] - end - let(:remaining_hours) do - [nil, nil, nil] - end - - let(:aggregate_estimated_hours) do - 15.0 - end - let(:aggregate_remaining_hours) do - nil - end - let(:aggregate_done_ratio) do - nil - end + let(:expected_total_p_complete) do + 75 end end + end + end - context "with some tasks having estimated hours and none having progress set" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [nil, nil, nil] - end + context "with the parent having work and % complete set " \ + "and some children tasks having work and % complete set" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | 10h | 5h | 50% + child1 | 10h | 2.5h | 75% + child2 | | | + child3 | | | + TABLE - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - nil - end - let(:aggregate_done_ratio) do - nil - end + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + # Parent's work and remaining work are taken into account + let(:expected_total_work) do + 20.0 end - end - - context "with some tasks having estimated hours and those that do also having progress done" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [2.5, nil, nil] - end - - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - 2.5 - end - let(:aggregate_done_ratio) do - 75 - end + let(:expected_total_remaining_work) do + 7.5 + end + let(:expected_total_p_complete) do + 63 end end + end + end - context "with the parent having estimated hours and progress" do - shared_let(:parent) do - create(:work_package, - subject: "parent", - estimated_hours: 10.0, - remaining_hours: 5.0) - end + context "with the parent having work and % complete set " \ + "and no children tasks having work or % complete set" do + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + parent | 10h | 5h | 50% + child1 | | | + child2 | | | + child3 | | | + TABLE - context "and some tasks having estimated hours and some progress" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [10.0, nil, nil] - end - let(:remaining_hours) do - [2.5, nil, nil] - end - - # Parent's estimated and remaining hours are taken into account - let(:aggregate_estimated_hours) do - 20.0 - end - let(:aggregate_remaining_hours) do - 7.5 - end - let(:aggregate_done_ratio) do - 63 - end - end + for_each_context "when work is changed", + "when remaining work is changed" do + it_behaves_like "attributes of parent having children" do + # Parent's work and remaining work become the total values + let(:expected_total_work) do + 10.0 end - - context "and no tasks having estimated hours or progress" do - it_behaves_like "attributes of parent having children" do - let(:estimated_hours) do - [nil, nil, nil] - end - let(:remaining_hours) do - [nil, nil, nil] - end - - # Parent's estimated hours and remaining hours become the aggregated values - let(:aggregate_estimated_hours) do - 10.0 - end - let(:aggregate_remaining_hours) do - 5.0 - end - let(:aggregate_done_ratio) do - 50 - end - end + let(:expected_total_remaining_work) do + 5.0 + end + let(:expected_total_p_complete) do + 50 end end end @@ -597,37 +360,13 @@ def call_update_ancestors_service(work_package) end context "for the previous ancestors" do - shared_let(:sibling_estimated_hours) { 7.0 } - shared_let(:sibling_remaining_hours) { 3.5 } - shared_let(:parent_estimated_hours) { 3.0 } - shared_let(:grandparent_estimated_hours) { 3.0 } - shared_let(:grandparent_remaining_hours) { 1.5 } - - shared_let(:grandparent) do - create(:work_package, - subject: "grandparent", - estimated_hours: grandparent_estimated_hours, - remaining_hours: grandparent_remaining_hours) - end - shared_let(:parent) do - create(:work_package, - subject: "parent", - estimated_hours: parent_estimated_hours, - parent: grandparent) - end - shared_let(:sibling) do - create(:work_package, - subject: "sibling", - parent:, - estimated_hours: sibling_estimated_hours, - remaining_hours: sibling_remaining_hours) - end - - shared_let(:work_package) do - create(:work_package, - subject: "subject - loses its parent", - parent:) - end + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + grandparent | 3h | 1.5h | 50% + parent | 3h | 0h | 100% + work package | | | + sibling | 7h | 3.5h | 50% + TABLE subject do work_package.parent = nil @@ -644,93 +383,25 @@ def call_update_ancestors_service(work_package) .to be_success end + it "updates the totals of the ancestors" do + subject + expect_work_packages([grandparent, parent, sibling, work_package], <<~TABLE) + hierarchy | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | 3h | 1.5h | 50% | 13h | 5h | 62% + parent | 3h | 0h | 100% | 10h | 3.5h | 65% + sibling | 7h | 3.5h | 50% | | | + work package | | | | | | + TABLE + end + it "returns the former ancestors in the dependent results" do expect(subject.dependent_results.map(&:result)) .to contain_exactly(parent, grandparent) end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours of the former parent" do - expect do - subject - parent.reload - end - .to change(parent, :derived_done_ratio).to(65) # 6.5h derived_work_done / 10.0h derived_estimated_hours - .and change(parent, :derived_estimated_hours).to(parent_estimated_hours + sibling_estimated_hours) - .and change(parent, :derived_remaining_hours).to(sibling_remaining_hours) - end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours of the former grandparent" do - expect do - subject - grandparent.reload - end - .to change(grandparent, :derived_done_ratio).to(62) # 8.0h derived_work_done / 13.0h derived_estimated_hours - .and change(grandparent, :derived_estimated_hours).to(grandparent_estimated_hours + - parent_estimated_hours + - sibling_estimated_hours) - .and change(grandparent, :derived_remaining_hours).to(sibling_remaining_hours + - grandparent_remaining_hours) - end end context "for new ancestors" do - shared_let(:estimated_hours) { 7.0 } - shared_let(:remaining_hours) { 3.5 } - shared_let(:parent_estimated_hours) { 3.0 } - shared_let(:parent_remaining_hours) { 1.5 } - - shared_let(:grandparent) do - create(:work_package, - subject: "grandparent") - end - shared_let(:parent) do - create(:work_package, - subject: "parent", - estimated_hours: parent_estimated_hours, - remaining_hours: parent_remaining_hours, - parent: grandparent) - end - shared_let(:work_package) do - create(:work_package, - subject: "subject - gains a new parent and grandparent", - estimated_hours:, - remaining_hours:) - end - - shared_examples_for "updates the attributes within the new hierarchy" do - it "is successful" do - expect(subject) - .to be_success - end - - it "returns the new ancestors in the dependent results" do - expect(subject.dependent_results.map(&:result)) - .to contain_exactly(parent, grandparent) - end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours of the new parent" do - expect do - subject - parent.reload - end - .to change(parent, :derived_done_ratio).to(50) # 5.0h derived_work_done / 10.0h derived_estimated_hours - .and change(parent, :derived_estimated_hours).to(parent_estimated_hours + estimated_hours) - .and change(parent, :derived_remaining_hours).to(parent_remaining_hours + remaining_hours) - end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours " \ - "of the new grandparent" do - expect do - subject - grandparent.reload - end - .to change(grandparent, :derived_done_ratio).to(50) # 5.0h derived_work_done / 10.0h derived_estimated_hours - .and change(grandparent, :derived_estimated_hours).to(parent_estimated_hours + estimated_hours) - .and change(grandparent, :derived_remaining_hours).to(parent_remaining_hours + remaining_hours) - end - end - - context "if setting the parent" do + shared_context "if setting the parent" do subject do work_package.parent = parent work_package.save! @@ -740,11 +411,9 @@ def call_update_ancestors_service(work_package) work_package:) .call(%i(parent)) end - - it_behaves_like "updates the attributes within the new hierarchy" end - context "if setting the parent_id" do + shared_context "if setting the parent_id" do subject do work_package.parent_id = parent.id work_package.save! @@ -754,59 +423,61 @@ def call_update_ancestors_service(work_package) work_package:) .call(%i(parent_id)) end + end + + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete + grandparent | | | + parent | 3h | 1.5h | 50% + work package | 7h | 3.5h | 50% + TABLE + + for_each_context "if setting the parent", + "if setting the parent_id" do + it "is successful" do + expect(subject) + .to be_success + end + + it "returns the new ancestors in the dependent results" do + expect(subject.dependent_results.map(&:result)) + .to contain_exactly(parent, grandparent) + end - it_behaves_like "updates the attributes within the new hierarchy" + it "updates the totals of the ancestors" do + subject + expect_work_packages([grandparent, parent, work_package], <<~TABLE) + hierarchy | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | | | | 10h | 5h | 50% + parent | 3h | 1.5h | 50% | 10h | 5h | 50% + work package | 7h | 3.5h | 50% | | | + TABLE + end end end context "with old and new parent having a common ancestor" do - shared_let(:estimated_hours) { 7.0 } - shared_let(:remaining_hours) { 3.5 } - shared_let(:new_estimated_hours) { 10.0 } - shared_let(:new_remaining_hours) { 2 } - - shared_let(:grandparent) do - create(:work_package, - subject: "common grandparent", - derived_done_ratio: 50, # two children having [done_ratio, 0] - derived_estimated_hours: estimated_hours, - derived_remaining_hours: remaining_hours) - end - shared_let(:old_parent) do - create(:work_package, - subject: "old parent", - parent: grandparent, - derived_done_ratio: 50, - derived_estimated_hours: estimated_hours, - derived_remaining_hours: remaining_hours) - end - shared_let(:new_parent) do - create(:work_package, - subject: "new parent", - parent: grandparent) - end - shared_let(:work_package) do - create(:work_package, - subject: "subject - parent changes from old parent to new parent, same grandparent", - parent: old_parent, - estimated_hours:, - remaining_hours:) - end + # work_package's parent will change from old parent to new parent, same grandparent + shared_let_work_packages(<<~TABLE) + hierarchy | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | | | | 7h | 3.5h | 50% + old parent | | | | 7h | 3.5h | 50% + work package | 7h | 3.5h | 50% | | | + new parent | | | | | | + TABLE subject do - work_package.parent = new_parent # In this test case, done_ratio, derived_estimated_hours, and - # derived_remaining_hours would not inherently change on grandparent. - # However, if work_package has siblings then changing its parent could - # cause done_ratio, derived_estimated_hours, and/or - # derived_remaining_hours on grandparent to inherently change. To verify - # that grandparent can be properly updated in that case without making - # this test dependent on the implementation details of the calculations, - # done_ratio, derived_estimated_hours, and derived_remaining_hours are - # forced to change at the same time as the parent. + # derived_remaining_hours would not inherently change on grandparent + # if work package keeps the same progress values. + # + # To verify that grandparent can be properly updated in this scenario, + # work and remaining work are also changed on the work package to force + # grandparent totals to be updated. set_attributes_on(work_package, - estimated_hours: new_estimated_hours, - remaining_hours: new_remaining_hours) + parent: new_parent, + estimated_hours: 10, + remaining_hours: 2) work_package.save! described_class @@ -820,49 +491,29 @@ def call_update_ancestors_service(work_package) .to be_success end + it "updates the totals of the new parent and the former parent" do + subject + expect_work_packages([grandparent, old_parent, new_parent, work_package], <<~TABLE) + hierarchy | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | | | | 10h | 2h | 80% + old parent | | | | | | + new parent | | | | 10h | 2h | 80% + work package | 10h | 2h | 80% | | | + TABLE + end + it "returns both the former and new ancestors in the dependent results without duplicates" do expect(subject.dependent_results.map(&:result)) .to contain_exactly(new_parent, grandparent, old_parent) end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours " \ - "of the former parent to nil" do - expect do - subject - old_parent.reload - end - .to change(old_parent, :derived_done_ratio).to(nil) - .and change(old_parent, :derived_estimated_hours).to(nil) - .and change(old_parent, :derived_remaining_hours).to(nil) - end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours of the new parent" do - expect do - subject - new_parent.reload - end - .to change(new_parent, :derived_done_ratio).to(80) - .and change(new_parent, :derived_estimated_hours).to(new_estimated_hours) - .and change(new_parent, :derived_remaining_hours).to(new_remaining_hours) - end - - it "updates the derived_done_ratio, derived_estimated_hours, and derived_remaining_hours of the grandparent" do - expect do - subject - grandparent.reload - end - .to change(grandparent, :derived_done_ratio).to(80) - .and change(grandparent, :derived_estimated_hours).to(new_estimated_hours) - .and change(grandparent, :derived_remaining_hours).to(new_remaining_hours) - end end end describe "work propagation" do - shared_let(:parent) { create(:work_package, subject: "parent") } - shared_let(:child) { create(:work_package, subject: "child", parent:) } - context "when setting work of a work package having children without any work value" do + shared_let(:parent) { create(:work_package, subject: "parent") } + shared_let(:child) { create(:work_package, subject: "child", parent:) } + before do parent.estimated_hours = 2.0 end @@ -1050,7 +701,7 @@ def call_update_ancestors_service(work_package) end end - describe "done_ratio propagation" do + describe "% complete propagation" do shared_let(:parent) { create(:work_package, subject: "parent") } context "given child with work, when remaining work being set on parent" do @@ -1096,6 +747,52 @@ def call_update_ancestors_service(work_package) TABLE end end + + context "given child becomes excluded from totals calculation because of its status changed to rejected" do + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | 10h | 12h | 1h | 8h | 90% | 33% + child | Open | 2h | | 7h | | 29% | + TABLE + + subject(:call_result) do + set_attributes_on(child, status: rejected_status) + call_update_ancestors_service(child) + end + + it "computes parent totals excluding the child from calculations accordingly" do + expect(call_result).to be_success + updated_work_packages = call_result.all_results + expect_work_packages(updated_work_packages, <<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | 10h | 10h | 1h | 1h | 90% | 90% + child | Rejected | 2h | | 7h | | 29% | + TABLE + end + end + + context "given child is no longer excluded from totals calculation because of its status changed from rejected" do + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | 10h | 10h | 1h | 1h | 90% | 90% + child | Rejected | 2h | | 7h | | 29% | + TABLE + + subject(:call_result) do + set_attributes_on(child, status: open_status) + call_update_ancestors_service(child) + end + + it "computes parent totals excluding the child from calculations accordingly" do + expect(call_result).to be_success + updated_work_packages = call_result.all_results + expect_work_packages(updated_work_packages, <<~TABLE) + hierarchy | status | work | ∑ work | remaining work | ∑ remaining work | % complete | ∑ % complete + parent | Open | 10h | 12h | 1h | 8h | 90% | 33% + child | Open | 2h | | 7h | | 29% | + TABLE + end + end end describe "ignore_non_working_days propagation" do diff --git a/spec/support/for_each_context.rb b/spec/support/for_each_context.rb new file mode 100644 index 000000000000..78fdee1f77d7 --- /dev/null +++ b/spec/support/for_each_context.rb @@ -0,0 +1,73 @@ +# 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 RSpecOpExt + module ForEachContext + # Runs the same example group multiple times: once for each given named + # context. + # + # For each named context, the context is applied and the tests are run. It + # allows to have multiple shared contexts and only one example group, + # instead of having shared examples and multiple contexts including the + # shared examples. It generally reads better. + # + # @example + # + # RSpec.describe "something" do + # shared_context "early in the morning" do + # let(:time) { "06:30" } + # end + # + # shared_context "late in the evening" do + # let(:time) { "23:00" } + # end + # + # for_each_context "early in the morning", + # "late in the evening" do + # it "has energy" do + # expect(body.energy_level).to eq(100) + # end + # end + # end + def for_each_context(*context_names, &blk) + context_names.each do |context_name| + context context_name do + include_context context_name + + instance_exec(&blk) + end + end + end + end +end + +RSpec.configure do |config| + config.extend RSpecOpExt::ForEachContext +end diff --git a/spec/support/table_helpers.rb b/spec/support/table_helpers.rb index e4597d06e140..7819e5c8fa06 100644 --- a/spec/support/table_helpers.rb +++ b/spec/support/table_helpers.rb @@ -36,6 +36,7 @@ match do |actual_work_packages| expected_data = TableHelpers::TableData.for(expected) actual_data = TableHelpers::TableData.from_work_packages(actual_work_packages, expected_data.columns) + actual_data.order_like!(expected_data) representer = TableHelpers::TableRepresenter.new(tables_data: [expected_data, actual_data], columns: expected_data.columns) diff --git a/spec/support/table_helpers/example_methods.rb b/spec/support/table_helpers/example_methods.rb index 5843b2744ac8..f7ea096f8eb5 100644 --- a/spec/support/table_helpers/example_methods.rb +++ b/spec/support/table_helpers/example_methods.rb @@ -51,7 +51,8 @@ def create_table(table_representation) # Expect the given work packages to match a visual table representation. # - # It uses +match_table+ internally. + # It uses +match_table+ internally and reloads the work packages from + # database before comparing. # # For instance: # @@ -66,6 +67,7 @@ def create_table(table_representation) # is equivalent to: # # it 'is scheduled' do + # work_packages.each(&:reload) # expect(work_packages).to match_table(<<~TABLE) # subject | work | derived work | # parent | 1h | 3h | @@ -73,6 +75,7 @@ def create_table(table_representation) # TABLE # end def expect_work_packages(work_packages, table_representation) + work_packages.each(&:reload) expect(work_packages).to match_table(table_representation) end end diff --git a/spec/support/table_helpers/let_work_packages.rb b/spec/support/table_helpers/let_work_packages.rb index c7982c267a7e..bc62f2cf3da0 100644 --- a/spec/support/table_helpers/let_work_packages.rb +++ b/spec/support/table_helpers/let_work_packages.rb @@ -49,7 +49,7 @@ module LetWorkPackages # let!(:_table) do # create_table(table_representation) # end - # let!(:table_work_packages) do + # let(:table_work_packages) do # _table.work_packages # end # let(:parent) do @@ -70,5 +70,48 @@ def let_work_packages(table_representation) let(identifier) { _table.work_package(identifier) } end end + + # 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: + # + # shared_let_work_packages(<<~TABLE) + # hierarchy | work | + # parent | 1h | + # child | 2.5h | + # another one | | + # TABLE + # + # is equivalent to: + # + # shared_let(:_table) do + # create_table(table_representation) + # end + # shared_let(:table_work_packages) do + # _table.work_packages + # end + # shared_let(:parent) do + # _table.work_package(:parent) + # end + # shared_let(:child) do + # _table.work_package(:child) + # end + # shared_let(:another_one) do + # _table.work_package(:another_one) + # end + def shared_let_work_packages(table_representation) + shared_let(:_table) { create_table(table_representation) } + + table_data = TableData.for(table_representation) + shared_let(:table_work_packages) { _table.work_packages } + table_data.work_package_identifiers.each do |identifier| + shared_let(identifier) { _table.work_package(identifier) } + end + end end end diff --git a/spec/support/table_helpers/table_data.rb b/spec/support/table_helpers/table_data.rb index 0659f89280f3..59186d3463aa 100644 --- a/spec/support/table_helpers/table_data.rb +++ b/spec/support/table_helpers/table_data.rb @@ -83,6 +83,15 @@ def create_work_packages Table.new(work_packages_by_identifier) end + def order_like!(other_table) + ordered_identifiers = other_table.work_package_identifiers + extra_identifiers = work_package_identifiers - ordered_identifiers + @work_packages_data = work_packages_data + .index_by { _1[:identifier] } + .values_at(*(ordered_identifiers + extra_identifiers)) + .compact + end + class Factory attr_reader :table_data, :work_packages_by_identifier diff --git a/spec/support/table_helpers/table_parser.rb b/spec/support/table_helpers/table_parser.rb index 80466f3e45d3..c3faa62614be 100644 --- a/spec/support/table_helpers/table_parser.rb +++ b/spec/support/table_helpers/table_parser.rb @@ -31,14 +31,16 @@ module TableHelpers class TableParser def parse(representation) - headers, *rows = representation.split("\n") - headers = split(headers) - rows = rows.filter_map { |row| parse_row(row, headers) } - work_packages_data = rows.map.with_index do |row, index| + headers, *rows = representation.split("\n").filter_map { |line| split_line_into_cells(line) } + work_packages_data = rows.map.with_index do |cells, index| + if cells.size > headers.size + raise ArgumentError, "Too many cells in row #{index + 1}, have you forgotten some headers?" + end + { attributes: {}, index:, - row: + row: headers.zip(cells).to_h } end headers.each do |header| @@ -50,13 +52,12 @@ def parse(representation) private - def parse_row(row, headers) - case row + def split_line_into_cells(line) + case line when "", /^\s*#/ # noop else - values = split(row) - headers.zip(values).to_h + split(line) end end diff --git a/spec/support_spec/table_helpers/table_data_spec.rb b/spec/support_spec/table_helpers/table_data_spec.rb index 0fbecea1677c..f6ffe8581941 100644 --- a/spec/support_spec/table_helpers/table_data_spec.rb +++ b/spec/support_spec/table_helpers/table_data_spec.rb @@ -145,5 +145,69 @@ module TableHelpers .not_to raise_error end end + + describe "#order_like" do + it "orders the table data like the given table" do + table_representation = <<~TABLE + | subject | remaining work | + | work package | 3h | + | another one | | + TABLE + table_data = described_class.for(table_representation) + + other_table_representation = <<~TABLE + | subject | remaining work | + | another one | | + | work package | 3h | + TABLE + other_table_data = described_class.for(other_table_representation) + expect(table_data.work_package_identifiers).to eq(%i[work_package another_one]) + + table_data.order_like!(other_table_data) + expect(table_data.work_package_identifiers).to eq(%i[another_one work_package]) + end + + it "ignores unknown rows from the given table" do + table_representation = <<~TABLE + | subject | remaining work | + | work package | 3h | + | another one | | + TABLE + table_data = described_class.for(table_representation) + + other_table_representation = <<~TABLE + | subject | remaining work | + | another one | | + | work package | 3h | + | unknown one | | + TABLE + other_table_data = described_class.for(other_table_representation) + expect(table_data.work_package_identifiers).to eq(%i[work_package another_one]) + + table_data.order_like!(other_table_data) + expect(table_data.work_package_identifiers).to eq(%i[another_one work_package]) + end + + it "appends to the bottom the rows missing in the given table" do + table_representation = <<~TABLE + | subject | remaining work | + | work package | 3h | + | extra one | | + | another one | | + TABLE + table_data = described_class.for(table_representation) + + other_table_representation = <<~TABLE + | subject | remaining work | + | another one | | + | work package | 3h | + TABLE + other_table_data = described_class.for(other_table_representation) + expect(table_data.work_package_identifiers).to eq(%i[work_package extra_one another_one]) + + table_data.order_like!(other_table_data) + expect(table_data.work_package_identifiers).to eq(%i[another_one work_package extra_one]) + end + end end end diff --git a/spec/support_spec/table_helpers/table_parser_spec.rb b/spec/support_spec/table_helpers/table_parser_spec.rb index 12d0b34e2f16..2e5358ec5748 100644 --- a/spec/support_spec/table_helpers/table_parser_spec.rb +++ b/spec/support_spec/table_helpers/table_parser_spec.rb @@ -53,6 +53,7 @@ it "ignores comments and empty lines" do table = <<~TABLE + # this comment is ignored | subject | # this comment and the following empty line are ignored @@ -72,6 +73,25 @@ .to raise_error(ArgumentError, 'Please use "remaining work" instead of "remaining hours"') end + it "raises an error if there are more cells than headers in a row" do + table = <<~TABLE + subject | work + wp | 4h | 6h + TABLE + expect { described_class.new.parse(table) } + .to raise_error(ArgumentError, "Too many cells in row 1, have you forgotten some headers?") + end + + it "is ok to have more headers than cells (value of missing cells will be nil)" do + table = <<~TABLE + subject | work | remaining work + wp | 4h + TABLE + parsed_data = described_class.new.parse(table) + expect(parsed_data.dig(0, :attributes, :estimated_hours)).to eq(4.0) + expect(parsed_data.dig(0, :attributes, :remaining_hours)).to be_nil + end + describe "subject column" do let(:table) do <<~TABLE diff --git a/spec/workers/work_packages/progress/apply_statuses_p_complete_job_spec.rb b/spec/workers/work_packages/progress/apply_statuses_change_job_spec.rb similarity index 56% rename from spec/workers/work_packages/progress/apply_statuses_p_complete_job_spec.rb rename to spec/workers/work_packages/progress/apply_statuses_change_job_spec.rb index 185fead8735d..97ad3d2cc0d2 100644 --- a/spec/workers/work_packages/progress/apply_statuses_p_complete_job_spec.rb +++ b/spec/workers/work_packages/progress/apply_statuses_change_job_spec.rb @@ -28,11 +28,23 @@ require "rails_helper" -RSpec.describe WorkPackages::Progress::ApplyStatusesPCompleteJob do +RSpec.describe WorkPackages::Progress::ApplyStatusesChangeJob do shared_let(:author) { create(:user) } shared_let(:priority) { create(:priority, name: "Normal") } shared_let(:project) { create(:project, name: "Main project") } + + # statuses for work-based mode shared_let(:status_new) { create(:status, name: "New") } + shared_let(:status_wip) { create(:status, name: "In progress") } + shared_let(:status_closed) { create(:status, name: "Closed") } + + # statuses for status-based mode + shared_let(:status_0p_todo) { create(:status, name: "To do (0%)", default_done_ratio: 0) } + shared_let(:status_40p_doing) { create(:status, name: "Doing (40%)", default_done_ratio: 40) } + shared_let(:status_100p_done) { create(:status, name: "Done (100%)", default_done_ratio: 100) } + + # statuses for both work-based and status-based modes + shared_let(:status_excluded) { create(:status, :excluded_from_totals, name: "Excluded") } before_all do set_factory_default(:user, author) @@ -42,20 +54,16 @@ set_factory_default(:status, status_new) end - shared_let(:status_0p_todo) { create(:status, name: "To do (0%)", default_done_ratio: 0) } - shared_let(:status_40p_doing) { create(:status, name: "Doing (40%)", default_done_ratio: 40) } - shared_let(:status_100p_done) { create(:status, name: "Done (100%)", default_done_ratio: 100) } - subject(:job) { described_class } def expect_performing_job_changes(from:, to:, - cause_type: "status_p_complete_changed", - status_name: "New", + cause_type: "status_changed", + status_name: "Some status name", status_id: 99, - change: [33, 66]) + changes: { "default_done_ratio" => [33, 66] }) table = create_table(from) - job.perform_now(cause_type:, status_name:, status_id:, change:) + job.perform_now(cause_type:, status_name:, status_id:, changes:) table.work_packages.map(&:reload) expect_work_packages(table.work_packages, to) @@ -85,6 +93,89 @@ def expect_performing_job_changes(from:, to:, ) end end + + context "when a status is being excluded from progress calculation" do + it "computes totals of the parent having work when all children are excluded" do + expect_performing_job_changes( + from: <<~TABLE, + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | In progress | 10h | 3h | 70% | 20h | 5h | 75% + child | Excluded | 10h | 2h | 50% | | | + TABLE + to: <<~TABLE + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | In progress | 10h | 3h | 70% | 10h | 3h | 70% + child | Excluded | 10h | 2h | 50% | | | + TABLE + ) + end + + it "keeps the totals unset if work, remaining work, and % complete are all nil" do + expect_performing_job_changes( + from: <<~TABLE, + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | In progress | | | | | | + child | Excluded | | | | | | + TABLE + to: <<~TABLE + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + parent | In progress | | | | | | + child | Excluded | | | | | | + TABLE + ) + end + + describe "general case" do + # The work packages are created like if the status is not excluded yet + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | New | 1h | 0.6h | 40% | 24h | 11.3h | 53% + parent | Excluded | 4h | 1h | 75% | 23h | 10.7h | 53% + child 1 | Excluded | 9h | 7.2h | 20% | | | + child 2 | In progress | 5h | 2.5h | 50% | | | + child 3 | Closed | 5h | 0h | 100% | | | + TABLE + + before do + job.perform_now( + cause_type: "status_changed", + status_name: status_excluded.name, + status_id: status_excluded.id, + changes: { "excluded_from_totals" => [false, true] } + ) + table_work_packages.map(&:reload) + end + + it "recomputes totals without the values from work packages having the excluded status" do + expect_work_packages(table_work_packages, <<~TABLE) + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | New | 1h | 0.6h | 40% | 11h | 3.1h | 72% + parent | Excluded | 4h | 1h | 75% | 10H | 2.5h | 75% + child 1 | Excluded | 9h | 7.2h | 20% | | | + child 2 | In progress | 5h | 2.5h | 50% | | | + child 3 | Closed | 5h | 0h | 100% | | | + TABLE + end + + it "adds a relevant journal entry for the parent with recomputed total" do + changed_worked_packages = [grandparent, parent] + changed_worked_packages.each do |work_package| + expect(work_package.journals.count).to eq(2), "expected #{work_package} to have a new journal" + last_journal = work_package.last_journal + expect(last_journal.user).to eq(User.system) + expect(last_journal.cause_type).to eq("status_changed") + expect(last_journal.cause_status_name).to eq("Excluded") + expect(last_journal.cause_status_id).to eq(status_excluded.id) + expect(last_journal.cause_status_changes).to eq({ "excluded_from_totals" => [false, true] }) + end + + unchanged_work_packages = table_work_packages - changed_worked_packages + unchanged_work_packages.each do |work_package| + expect(work_package.journals.count).to eq(1), "expected #{work_package} not to have new journals" + end + end + end + end end context "when in status-based mode", @@ -155,6 +246,57 @@ def expect_performing_job_changes(from:, to:, end end + context "when a status is being excluded from progress calculation" do + # The work packages are created like if the status is not excluded yet + shared_let_work_packages(<<~TABLE) + hierarchy | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | Doing (40%) | 1h | 0.6h | 40% | 24h | 16.6h | 31% + parent | Excluded | 4h | 4h | 0% | 23h | 16h | 30% + child 1 | Excluded | 9h | 9h | 0% | | | + child 2 | Doing (40%) | 5h | 3h | 40% | | | + child 3 | Done (100%) | 5h | 0h | 100% | | | + TABLE + + before do + job.perform_now( + cause_type: "status_changed", + status_name: status_excluded.name, + status_id: status_excluded.id, + changes: { "excluded_from_totals" => [false, true] } + ) + table_work_packages.map(&:reload) + end + + it "recomputes totals without the values from work packages having the excluded status" do + expect_work_packages(table_work_packages, <<~TABLE) + subject | status | work | remaining work | % complete | ∑ work | ∑ remaining work | ∑ % complete + grandparent | Doing (40%) | 1h | 0.6h | 40% | 11h | 3.6h | 67% + parent | Excluded | 4h | 4h | 0% | 10h | 3h | 70% + child 1 | Excluded | 9h | 9h | 0% | | | + child 2 | Doing (40%) | 5h | 3h | 40% | | | + child 3 | Done (100%) | 5h | 0h | 100% | | | + TABLE + end + + it "adds a relevant journal entry for the parent with recomputed total" do + changed_worked_packages = [grandparent, parent] + changed_worked_packages.each do |work_package| + expect(work_package.journals.count).to eq(2), "expected #{work_package} to have a new journal" + last_journal = work_package.last_journal + expect(last_journal.user).to eq(User.system) + expect(last_journal.cause_type).to eq("status_changed") + expect(last_journal.cause_status_name).to eq("Excluded") + expect(last_journal.cause_status_id).to eq(status_excluded.id) + expect(last_journal.cause_status_changes).to eq({ "excluded_from_totals" => [false, true] }) + end + + unchanged_work_packages = table_work_packages - changed_worked_packages + unchanged_work_packages.each do |work_package| + expect(work_package.journals.count).to eq(1), "expected #{work_package} not to have new journals" + end + end + end + describe "journals" do # rubocop:disable RSpec/ExampleLength it "creates journal entries for modified work packages on status % complete change" do @@ -171,19 +313,19 @@ def expect_performing_job_changes(from:, to:, child 1 | Doing (40%) | 10h | 6h | 40% | | | child 2 | Done (100%) | 10h | 0h | 100% | | | TABLE - cause_type: "status_p_complete_changed", + cause_type: "status_changed", status_name: status_40p_doing.name, status_id: status_40p_doing.id, - change: [20, 40] + changes: { "default_done_ratio" => [20, 40] } ) [parent, child1].each do |work_package| expect(work_package.journals.count).to eq 2 last_journal = work_package.last_journal expect(last_journal.user).to eq(User.system) - expect(last_journal.cause_type).to eq("status_p_complete_changed") + expect(last_journal.cause_type).to eq("status_changed") expect(last_journal.cause_status_name).to eq("Doing (40%)") expect(last_journal.cause_status_id).to eq(status_40p_doing.id) - expect(last_journal.cause_status_p_complete_change).to eq([20, 40]) + expect(last_journal.cause_status_changes).to eq({ "default_done_ratio" => [20, 40] }) end # unchanged => no new journals @@ -241,10 +383,10 @@ def expect_performing_job_changes(from:, to:, .and_return(nil) begin - job.perform_now(cause_type: "status_p_complete_changed", + job.perform_now(cause_type: "status_changed", status_name: "New", status_id: 99, - change: [33, 66]) + changes: { "default_done_ratio" => [33, 66] }) rescue StandardError end end