diff --git a/.github/workflows/crowdin.yml b/.github/workflows/crowdin.yml index 205c58e518d1..ade7339c7a73 100644 --- a/.github/workflows/crowdin.yml +++ b/.github/workflows/crowdin.yml @@ -78,6 +78,9 @@ jobs: env: OPENPROJECT_CROWDIN_PROJECT: ${{ secrets.OPENPROJECT_CROWDINV2_PROJECT }} OPENPROJECT_CROWDIN_API_KEY: ${{ secrets.OPENPROJECT_CROWDINV2_API_KEY }} + - name: "Fix root key in Portuguese crowdin translation files" + run: | + script/i18n/fix_crowdin_pt_language_root_key - name: "Commit translations" run: | git config user.name "OpenProject Actions CI" diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 7a014e6b9d54..7ffa1dd391be 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -21,7 +21,41 @@ env: REGISTRY_IMAGE: openproject/community jobs: + extract_version: + runs-on: ubuntu-latest + steps: + - name: Extract version + id: extract_version + run: | + if [[ ${{ github.event_name }} == 'push' ]]; then + TAG_REF=${GITHUB_REF#refs/tags/} + CHECKOUT_REF=$GITHUB_REF + elif [[ ${{ github.event_name }} == 'schedule' ]]; then + TAG_REF=dev + CHECKOUT_REF=refs/heads/dev + elif [[ ${{ github.event_name }} == 'workflow_dispatch' ]]; then + TAG_REF=${{ inputs.tag }} + CHECKOUT_REF=${{ inputs.tag }} + else + echo "Unsupported event" + exit 1 + fi + + if [ -z "$TAG_REF" ] || [ -z "$CHECKOUT_REF" ]; then + echo "No TAG_REF or CHECKOUT_REF set. Aborting" + exit 1 + fi + + VERSION=${TAG_REF#v} + echo "Version: $VERSION" + echo "version=$VERSION" >> "$GITHUB_OUTPUT" + echo "checkout_ref=$CHECKOUT_REF" >> "$GITHUB_OUTPUT" + outputs: + version: ${{ steps.extract_version.outputs.version }} + checkout_ref: ${{ steps.extract_version.outputs.checkout_ref }} build: + needs: + - extract_version if: github.repository == 'opf/openproject' runs-on: runs-on,runner=8cpu-linux,family=m7i+m7a,run-id=${{ github.run_id }} strategy: @@ -61,7 +95,7 @@ jobs: echo "No TAG_REF or CHECKOUT_REF set. Aborting" exit 1 fi - + VERSION=${TAG_REF#v} echo "Version: $VERSION" echo "::set-output name=version::$VERSION" @@ -87,8 +121,13 @@ jobs: id: meta uses: docker/metadata-action@v4 with: + context: git + labels: | + io.artifacthub.package.readme-url=https://www.openproject.org/docs/installation-and-operations/installation/docker/ + org.opencontainers.image.documentation=https://www.openproject.org/docs/ + org.opencontainers.image.vendor=OpenProject GmbH tags: | - type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} images: | ${{ env.REGISTRY_IMAGE }} - name: Build image @@ -150,6 +189,7 @@ jobs: matrix: target: [slim, all-in-one] needs: + - extract_version - build steps: - name: Download digests @@ -171,16 +211,16 @@ jobs: with: images: ${{ env.REGISTRY_IMAGE }} labels: | - io.artifacthub.package.readme-url="https://www.openproject.org/docs/installation-and-operations/installation/docker/" - org.opencontainers.image.documentation="https://www.openproject.org/docs/" - org.opencontainers.image.vendor="OpenProject GmbH" + io.artifacthub.package.readme-url=https://www.openproject.org/docs/installation-and-operations/installation/docker/ + org.opencontainers.image.documentation=https://www.openproject.org/docs/ + org.opencontainers.image.vendor=OpenProject GmbH flavor: | latest=false suffix=${{ steps.set_suffix.outputs.suffix }} tags: | - type=semver,pattern={{version}},value=${{ steps.extract_version.outputs.version }} - type=semver,pattern={{major}}.{{minor}},value=${{ steps.extract_version.outputs.version }} - type=semver,pattern={{major}},value=${{ steps.extract_version.outputs.version }} + type=semver,pattern={{version}},value=${{ needs.extract_version.outputs.version }} + type=semver,pattern={{major}}.{{minor}},value=${{ needs.extract_version.outputs.version }} + type=semver,pattern={{major}},value=${{ needs.extract_version.outputs.version }} type=raw,value=dev,priority=200,enable={{is_default_branch}} - name: Login to Docker Hub uses: docker/login-action@v2 diff --git a/.github/workflows/pullpreview.yml b/.github/workflows/pullpreview.yml index b60c278a68eb..dc1c49054027 100644 --- a/.github/workflows/pullpreview.yml +++ b/.github/workflows/pullpreview.yml @@ -45,7 +45,7 @@ jobs: with: admins: crohr,HDinger,machisuji,oliverguenther,ulferts,wielinde,cbliard compose_files: docker-compose.pullpreview.yml - instance_type: large + instance_type: xlarge ports: 80,443,8080 default_port: 443 ttl: 10d diff --git a/Gemfile.lock b/Gemfile.lock index a3a96368eeb2..9b299f0d7cef 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -785,7 +785,7 @@ GEM openssl-signature_algorithm (1.3.0) openssl (> 2.0) os (1.1.4) - ox (2.14.17) + ox (2.14.18) paper_trail (15.1.0) activerecord (>= 6.1) request_store (~> 1.4) @@ -993,7 +993,7 @@ GEM rubocop-performance (1.20.2) rubocop (>= 1.48.1, < 2.0) rubocop-ast (>= 1.30.0, < 2.0) - rubocop-rails (2.24.0) + rubocop-rails (2.24.1) activesupport (>= 4.2.0) rack (>= 1.1) rubocop (>= 1.33.0, < 2.0) diff --git a/app/components/_index.sass b/app/components/_index.sass index 1c262068b87b..28d1d6ec9cb8 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -2,3 +2,4 @@ @import "work_packages/share/invite_user_form_component" @import "open_project/common/attribute_component" @import "filters_component" +@import "projects/settings/project_custom_field_sections/index_component" diff --git a/app/components/open_project/common/attribute_component.rb b/app/components/open_project/common/attribute_component.rb index e77a2f9892b5..1182707b70a7 100644 --- a/app/components/open_project/common/attribute_component.rb +++ b/app/components/open_project/common/attribute_component.rb @@ -25,7 +25,7 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -require 'nokogiri' +require "nokogiri" module OpenProject module Common @@ -34,7 +34,7 @@ class AttributeComponent < Primer::Component :name, :description - PARAGRAPH_CSS_CLASS = 'op-uc-p'.freeze + PARAGRAPH_CSS_CLASS = "op-uc-p".freeze def initialize(id, name, description, **args) super @@ -73,7 +73,7 @@ def first_paragraph .inner_html .html_safe # rubocop:disable Rails/OutputSafety else - '' + "" end end @@ -83,12 +83,12 @@ def text_ast def body_children text_ast - .xpath('html/body') + .xpath("html/body") .children end def multi_type? - first_paragraph.include?('figure') || first_paragraph.include?('macro') + first_paragraph.include?("figure") || first_paragraph.include?("macro") end end end diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..8e538fb20a36 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,50 @@ +<%= + component_wrapper do + flex_layout(align_items: :center, justify_content: :space_between, classes: 'op-project-custom-field', data: { + qa_selector: "project-custom-field-#{@project_custom_field.id}" + }) do |custom_field_container| + custom_field_container.with_column(flex_layout: true) do |title_container| + title_container.with_column(pt: 1, mr: 2) do + render(Primer::Beta::Text.new) do + @project_custom_field.name + end + end + title_container.with_column(pt: 1, mr: 2, data: { qa_selector: "custom-field-type" } ) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize + end + end + if @project_custom_field.required? + title_container.with_column(pt: 1) do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") + end + end + end + end + # py: 1 quick fix: prevents the row from bouncing as the toggle switch currently changes height while toggling + custom_field_container.with_column(py: 1, mr: 2) do + # buggy currently: + # small variant + status_label_position: :start leads to a small bounce while toggling + # behavior can be seen on primer's viewbook as well -> https://view-components-storybook.eastus.cloudapp.azure.com/view-components/lookbook/inspect/primer/alpha/toggle_switch/small + # quick fix: don't display loading indicator which is causing the bounce + render(Primer::Alpha::ToggleSwitch.new( + src: toggle_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_id: @project_custom_field.id + } + ), + csrf_token: form_authenticity_token, + data: { 'turbo-method': :put, 'turbo-stream': true, + qa_selector: "toggle-project-custom-field-mapping-#{@project_custom_field.id}" }, + checked: active_in_project?, + enabled: !@project_custom_field.required?, # required fields cannot be disabled + size: :small, + status_label_position: :start, + classes: "op-primer-adjustments__toggle-switch--hidden-loading-indicator", + )) + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..3cb77666b6db --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,59 @@ +#-- 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 Projects + module Settings + module ProjectCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field:) + super + + @project = project + @project_custom_field = project_custom_field + @project_custom_field_project_mappings = project.project_custom_field_project_mappings + end + + private + + def wrapper_uniq_by + @project_custom_field.id + end + + def active_in_project? + @project_custom_field_project_mappings.any? do |mapping| + mapping.custom_field_id == @project_custom_field.id + end + end + end + end + end +end 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 new file mode 100644 index 000000000000..5da79313acf2 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.html.erb @@ -0,0 +1,33 @@ +<%= + 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" + } + )) + end + + @project_custom_field_sections.each do |project_custom_field_section| + flex.with_row do + render(Projects::Settings::ProjectCustomFieldSections::ShowComponent.new( + project: @project, + project_custom_field_section: + )) + end + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.rb b/app/components/projects/settings/project_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..38f061e9a5f3 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.rb @@ -0,0 +1,55 @@ +#-- 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 Projects + module Settings + module ProjectCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field_sections:) + super + + @project = project + @project_custom_field_sections = project_custom_field_sections + end + + private + + def wrapper_data_attributes + { + controller: 'projects--settings--project-custom-fields-mapping-filter', + 'application-target': 'dynamic' + } + end + end + end + end +end diff --git a/app/components/projects/settings/project_custom_field_sections/index_component.sass b/app/components/projects/settings/project_custom_field_sections/index_component.sass new file mode 100644 index 000000000000..9380cd6b3874 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/index_component.sass @@ -0,0 +1,3 @@ +.Box + .hidden-by-filter + display: none 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 new file mode 100644 index 000000000000..58f145a2f550 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/show_component.html.erb @@ -0,0 +1,73 @@ +<%= + component_wrapper do + render(Primer::Beta::BorderBox.new(mt: 3, classes: 'op-project-custom-field-section', data: { + qa_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + })) do |component| + component.with_header(font_weight: :bold, py: 2) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(py: 2) do |content_container| + # adding py: 2 here to match the padding of the actions_container + # otherwise the header height changes when the actions gets hidden when filtering + render(Primer::Beta::Text.new(font_weight: :bold)) do + @project_custom_field_section.name + end + end + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: enable_all_of_section_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_section_id: @project_custom_field_section.id + } + ), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.project_custom_fields.actions.label_enable_all'), + data: { 'turbo-method': :put, 'turbo-stream': true, qa_selector: "enable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } + )) do |button| + button.with_leading_visual_icon(icon: 'check-circle', color: :subtle) + t('projects.settings.project_custom_fields.actions.label_enable_all') + end + end + actions_container.with_column(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'bulkActionContainer' }) do + render(Primer::Beta::Button.new( + tag: :a, + href: disable_all_of_section_project_settings_project_custom_fields_path( + project_custom_field_project_mapping: { + project_id: @project.id, + custom_field_section_id: @project_custom_field_section.id + } + ), + scheme: :invisible, + font_weight: :bold, + color: :subtle, + 'aria-label': t('projects.settings.project_custom_fields.actions.label_disable_all'), + data: { 'turbo-method': :put, 'turbo-stream': true, qa_selector: "disable-all-project-custom-field-mappings-#{@project_custom_field_section.id}" } + )) do |button| + button.with_leading_visual_icon(icon: 'x-circle', color: :subtle) + t('projects.settings.project_custom_fields.actions.label_disable_all') + end + end + end + end + end + if @project_custom_fields.empty? + component.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + end + else + @project_custom_fields.each do |project_custom_field| + component.with_row(data: { 'projects--settings--project-custom-fields-mapping-filter-target': 'searchItem' }) do + render(Projects::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( + project: @project, + project_custom_field:, + )) + end + end + end + end + end +%> diff --git a/app/components/projects/settings/project_custom_field_sections/show_component.rb b/app/components/projects/settings/project_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..df2b2c634ae7 --- /dev/null +++ b/app/components/projects/settings/project_custom_field_sections/show_component.rb @@ -0,0 +1,53 @@ +#-- 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 Projects + module Settings + module ProjectCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field_section:) + super + + @project = project + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_field_section.custom_fields + end + + private + + def wrapper_uniq_by + @project_custom_field_section.id + end + end + end + end +end diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb new file mode 100644 index 000000000000..bba637366eb8 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.html.erb @@ -0,0 +1,48 @@ +<%= + component_wrapper(class: "op-project-custom-field-container", data: { qa_selector: "project-custom-field-container-#{@project_custom_field.id}" }) do + flex_layout(justify_content: :space_between, align_items: :center) do |main_container| + main_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: 'handle')) + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Link.new( + href: edit_admin_settings_project_custom_field_path(@project_custom_field), + scheme: :primary, + underline: false, + font_weight: :bold, + data: { turbo: "false" } + )) do + @project_custom_field.name + end + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :small, color: :subtle)) do + @project_custom_field.field_format.capitalize + end + end + content_container.with_column(mr: 2) do + render(Primer::Beta::Text.new(font_size: :small)) do + t("project.count", + count: @project_custom_field.project_custom_field_project_mappings.size) + end + end + if @project_custom_field.required? + content_container.with_column do + render(Primer::Beta::Label.new(scheme: :attention, size: :medium)) do + t("label_required") + end + end + end + end + main_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { qa_selector: "project-custom-field-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_project_custom_field_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + delete_action_item(menu) + end + end + end + end +%> diff --git a/app/components/settings/project_custom_field_sections/custom_field_row_component.rb b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb new file mode 100644 index 000000000000..765ec50dc0db --- /dev/null +++ b/app/components/settings/project_custom_field_sections/custom_field_row_component.rb @@ -0,0 +1,108 @@ +#-- 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 ProjectCustomFieldSections + class CustomFieldRowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field:, first_and_last:) + super + + @project_custom_field = project_custom_field + @first_and_last = first_and_last + end + + private + + def edit_action_item(menu) + menu.with_item(label: t("label_edit"), + href: edit_admin_settings_project_custom_field_path(@project_custom_field), + data: { turbo: "false", qa_selector: "project-custom-field-edit" }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), + "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), + "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), + "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_project_custom_field_path(@project_custom_field, move_to:), + form_arguments: { + method: :put, data: { 'turbo-stream': true, qa_selector: "project-custom-field-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_project_custom_field_path(@project_custom_field), + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true, + qa_selector: "project-custom-field-delete" } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @project_custom_field + else + @project_custom_field.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @project_custom_field + else + @project_custom_field.last? + end + end + end + end +end diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb new file mode 100644 index 000000000000..49626ea97d57 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.html.erb @@ -0,0 +1,27 @@ +<%= + component_wrapper do + primer_form_with(**form_config) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new) do + flex_layout(my: 3) do |modal_body| + modal_body.with_row do + render(ProjectCustomFieldSections::NameForm.new(f)) + end + end + end + + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |modal_footer| + modal_footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}" })) do + t("button_cancel") + end + + modal_footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end + end + end + end + end +%> diff --git a/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb new file mode 100644 index 000000000000..1e4243574b46 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/dialog_body_form_component.rb @@ -0,0 +1,57 @@ +#-- 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 ProjectCustomFieldSections + class DialogBodyFormComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field_section: ProjectCustomFieldSection.new) + super + + @project_custom_field_section = project_custom_field_section + end + + private + + def wrapper_uniq_by + @project_custom_field_section.id + end + + def form_config + { + model: @project_custom_field_section, + method: @project_custom_field_section.persisted? ? :put : :post, + url: @project_custom_field_section.persisted? ? admin_settings_project_custom_field_section_path(@project_custom_field_section) : admin_settings_project_custom_field_sections_path + } + end + end + end +end diff --git a/app/components/settings/project_custom_field_sections/index_component.html.erb b/app/components/settings/project_custom_field_sections/index_component.html.erb new file mode 100644 index 000000000000..2d26ddc97e84 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/index_component.html.erb @@ -0,0 +1,15 @@ +<%= + component_wrapper(data: wrapper_data_attributes) do + if @project_custom_field_sections.any? + flex_layout(classes: 'dragula-container', data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do |flex| + @project_custom_field_sections.each do |section| + flex.with_row( + data: draggable_item_config(section) + ) do + render(Settings::ProjectCustomFieldSections::ShowComponent.new(project_custom_field_section: section, first_and_last:)) + end + end + end + end + end +%> diff --git a/app/components/settings/project_custom_field_sections/index_component.rb b/app/components/settings/project_custom_field_sections/index_component.rb new file mode 100644 index 000000000000..7f4d7bdff1a4 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/index_component.rb @@ -0,0 +1,71 @@ +#-- 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 ProjectCustomFieldSections + class IndexComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field_sections:) + super + + @project_custom_field_sections = project_custom_field_sections + end + + def first_and_last + [@project_custom_field_sections.first, @project_custom_field_sections.last] + end + + private + + def wrapper_data_attributes + { + controller: 'generic-drag-and-drop', + 'application-target': 'dynamic' + } + end + + def drop_target_config + { + 'is-drag-and-drop-target': true, + 'target-allowed-drag-type': 'section' # the type of dragged items which are allowed to be dropped in this target + } + end + + def draggable_item_config(section) + { + 'draggable-id': section.id, + 'draggable-type': 'section', + 'drop-url': drop_admin_settings_project_custom_field_section_path(section) + } + end + end + end +end diff --git a/app/components/settings/project_custom_field_sections/show_component.html.erb b/app/components/settings/project_custom_field_sections/show_component.html.erb new file mode 100644 index 000000000000..ea991c887388 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/show_component.html.erb @@ -0,0 +1,78 @@ +<%= + component_wrapper(class: "op-project-custom-field-section-container", data: { qa_selector: "project-custom-field-section-container-#{@project_custom_field_section.id}" }) do + render(Primer::Beta::BorderBox.new(mt: 3, data: drag_and_drop_target_config)) do |component| + component.with_header(font_weight: :bold) do + flex_layout(justify_content: :space_between, align_items: :center) do |section_header_container| + section_header_container.with_column(flex_layout: true, align_items: :center) do |content_container| + content_container.with_column(mr: 2) do + render(Primer::OpenProject::DragHandle.new(classes: 'handle')) + end + content_container.with_column do + render(Primer::Beta::Text.new(font_weight: :bold)) do + @project_custom_field_section.name + end + end + end + section_header_container.with_column(flex_layout: true, justify_content: :flex_end) do |actions_container| + actions_container.with_column do + render(Primer::Alpha::ActionMenu.new(data: { qa_selector: "project-custom-field-section-action-menu" })) do |menu| + menu.with_show_button(icon: "kebab-horizontal", 'aria-label': t("settings.project_attributes.label_section_actions"), scheme: :invisible) + edit_action_item(menu) + move_actions(menu) + if @project_custom_fields.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + end + end + actions_container.with_column do + render(Primer::Alpha::Dialog.new( + id: "project-custom-field-section-dialog#{@project_custom_field_section.id}", title: t('settings.project_attributes.label_new_section'), + size: :medium_portrait + )) do |dialog| + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new(project_custom_field_section: @project_custom_field_section)) + end + end + end + end + end + if @project_custom_fields.empty? + component.with_row(data: { 'empty-list-item': true }) do + flex_layout(align_items: :center, justify_content: :space_between) do |empty_list_container| + empty_list_container.with_column(ml: 4, mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t("settings.project_attributes.label_no_project_custom_fields") } + end + empty_list_container.with_column do + render(Primer::Beta::Button.new( + tag: :a, + href: new_admin_settings_project_custom_field_path( + type: "ProjectCustomField", custom_field_section_id: @project_custom_field_section.id + ), + scheme: :secondary, + data: { turbo: "false", qa_selector: "new-project-custom-field-button" } + )) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_attribute') + end + end + end + end + else + first_and_last = [@project_custom_fields.first, @project_custom_fields.last] + @project_custom_fields.each do |project_custom_field| + component.with_row( + data: draggable_item_config(project_custom_field) + ) do + render( + ::Settings::ProjectCustomFieldSections::CustomFieldRowComponent.new( + project_custom_field:, + first_and_last: + ) + ) + end + end + end + end + end +%> diff --git a/app/components/settings/project_custom_field_sections/show_component.rb b/app/components/settings/project_custom_field_sections/show_component.rb new file mode 100644 index 000000000000..76fa3c16a738 --- /dev/null +++ b/app/components/settings/project_custom_field_sections/show_component.rb @@ -0,0 +1,141 @@ +#-- 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 ProjectCustomFieldSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project_custom_field_section:, first_and_last: []) + super + + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_field_section.custom_fields + @first_and_last = first_and_last + end + + private + + def wrapper_uniq_by + @project_custom_field_section.id + end + + def drag_and_drop_target_config + { + 'is-drag-and-drop-target': true, + 'target-container-accessor': '.Box > ul', # the accessor of the container that contains the drag and drop items + 'target-id': @project_custom_field_section.id, # the id of the target + 'target-allowed-drag-type': 'custom-field' # the type of dragged items which are allowed to be dropped in this target + } + end + + def draggable_item_config(project_custom_field) + { + 'draggable-id': project_custom_field.id, + 'draggable-type': 'custom-field', + 'drop-url': drop_admin_settings_project_custom_field_path(project_custom_field) + } + end + + def move_actions(menu) + unless first? + move_action_item(menu, :highest, t("label_agenda_item_move_to_top"), + "move-to-top") + move_action_item(menu, :higher, t("label_agenda_item_move_up"), "chevron-up") + end + unless last? + move_action_item(menu, :lower, t("label_agenda_item_move_down"), + "chevron-down") + move_action_item(menu, :lowest, t("label_agenda_item_move_to_bottom"), + "move-to-bottom") + end + end + + def move_action_item(menu, move_to, label_text, icon) + menu.with_item(label: label_text, + href: move_admin_settings_project_custom_field_section_path(@project_custom_field_section, move_to:), + form_arguments: { + method: :put, data: { 'turbo-stream': true, qa_selector: "project-custom-field-section-move-#{move_to}" } + }) do |item| + item.with_leading_visual_icon(icon:) + end + end + + def disabled_delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + disabled: true) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def edit_action_item(menu) + menu.with_item(label: t("settings.project_attributes.label_edit_section"), + tag: :button, + content_arguments: { + 'data-show-dialog-id': "project-custom-field-section-dialog#{@project_custom_field_section.id}", + 'data-qa-selector': "project-custom-field-section-edit" + }, + value: "") do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: admin_settings_project_custom_field_section_path(@project_custom_field_section), + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), 'turbo-stream': true, + qa_selector: "project-custom-field-section-delete" } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @project_custom_field_section + else + @project_custom_field_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @project_custom_field_section + else + @project_custom_field_section.last? + end + end + end + end +end diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.html.erb b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb new file mode 100644 index 000000000000..4009432e3974 --- /dev/null +++ b/app/components/settings/project_custom_fields/edit_form_header_component.html.erb @@ -0,0 +1,38 @@ +<%#-- 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. + +++#%> +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title(variant: :medium) { @custom_field.name } + header.with_description { t('settings.project_attributes.edit.description') } + header.with_parent_link(href: admin_settings_project_custom_fields_path, 'aria-label': I18n.t("button_back")) do + t('settings.project_attributes.heading') + end + header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) + end +%> \ No newline at end of file diff --git a/app/components/settings/project_custom_fields/edit_form_header_component.rb b/app/components/settings/project_custom_fields/edit_form_header_component.rb new file mode 100644 index 000000000000..932bd6d5c121 --- /dev/null +++ b/app/components/settings/project_custom_fields/edit_form_header_component.rb @@ -0,0 +1,39 @@ +#-- 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 + class EditFormHeaderComponent < ApplicationComponent + def initialize(custom_field:) + super + + @custom_field = custom_field + end + end + end +end diff --git a/app/components/settings/project_custom_fields/header_component.html.erb b/app/components/settings/project_custom_fields/header_component.html.erb new file mode 100644 index 000000000000..0b1d0169aa7c --- /dev/null +++ b/app/components/settings/project_custom_fields/header_component.html.erb @@ -0,0 +1,36 @@ + +<%= + 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_actions do + flex_layout(justify_content: :space_between, align_items: :center) do |action_buttons_container| + action_buttons_container.with_column(mr: 2) do + render(Primer::Beta::Button.new( + tag: :a, + href: new_admin_settings_project_custom_field_path(type: "ProjectCustomField"), + scheme: :primary, + data: { turbo: "false", qa_selector: "new-project-custom-field-button" } + )) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_attribute') + end + end + action_buttons_container.with_column 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'), scheme: :default) do |button| + button.with_leading_visual_icon(icon: :plus) + t('settings.project_attributes.label_new_section') + end + render(Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new()) + end + end + end + end + end + end +%> diff --git a/app/components/settings/project_custom_fields/header_component.rb b/app/components/settings/project_custom_fields/header_component.rb new file mode 100644 index 000000000000..5970978e5329 --- /dev/null +++ b/app/components/settings/project_custom_fields/header_component.rb @@ -0,0 +1,37 @@ +#-- 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 + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + end + end +end diff --git a/app/components/settings/project_custom_fields/new_form_header_component.html.erb b/app/components/settings/project_custom_fields/new_form_header_component.html.erb new file mode 100644 index 000000000000..1913a74db2a0 --- /dev/null +++ b/app/components/settings/project_custom_fields/new_form_header_component.html.erb @@ -0,0 +1,39 @@ +<%#-- 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. + +++#%> + +<%= + render(Primer::OpenProject::PageHeader.new) do |header| + header.with_title(variant: :medium) { t('settings.project_attributes.new.heading') } + header.with_description { t('settings.project_attributes.new.description') } + header.with_parent_link(href: admin_settings_project_custom_fields_path, 'aria-label': I18n.t("button_back")) do + t('settings.project_attributes.heading') + end + header.with_back_button(href: admin_settings_project_custom_fields_path, 'aria-label': t('button_back')) + end +%> diff --git a/app/components/settings/project_custom_fields/new_form_header_component.rb b/app/components/settings/project_custom_fields/new_form_header_component.rb new file mode 100644 index 000000000000..8c9f07076f8e --- /dev/null +++ b/app/components/settings/project_custom_fields/new_form_header_component.rb @@ -0,0 +1,34 @@ +#-- 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 + class NewFormHeaderComponent < ApplicationComponent + end + end +end diff --git a/app/contracts/base_contract.rb b/app/contracts/base_contract.rb index c57c66d1a60d..9402f3131818 100644 --- a/app/contracts/base_contract.rb +++ b/app/contracts/base_contract.rb @@ -222,12 +222,27 @@ def collect_writable_attributes end if model.respond_to?(:available_custom_fields) - writable += model.available_custom_fields.map(&:attribute_name) + writable += collect_available_custom_field_attributes end writable end + def collect_available_custom_field_attributes + if model.is_a?(Project) + # required because project custom fields are now activated on a per-project basis + # + # if we wouldn't query available_custom field on a global level here, + # implicitly enabling project custom fields through this contract would fail + # as the disabled custom fields would be treated as not-writable + # + # relevant especially for the project API + model.available_custom_fields(global: true).map(&:attribute_name) + else + model.available_custom_fields.map(&:attribute_name) + end + end + def reduce_writable_attributes(attributes) attributes = reduce_by_writable_conditions(attributes) reduce_by_writable_permissions(attributes) diff --git a/app/contracts/custom_fields/base_contract.rb b/app/contracts/custom_fields/base_contract.rb index faea4941b9c1..9cb68cc1bcb1 100644 --- a/app/contracts/custom_fields/base_contract.rb +++ b/app/contracts/custom_fields/base_contract.rb @@ -47,6 +47,7 @@ class BaseContract < ::ModelContract attribute :possible_values attribute :multi_value attribute :content_right_to_left + attribute :custom_field_section_id attribute :allow_non_open_versions end end diff --git a/app/contracts/project_custom_field_project_mappings/base_contract.rb b/app/contracts/project_custom_field_project_mappings/base_contract.rb new file mode 100644 index 000000000000..106abf73b07f --- /dev/null +++ b/app/contracts/project_custom_field_project_mappings/base_contract.rb @@ -0,0 +1,60 @@ +#-- 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 BaseContract < ::ModelContract + attribute :project_id + attribute :custom_field_id + + validate :select_project_custom_fields_permission + validate :not_required + validate :visbile_to_user + + def select_project_custom_fields_permission + return if user.allowed_in_project?(:select_project_custom_fields, model.project) + + errors.add :base, :error_unauthorized + end + + def not_required + # only mappings of custom fields which are not required can be manipulated by the user + # enabling a custom field which is required happens in an after_save hook within the custom field model itself + return if model.project_custom_field.nil? || !model.project_custom_field.required? + + errors.add :custom_field_id, :invalid + end + + def visbile_to_user + # "invisible" custom fields can only be seen and edited by admins + # using visible scope to check if the custom field is actually visible to the user + return if model.project_custom_field.nil? || ProjectCustomField.visible.pluck(:id).include?(model.project_custom_field.id) + + errors.add :custom_field_id, :invalid + end + end +end diff --git a/app/contracts/project_custom_field_project_mappings/update_contract.rb b/app/contracts/project_custom_field_project_mappings/update_contract.rb new file mode 100644 index 000000000000..66140ecdf988 --- /dev/null +++ b/app/contracts/project_custom_field_project_mappings/update_contract.rb @@ -0,0 +1,32 @@ +#-- 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 UpdateContract < BaseContract + end +end diff --git a/app/contracts/project_custom_field_sections/base_contract.rb b/app/contracts/project_custom_field_sections/base_contract.rb new file mode 100644 index 000000000000..951c64d1e39e --- /dev/null +++ b/app/contracts/project_custom_field_sections/base_contract.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. +#++ + +module ProjectCustomFieldSections + class BaseContract < ::ModelContract + include RequiresAdminGuard + + attribute :name + attribute :position + attribute :type + end +end diff --git a/app/contracts/project_custom_field_sections/create_contract.rb b/app/contracts/project_custom_field_sections/create_contract.rb new file mode 100644 index 000000000000..91e44c3aaec1 --- /dev/null +++ b/app/contracts/project_custom_field_sections/create_contract.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class CreateContract < BaseContract + end +end diff --git a/app/contracts/project_custom_field_sections/delete_contract.rb b/app/contracts/project_custom_field_sections/delete_contract.rb new file mode 100644 index 000000000000..3b5e38d49308 --- /dev/null +++ b/app/contracts/project_custom_field_sections/delete_contract.rb @@ -0,0 +1,39 @@ +#-- 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 ProjectCustomFieldSections + class DeleteContract < BaseContract + validate :section_not_in_use + + def section_not_in_use + if model.custom_fields.exists? + errors.add(:base, :in_use) + end + end + end +end diff --git a/app/contracts/project_custom_field_sections/update_contract.rb b/app/contracts/project_custom_field_sections/update_contract.rb new file mode 100644 index 000000000000..85960355c764 --- /dev/null +++ b/app/contracts/project_custom_field_sections/update_contract.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class UpdateContract < BaseContract + end +end diff --git a/app/contracts/projects/base_contract.rb b/app/contracts/projects/base_contract.rb index 84bfde71333e..7b0ec44ca038 100644 --- a/app/contracts/projects/base_contract.rb +++ b/app/contracts/projects/base_contract.rb @@ -50,6 +50,10 @@ class BaseContract < ::ModelContract validate_templated_set_by_admin end + attribute :_limit_custom_fields_validation_to_section_id + # `_limit_custom_fields_validation_to_section_id` used in Projects::ActsAsCustomizablePatches in order to + # only validate custom fields of the touched section + validate :validate_user_allowed_to_manage def assignable_parents diff --git a/app/controllers/admin/settings/project_custom_field_sections_controller.rb b/app/controllers/admin/settings/project_custom_field_sections_controller.rb new file mode 100644 index 000000000000..e861d0b475cc --- /dev/null +++ b/app/controllers/admin/settings/project_custom_field_sections_controller.rb @@ -0,0 +1,115 @@ +#-- 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 Admin::Settings + class ProjectCustomFieldSectionsController < ::Admin::SettingsController + include OpTurbo::ComponentStream + include Admin::Settings::ProjectCustomFields::ComponentStreams + + before_action :set_project_custom_field_section, only: %i[update move drop destroy] + + def create + # show new sections at the top of the list, otherwise might not be visible to user + call = ::ProjectCustomFieldSections::CreateService.new(user: current_user).call( + project_custom_field_section_params.merge(position: 1) + ) + + if call.success? + update_header_via_turbo_stream # required to closed the dialog + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def update + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + project_custom_field_section_params + ) + + if call.success? + update_section_via_turbo_stream(project_custom_field_section: call.result) + else + update_section_dialog_body_form_via_turbo_stream(project_custom_field_section: call.result) + end + + respond_with_turbo_streams + end + + def destroy + call = ::ProjectCustomFieldSections::DeleteService.new(user: current_user, model: @project_custom_field_section).call + + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message + end + + respond_with_turbo_streams + end + + def move + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + move_to: params[:move_to]&.to_sym + ) + + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message + end + + respond_with_turbo_streams + end + + def drop + call = ::ProjectCustomFieldSections::UpdateService.new(user: current_user, model: @project_custom_field_section).call( + position: params[:position].to_i + ) + + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: ProjectCustomFieldSection.all) + else + # TODO: show error message + end + respond_with_turbo_streams + end + + private + + def set_project_custom_field_section + @project_custom_field_section = ProjectCustomFieldSection.find(params[:id]) + end + + def project_custom_field_section_params + params.require(:project_custom_field_section).permit(:name) + 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 new file mode 100644 index 000000000000..f25cca6013da --- /dev/null +++ b/app/controllers/admin/settings/project_custom_fields_controller.rb @@ -0,0 +1,131 @@ +#-- 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 Admin::Settings + class ProjectCustomFieldsController < ::Admin::SettingsController + include CustomFields::SharedActions + include OpTurbo::ComponentStream + include Admin::Settings::ProjectCustomFields::ComponentStreams + + menu_item :project_custom_fields_settings + + before_action :set_sections, only: %i[show index edit update move drop] + before_action :find_custom_field, only: %i(show edit 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 + + def default_breadcrumb + if action_name == "index" + t("label_project_attribute_plural") + else + ActionController::Base.helpers.link_to(t("label_project_attribute_plural"), admin_settings_project_custom_fields_path) + end + end + + def show_local_breadcrumb + true + end + + def index + respond_to :html + end + + def show + # quick fixing redirect issue from perform_update + # perform_update is always redirecting to the show action altough configured otherwise + render :edit + end + + def new + @custom_field = ProjectCustomField.new(custom_field_section_id: params[:custom_field_section_id]) + + respond_to :html + end + + def edit; end + + def move + call = CustomFields::UpdateService.new(user: current_user, model: @custom_field).call( + move_to: params[:move_to]&.to_sym + ) + + if call.success? + update_sections_via_turbo_stream(project_custom_field_sections: @project_custom_field_sections) + else + # TODO: handle error + end + + respond_with_turbo_streams + end + + def drop + call = ::ProjectCustomFields::DropService.new(user: current_user, project_custom_field: @custom_field).call( + target_id: params[:target_id], + position: params[:position] + ) + + if call.success? + drop_success_streams(call) + else + # TODO: handle error + end + + respond_with_turbo_streams + end + + def destroy + @custom_field.destroy + + update_section_via_turbo_stream(project_custom_field_section: @custom_field.project_custom_field_section) + + respond_with_turbo_streams + end + + private + + def set_sections + @project_custom_field_sections = ProjectCustomFieldSection + .includes(custom_fields: :project_custom_field_project_mappings) + .order("custom_fields.position_in_custom_field_section ASC") + .all + end + + def find_custom_field + @custom_field = ProjectCustomField.find(params[:id]) + rescue ActiveRecord::RecordNotFound + render_404 + end + + def drop_success_streams(call) + update_section_via_turbo_stream(project_custom_field_section: call.result[:current_section]) + if call.result[:section_changed] + update_section_via_turbo_stream(project_custom_field_section: call.result[:old_section]) + end + end + end +end diff --git a/app/controllers/admin/settings/projects_settings_controller.rb b/app/controllers/admin/settings/projects_settings_controller.rb index 5e879fb9abfd..11355769a88b 100644 --- a/app/controllers/admin/settings/projects_settings_controller.rb +++ b/app/controllers/admin/settings/projects_settings_controller.rb @@ -28,12 +28,12 @@ module Admin::Settings class ProjectsSettingsController < ::Admin::SettingsController - menu_item :settings_projects + menu_item :projects_settings before_action :validate_enabled_modules, only: :update def default_breadcrumb - t(:label_project_plural) + t(:label_project_settings) end private diff --git a/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb new file mode 100644 index 000000000000..5073bdf20b23 --- /dev/null +++ b/app/controllers/concerns/admin/settings/project_custom_fields/component_streams.rb @@ -0,0 +1,72 @@ +#-- 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 Admin + module Settings + module ProjectCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_header_via_turbo_stream + update_via_turbo_stream( + component: ::Settings::ProjectCustomFields::HeaderComponent.new + ) + end + + def update_section_via_turbo_stream(project_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::ProjectCustomFieldSections::ShowComponent.new( + # Note: `first_and_last:` argument is not necessary here, because we render + # a single custom field section, and not a list of sections. Calling first? + # and last? method in the component will not result in an N+1 in this case. + project_custom_field_section: + ) + ) + end + + def update_section_dialog_body_form_via_turbo_stream(project_custom_field_section:) + update_via_turbo_stream( + component: ::Settings::ProjectCustomFieldSections::DialogBodyFormComponent.new( + project_custom_field_section: + ) + ) + end + + def update_sections_via_turbo_stream(project_custom_field_sections:) + replace_via_turbo_stream( + component: ::Settings::ProjectCustomFieldSections::IndexComponent.new( + project_custom_field_sections: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/concerns/custom_fields/shared_actions.rb b/app/controllers/concerns/custom_fields/shared_actions.rb new file mode 100644 index 000000000000..1e8265f0e769 --- /dev/null +++ b/app/controllers/concerns/custom_fields/shared_actions.rb @@ -0,0 +1,149 @@ +#-- 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 CustomFields + module SharedActions + extend ActiveSupport::Concern + + included do + def index_path(custom_field, params = {}) + if custom_field.type == 'ProjectCustomField' + admin_settings_project_custom_fields_path(**params) + else + custom_fields_path(**params) + end + end + + def edit_path(custom_field, params = {}) + if custom_field.type == 'ProjectCustomField' + admin_settings_project_custom_field_path(**params) + else + edit_custom_field_path(**params) + end + end + + def create + call = ::CustomFields::CreateService + .new(user: current_user) + .call(get_custom_field_params.merge(type: permitted_params.custom_field_type)) + + if call.success? + flash[:notice] = t(:notice_successful_create) + call_hook(:controller_custom_fields_new_after_save, custom_field: call.result) + redirect_to index_path(call.result, tab: call.result.class.name) + else + @custom_field = call.result || new_custom_field + render action: 'new' + end + end + + def update + perform_update(get_custom_field_params) + end + + def perform_update(custom_field_params) + call = ::CustomFields::UpdateService + .new(user: current_user, model: @custom_field) + .call(custom_field_params) + + if call.success? + flash[:notice] = t(:notice_successful_update) + call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) + redirect_back_or_default(edit_path(@custom_field, id: @custom_field.id)) + else + render action: 'edit' + end + end + + def reorder_alphabetical + reordered_options = @custom_field + .custom_options + .sort_by(&:value) + .each_with_index + .map do |custom_option, index| + { id: custom_option.id, position: index + 1 } + end + + perform_update(custom_options_attributes: reordered_options) + end + + def destroy + begin + @custom_field.destroy + rescue StandardError + flash[:error] = I18n.t(:error_can_not_delete_custom_field) + end + redirect_to index_path(@custom_field, tab: @custom_field.class.name) + end + + def delete_option + if @custom_option.destroy + num_deleted = delete_custom_values! @custom_option + + flash[:notice] = I18n.t( + :notice_custom_options_deleted, option_value: @custom_option.value, num_deleted: + ) + else + flash[:error] = @custom_option.errors.full_messages + end + + redirect_to edit_path(@custom_field, id: @custom_field.id) + end + + def new_custom_field + ::CustomFields::CreateService.careful_new_custom_field(permitted_params.custom_field_type) + end + + def get_custom_field_params + permitted_params.custom_field + end + + def find_custom_option + @custom_option = CustomOption.find params[:option_id] + rescue ActiveRecord::RecordNotFound + render_404 + end + + def delete_custom_values!(custom_option) + CustomValue + .where(custom_field_id: custom_option.custom_field_id, value: custom_option.id) + .delete_all + end + + def prepare_custom_option_position + return unless params[:custom_field][:custom_options_attributes] + + index = 0 + + params[:custom_field][:custom_options_attributes].each do |_id, attributes| + attributes[:position] = (index = index + 1) + end + end + end + end +end diff --git a/app/controllers/concerns/op_turbo/component_stream.rb b/app/controllers/concerns/op_turbo/component_stream.rb index 6d8c2ed136ac..625f98a978a6 100644 --- a/app/controllers/concerns/op_turbo/component_stream.rb +++ b/app/controllers/concerns/op_turbo/component_stream.rb @@ -30,7 +30,7 @@ module OpTurbo module ComponentStream extend ActiveSupport::Concern - def respond_to_with_turbo_streams(&format_block) + def respond_to_with_turbo_streams(status: :ok, &format_block) respond_to do |format| format.turbo_stream do render turbo_stream: turbo_streams, status: diff --git a/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb new file mode 100644 index 000000000000..0b126e565c40 --- /dev/null +++ b/app/controllers/concerns/projects/settings/project_custom_fields/component_streams.rb @@ -0,0 +1,51 @@ +#-- 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 Projects + module Settings + module ProjectCustomFields + module ComponentStreams + extend ActiveSupport::Concern + + included do + def update_sections_via_turbo_stream( + project: @project, + project_custom_field_sections: @project_custom_field_sections + ) + update_via_turbo_stream( + component: ::Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( + project:, + project_custom_field_sections: + ) + ) + end + end + end + end + end +end diff --git a/app/controllers/custom_fields_controller.rb b/app/controllers/custom_fields_controller.rb index b7b656437260..b35ed052dd1a 100644 --- a/app/controllers/custom_fields_controller.rb +++ b/app/controllers/custom_fields_controller.rb @@ -27,6 +27,7 @@ #++ class CustomFieldsController < ApplicationController + include CustomFields::SharedActions # share logic with ProjectCustomFieldsControlller layout 'admin' before_action :require_admin @@ -36,7 +37,10 @@ class CustomFieldsController < ApplicationController def index # loading wp cfs exclicity to allow for eager loading - @custom_fields_by_type = CustomField.all.where.not(type: 'WorkPackageCustomField').group_by { |f| f.class.name } + @custom_fields_by_type = CustomField.all + .where.not(type: ['WorkPackageCustomField', 'ProjectCustomField']) + .group_by { |f| f.class.name } + @custom_fields_by_type['WorkPackageCustomField'] = WorkPackageCustomField.includes(:types).all @tab = params[:tab] || 'WorkPackageCustomField' @@ -45,112 +49,25 @@ def index def new @custom_field = new_custom_field - if @custom_field.nil? - flash[:error] = 'Invalid CF type' - redirect_to action: :index - end + check_custom_field end - def edit; end - - def create - call = ::CustomFields::CreateService - .new(user: current_user) - .call(get_custom_field_params.merge(type: permitted_params.custom_field_type)) - - if call.success? - flash[:notice] = t(:notice_successful_create) - call_hook(:controller_custom_fields_new_after_save, custom_field: call.result) - redirect_to custom_fields_path(tab: call.result.class.name) - else - @custom_field = call.result || new_custom_field - render action: 'new' - end + def edit + check_custom_field end - def update - perform_update(get_custom_field_params) - end - - def reorder_alphabetical - reordered_options = @custom_field - .custom_options - .sort_by(&:value) - .each_with_index - .map do |custom_option, index| - { id: custom_option.id, position: index + 1 } - end - - perform_update(custom_options_attributes: reordered_options) - end - - def destroy - begin - @custom_field.destroy - rescue StandardError - flash[:error] = I18n.t(:error_can_not_delete_custom_field) - end - redirect_to custom_fields_path(tab: @custom_field.class.name) - end - - def delete_option - if @custom_option.destroy - num_deleted = delete_custom_values! @custom_option - - flash[:notice] = I18n.t( - :notice_custom_options_deleted, option_value: @custom_option.value, num_deleted: - ) - else - flash[:error] = @custom_option.errors.full_messages - end - - redirect_to edit_custom_field_path(id: @custom_field.id) - end - - private - - def perform_update(custom_field_params) - call = ::CustomFields::UpdateService - .new(user: current_user, model: @custom_field) - .call(custom_field_params) + protected - if call.success? - flash[:notice] = t(:notice_successful_update) - call_hook(:controller_custom_fields_edit_after_save, custom_field: @custom_field) - redirect_back_or_default edit_custom_field_path(id: @custom_field.id) + def default_breadcrumb + if action_name == 'index' + t('label_custom_field_plural') else - render action: 'edit' + ActionController::Base.helpers.link_to(t('label_custom_field_plural'), custom_fields_path) end end - def new_custom_field - ::CustomFields::CreateService.careful_new_custom_field(permitted_params.custom_field_type) - end - - def get_custom_field_params - permitted_params.custom_field - end - - def find_custom_option - @custom_option = CustomOption.find params[:option_id] - rescue ActiveRecord::RecordNotFound - render_404 - end - - def delete_custom_values!(custom_option) - CustomValue - .where(custom_field_id: custom_option.custom_field_id, value: custom_option.id) - .delete_all - end - - def prepare_custom_option_position - return unless params[:custom_field][:custom_options_attributes] - - index = 0 - - params[:custom_field][:custom_options_attributes].each do |_id, attributes| - attributes[:position] = (index = index + 1) - end + def show_local_breadcrumb + true end def find_custom_field @@ -159,17 +76,11 @@ def find_custom_field render_404 end - protected - - def default_breadcrumb - if action_name == 'index' - t('label_custom_field_plural') - else - ActionController::Base.helpers.link_to(t('label_custom_field_plural'), custom_fields_path) + def check_custom_field + # ProjecCustomFields now managed in a different UI + if @custom_field.nil? || @custom_field.type == 'ProjectCustomField' + flash[:error] = 'Invalid CF type' + redirect_to action: :index end end - - def show_local_breadcrumb - true - end end diff --git a/app/controllers/oauth_clients_controller.rb b/app/controllers/oauth_clients_controller.rb index b0458a7d27f7..4a9823bc4277 100644 --- a/app/controllers/oauth_clients_controller.rb +++ b/app/controllers/oauth_clients_controller.rb @@ -70,6 +70,7 @@ def callback end end + # rubocop:disable Metrics/AbcSize def ensure_connection client_id = params.fetch(:oauth_client_id) storage_id = params.fetch(:storage_id) @@ -77,31 +78,32 @@ def ensure_connection handle_absent_oauth_client unless oauth_client - connection_manager = OAuthClients::ConnectionManager.new( - user: User.current, - configuration: oauth_client.integration.oauth_configuration - ) - + storage = oauth_client.integration # check if the origin is the same - destination_url = if params.fetch(:destination_url, '').start_with?(root_url) + destination_url = if params.fetch(:destination_url, "").start_with?(root_url) params[:destination_url] else root_url end - if connection_manager.authorization_state_connected? + auth_state = ::Storages::Peripherals::StorageInteraction::Authentication + .authorization_state(storage:, user: User.current) + + if auth_state == :connected redirect_to(destination_url) else nonce = SecureRandom.uuid cookies["oauth_state_#{nonce}"] = { value: { href: destination_url, storageId: storage_id }.to_json, expires: 1.hour } - redirect_to(connection_manager.get_authorization_uri(state: nonce)) + redirect_to(storage.oauth_configuration.authorization_uri(state: nonce)) end end + # rubocop:enable Metrics/AbcSize + private def handle_absent_oauth_client - flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), - I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_client_not_found"), + I18n.t("oauth_client.errors.oauth_client_not_found_explanation")] if User.current.admin? redirect_to admin_settings_storages_path @@ -123,9 +125,9 @@ def set_oauth_errors(service_result) service_result.errors = service_result.errors.to_active_model_errors end - flash[:error] = ["#{t(:'oauth_client.errors.oauth_authorization_code_grant_had_errors')}:"] + flash[:error] = ["#{t(:"oauth_client.errors.oauth_authorization_code_grant_had_errors")}:"] service_result.errors.each do |error| - flash[:error] << "#{t(:'oauth_client.errors.oauth_reported')}: #{error.full_message}" + flash[:error] << "#{t(:"oauth_client.errors.oauth_reported")}: #{error.full_message}" end end @@ -136,8 +138,8 @@ def set_code @code = params[:code] if @code.blank? - flash[:error] = [I18n.t('oauth_client.errors.oauth_code_not_present'), - I18n.t('oauth_client.errors.oauth_code_not_present_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_code_not_present"), + I18n.t("oauth_client.errors.oauth_code_not_present_explanation")] redirect_user_or_admin(get_redirect_uri) do # If the current user is an admin, we send her directly to the @@ -159,8 +161,8 @@ def set_redirect_uri else # To protect against CSRF we cancel this request. There was either no # state parameter given, or there was no corresponding cookie present. - flash[:error] = [I18n.t('oauth_client.errors.oauth_state_not_present'), - I18n.t('oauth_client.errors.oauth_state_not_present_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_state_not_present"), + I18n.t("oauth_client.errors.oauth_state_not_present_explanation")] redirect_user_or_admin(nil) do # Guide the user to the settings that she needs to edit/fix. @@ -195,8 +197,8 @@ def find_oauth_client # This happens during admin setup if the user forgot to update the return_uri # on the Authorization Server (i.e. Nextcloud) after updating the OpenProject # side with a new client_id and client_secret. - flash[:error] = [I18n.t('oauth_client.errors.oauth_client_not_found'), - I18n.t('oauth_client.errors.oauth_client_not_found_explanation')] + flash[:error] = [I18n.t("oauth_client.errors.oauth_client_not_found"), + I18n.t("oauth_client.errors.oauth_client_not_found_explanation")] redirect_user_or_admin(get_redirect_uri) do # Something must be wrong in the storage's setup @@ -212,7 +214,7 @@ def redirect_user_or_admin(redirect_uri = nil) if User.current.admin && redirect_uri && (nextcloud? || one_drive?) yield elsif redirect_uri - flash[:error] = [t(:'oauth_client.errors.oauth_issue_contact_admin')] + flash[:error] = [t(:"oauth_client.errors.oauth_issue_contact_admin")] redirect_to redirect_uri else redirect_to root_url diff --git a/app/controllers/projects/settings/project_custom_fields_controller.rb b/app/controllers/projects/settings/project_custom_fields_controller.rb new file mode 100644 index 000000000000..9dbcb329fe5a --- /dev/null +++ b/app/controllers/projects/settings/project_custom_fields_controller.rb @@ -0,0 +1,109 @@ +#-- 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. +#++ + +class Projects::Settings::ProjectCustomFieldsController < Projects::SettingsController + include OpTurbo::ComponentStream + include Projects::Settings::ProjectCustomFields::ComponentStreams + + menu_item :settings_project_custom_fields + + before_action :eager_load_project_custom_field_data, only: %i[show toggle enable_all_of_section disable_all_of_section] + + before_action :set_project_custom_field_section, only: %i[enable_all_of_section disable_all_of_section] + + def show; end + + def toggle + call = ProjectCustomFieldProjectMappings::ToggleService + .new(user: current_user) + .call(permitted_params.project_custom_field_project_mapping) + + # we don't need to rerender a component as the toggle switch shows the correct state base on a successful response + if call.success? + render json: {}, status: :ok + else + render json: {}, status: :unprocessable_entity + end + end + + def enable_all_of_section + call = bulk_edit_service.call(action: :enable) + + if call.success? + eager_load_project_custom_field_data # reload mappings + + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + else + # TODO: handle error + end + + respond_with_turbo_streams + end + + def disable_all_of_section + call = bulk_edit_service.call(action: :disable) + + if call.success? + eager_load_project_custom_field_data # reload mappings + + update_sections_via_turbo_stream # update all sections in order not to mess with stimulus target references + else + # TODO: handle error + end + + respond_with_turbo_streams + end + + private + + def eager_load_project_custom_field_data + # Load only the sections that have visible custom_fields associated + @project_custom_field_sections = + ProjectCustomFieldSection + .joins(:custom_fields) + .includes(:custom_fields) + .merge(ProjectCustomField.visible) + .group(:id, "custom_fields.id") + .order(:position, :position_in_custom_field_section) + end + + def set_project_custom_field_section + @project_custom_field_section = ProjectCustomFieldSection.find( + permitted_params.project_custom_field_project_mapping[:custom_field_section_id] + ) + end + + def bulk_edit_service + ProjectCustomFieldProjectMappings::BulkUpdateService + .new( + user: current_user, + project: @project, + project_custom_field_section: @project_custom_field_section + ) + end +end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb new file mode 100644 index 000000000000..76de92b4f45b --- /dev/null +++ b/app/forms/custom_fields/inputs/base/autocomplete/multi_value_input.rb @@ -0,0 +1,62 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Base::Autocomplete::MultiValueInput < CustomFields::Inputs::Base::Input + def input_attributes + base_input_attributes.merge( + autocomplete_options:, + wrapper_data_attributes: { + 'qa-field-name': qa_field_name + } + ) + end + + def autocomplete_options + { + multiple: true, + decorated: decorated?, + append_to: + } + end + + def decorated? + raise NotImplementedError + end + + def custom_values + @custom_values ||= @object.custom_values_for_custom_field(id: @custom_field.id) + end + + def invalid? + custom_values.any? { |custom_value| custom_value.errors.any? } + end + + def validation_message + custom_values.map { |custom_value| custom_value.errors.full_messages }.join(', ') if invalid? + end +end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb new file mode 100644 index 000000000000..ce6c7fcd740e --- /dev/null +++ b/app/forms/custom_fields/inputs/base/autocomplete/single_value_input.rb @@ -0,0 +1,50 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Base::Autocomplete::SingleValueInput < CustomFields::Inputs::Base::Input + def input_attributes + base_input_attributes.merge( + autocomplete_options:, + wrapper_data_attributes: { + 'qa-field-name': qa_field_name + } + ) + end + + def autocomplete_options + { + multiple: false, + decorated: decorated?, + append_to: + } + end + + def decorated? + raise NotImplementedError + end +end diff --git a/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb new file mode 100644 index 000000000000..8aa771231292 --- /dev/null +++ b/app/forms/custom_fields/inputs/base/autocomplete/user_query_utils.rb @@ -0,0 +1,60 @@ +#-- 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 CustomFields::Inputs::Base::Autocomplete::UserQueryUtils + def user_autocomplete_options + { + component: 'opce-user-autocompleter', + defaultData: false, + placeholder: I18n.t(:label_user_search), + resource:, + url: ::API::V3::Utilities::PathHelper::ApiV3Path.principals, + filters:, + searchKey: search_key, + inputValue: custom_input_value, + focusDirectly: false, + appendTo: append_to # unlike for the decorated autocompleters, this option has to be passed as camelCase key here! + } + end + + def resource + 'principals' + end + + def search_key + 'any_name_attribute' + end + + def filters + [ + { name: 'type', operator: '=', values: ['User', 'Group', 'PlaceholderUser'] }, + { name: 'member', operator: '=', values: [@object.id.to_s] }, + { name: 'status', operator: '!', values: [Principal.statuses["locked"].to_s] } + ] + end +end diff --git a/app/forms/custom_fields/inputs/base/input.rb b/app/forms/custom_fields/inputs/base/input.rb new file mode 100644 index 000000000000..a578a692ef95 --- /dev/null +++ b/app/forms/custom_fields/inputs/base/input.rb @@ -0,0 +1,60 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Base::Input < ApplicationForm + include CustomFields::Inputs::Base::Utils + + attr_reader :options + + def initialize(custom_field:, object:, **options) + @custom_field = custom_field + @object = object + @options = options + end + + def input_attributes + base_input_attributes.merge( + { + data: { 'qa-field-name': qa_field_name }, + value: + } + ) + end + + def custom_value + @custom_value ||= @object.custom_value_for(@custom_field.id) + end + + def invalid? + custom_value.errors.any? + end + + def validation_message + custom_value.errors.full_messages.join(', ') if invalid? + end +end diff --git a/app/forms/custom_fields/inputs/base/utils.rb b/app/forms/custom_fields/inputs/base/utils.rb new file mode 100644 index 000000000000..5d517a04fe04 --- /dev/null +++ b/app/forms/custom_fields/inputs/base/utils.rb @@ -0,0 +1,65 @@ +#-- 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 CustomFields::Inputs::Base::Utils + def base_input_attributes + { + name:, + label:, + value:, + required: required?, + invalid: invalid?, + validation_message: + } + end + + def name + @custom_field.id.to_s + end + + def label + @custom_field.name + end + + def value + @custom_value + end + + def required? + @custom_field.is_required? + end + + def qa_field_name + @custom_field.attribute_name(:kebab_case) + end + + # used within autocompleter inputs + def append_to + options.fetch(:wrapper_id, 'body') + end +end diff --git a/app/forms/custom_fields/inputs/bool.rb b/app/forms/custom_fields/inputs/bool.rb new file mode 100644 index 000000000000..69ee007427fb --- /dev/null +++ b/app/forms/custom_fields/inputs/bool.rb @@ -0,0 +1,41 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Bool < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.check_box(**input_attributes) + end + + def input_attributes + super.merge({ + value: "1", + unchecked_value: "0", + checked: @custom_value&.typed_value == true + }) + end +end diff --git a/app/forms/custom_fields/inputs/date.rb b/app/forms/custom_fields/inputs/date.rb new file mode 100644 index 000000000000..2834b3a8a60f --- /dev/null +++ b/app/forms/custom_fields/inputs/date.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Date < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.text_field(**input_attributes) + end + + def input_attributes + super.merge({ type: "date" }) + end +end diff --git a/app/forms/custom_fields/inputs/float.rb b/app/forms/custom_fields/inputs/float.rb new file mode 100644 index 000000000000..62e45610b409 --- /dev/null +++ b/app/forms/custom_fields/inputs/float.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Float < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.text_field(**input_attributes) + end + + def input_attributes + super.merge({ type: "number", step: :any }) + end +end diff --git a/app/forms/custom_fields/inputs/int.rb b/app/forms/custom_fields/inputs/int.rb new file mode 100644 index 000000000000..ab5fc29e50d9 --- /dev/null +++ b/app/forms/custom_fields/inputs/int.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Int < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.text_field(**input_attributes) + end + + def input_attributes + super.merge({ type: "number", step: 1 }) + end +end diff --git a/app/forms/custom_fields/inputs/multi_select_list.rb b/app/forms/custom_fields/inputs/multi_select_list.rb new file mode 100644 index 000000000000..f11f8cf5a396 --- /dev/null +++ b/app/forms/custom_fields/inputs/multi_select_list.rb @@ -0,0 +1,63 @@ +#-- 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. +#++ + +class CustomFields::Inputs::MultiSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput + form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) + + custom_value_form.autocompleter(**input_attributes) do |list| + @custom_field.custom_options.each do |custom_option| + list.option( + label: custom_option.value, value: custom_option.id, + selected: selected?(custom_option) + ) + end + end + end + + private + + def decorated? + true + end + + def selected?(custom_option) + if @custom_values.any? + @custom_values.pluck(:value).map { |value| value&.to_i }.include?(custom_option.id) + else + custom_option.default_value? + end + end +end diff --git a/app/forms/custom_fields/inputs/multi_user_select_list.rb b/app/forms/custom_fields/inputs/multi_user_select_list.rb new file mode 100644 index 000000000000..6dc05fe908e2 --- /dev/null +++ b/app/forms/custom_fields/inputs/multi_user_select_list.rb @@ -0,0 +1,58 @@ +#-- 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. +#++ + +class CustomFields::Inputs::MultiUserSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput + include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils + + form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) + + custom_value_form.autocompleter(**input_attributes) + end + + private + + def decorated? + false + end + + def autocomplete_options + super.merge(user_autocomplete_options) + end + + def custom_input_value + @custom_values.filter_map(&:value) + end +end diff --git a/app/forms/custom_fields/inputs/multi_version_select_list.rb b/app/forms/custom_fields/inputs/multi_version_select_list.rb new file mode 100644 index 000000000000..8092247ccb89 --- /dev/null +++ b/app/forms/custom_fields/inputs/multi_version_select_list.rb @@ -0,0 +1,63 @@ +#-- 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. +#++ + +class CustomFields::Inputs::MultiVersionSelectList < CustomFields::Inputs::Base::Autocomplete::MultiValueInput + include AssignableCustomFieldValues + + delegate :assignable_versions, to: :@object + + form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge( + scope_name_to_model: false, + name: "#{@object.class.name.downcase}[custom_field_values][#{input_attributes[:name]}][]", + value: + )) + + custom_value_form.autocompleter(**input_attributes) do |list| + assignable_custom_field_values(@custom_field).each do |version| + list.option( + label: version.name, value: version.id, + selected: selected?(version) + ) + end + end + end + + private + + def decorated? + true + end + + def selected?(version) + @custom_values.pluck(:value).map { |value| value&.to_i }.include?(version.id) + end +end diff --git a/app/forms/custom_fields/inputs/single_select_list.rb b/app/forms/custom_fields/inputs/single_select_list.rb new file mode 100644 index 000000000000..907200cb95dd --- /dev/null +++ b/app/forms/custom_fields/inputs/single_select_list.rb @@ -0,0 +1,55 @@ +#-- 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. +#++ + +class CustomFields::Inputs::SingleSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput + form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(value: "")) + + custom_value_form.autocompleter(**input_attributes) do |list| + @custom_value.custom_field.custom_options.each do |custom_option| + list.option( + label: custom_option.value, value: custom_option.id, + selected: selected?(custom_option) + ) + end + end + end + + private + + def decorated? + true + end + + def selected?(custom_option) + custom_option.id == @custom_value.value&.to_i || custom_option.id == @custom_field.default_value&.to_i + end +end diff --git a/app/forms/custom_fields/inputs/single_user_select_list.rb b/app/forms/custom_fields/inputs/single_user_select_list.rb new file mode 100644 index 000000000000..8203c534b491 --- /dev/null +++ b/app/forms/custom_fields/inputs/single_user_select_list.rb @@ -0,0 +1,49 @@ +#-- 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. +#++ + +class CustomFields::Inputs::SingleUserSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput + include CustomFields::Inputs::Base::Autocomplete::UserQueryUtils + + form do |custom_value_form| + custom_value_form.autocompleter(**input_attributes) + end + + private + + def decorated? + false + end + + def autocomplete_options + super.merge(user_autocomplete_options) + end + + def custom_input_value + @custom_value&.value + end +end diff --git a/app/forms/custom_fields/inputs/single_version_select_list.rb b/app/forms/custom_fields/inputs/single_version_select_list.rb new file mode 100644 index 000000000000..19f832c916d6 --- /dev/null +++ b/app/forms/custom_fields/inputs/single_version_select_list.rb @@ -0,0 +1,59 @@ +#-- 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. +#++ + +class CustomFields::Inputs::SingleVersionSelectList < CustomFields::Inputs::Base::Autocomplete::SingleValueInput + include AssignableCustomFieldValues + + delegate :assignable_versions, to: :@object + + form do |custom_value_form| + # autocompleter does not set key with blank value if nothing is selected or input is cleared + # in order to let acts_as_customizable handle the clearing of the value, we need to set the value to blank via a hidden field + # which sends blank if autocompleter is cleared + custom_value_form.hidden(**input_attributes.merge(value: "")) + + custom_value_form.autocompleter(**input_attributes) do |list| + assignable_custom_field_values(@custom_field).each do |version| + list.option( + label: version.name, value: version.id, + selected: selected?(version) + ) + end + end + end + + private + + def decorated? + true + end + + def selected?(version) + version.id == @custom_value.value&.to_i + end +end diff --git a/app/forms/custom_fields/inputs/string.rb b/app/forms/custom_fields/inputs/string.rb new file mode 100644 index 000000000000..8af06d84e6a8 --- /dev/null +++ b/app/forms/custom_fields/inputs/string.rb @@ -0,0 +1,33 @@ +#-- 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. +#++ + +class CustomFields::Inputs::String < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.text_field(**input_attributes) + end +end diff --git a/app/forms/custom_fields/inputs/text.rb b/app/forms/custom_fields/inputs/text.rb new file mode 100644 index 000000000000..4a6ffe3ec853 --- /dev/null +++ b/app/forms/custom_fields/inputs/text.rb @@ -0,0 +1,39 @@ +#-- 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. +#++ + +class CustomFields::Inputs::Text < CustomFields::Inputs::Base::Input + form do |custom_value_form| + custom_value_form.rich_text_area(**input_attributes.merge(rich_text_options:)) + end + + def rich_text_options + { + resource: nil + } + end +end diff --git a/app/forms/project_custom_field_sections/name_form.rb b/app/forms/project_custom_field_sections/name_form.rb new file mode 100644 index 000000000000..caba64f6b8f0 --- /dev/null +++ b/app/forms/project_custom_field_sections/name_form.rb @@ -0,0 +1,39 @@ +#-- 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. +#++ + +class ProjectCustomFieldSections::NameForm < ApplicationForm + form do |project_custom_field_section_form| + project_custom_field_section_form.text_field( + name: :name, + placeholder: ProjectCustomFieldSection.human_attribute_name(:name), + label: ProjectCustomFieldSection.human_attribute_name(:name), + required: true, + autofocus: true + ) + end +end diff --git a/app/forms/projects/custom_fields/form.rb b/app/forms/projects/custom_fields/form.rb new file mode 100644 index 000000000000..1508c068aae0 --- /dev/null +++ b/app/forms/projects/custom_fields/form.rb @@ -0,0 +1,118 @@ +#-- 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 Form < ApplicationForm + form do |custom_fields_form| + custom_fields.each do |custom_field| + custom_fields_form.fields_for(:custom_field_values) do |builder| + custom_field_input(builder, custom_field) + end + end + end + + def initialize(project:, custom_field_section: nil, custom_field: nil, wrapper_id: nil) + super() + @project = project + @custom_field_section = custom_field_section + @custom_field = custom_field + @wrapper_id = wrapper_id + + if @custom_field_section.present? && @custom_field.present? + raise ArgumentError, + "Either custom_field_section or custom_field must be specified, but not both" + end + end + + private + + def custom_fields + @custom_fields ||= + if @custom_field.present? + [@custom_field] + elsif @custom_field_section.present? + @project + .available_custom_fields + .where(custom_field_section_id: @custom_field_section.id) + else + @project.available_custom_fields + end + end + + def custom_field_input(builder, custom_field) + if custom_field.multi_value? + multi_value_custom_field_input(builder, custom_field) + else + single_value_custom_field_input(builder, custom_field) + end + end + + # TBD: transform inputs called below to primer form dsl instead of form classes? + # TODOS: + # - initial values for user inputs are not displayed + # - allow/disallow-non-open version setting is not yet respected in the version selector + # - rich text editor is not yet supported + + def single_value_custom_field_input(builder, custom_field) + form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } + + case custom_field.field_format + when "string" + CustomFields::Inputs::String.new(builder, **form_args) + when "text" + CustomFields::Inputs::Text.new(builder, **form_args) + when "int" + CustomFields::Inputs::Int.new(builder, **form_args) + when "float" + CustomFields::Inputs::Float.new(builder, **form_args) + when "list" + CustomFields::Inputs::SingleSelectList.new(builder, **form_args) + when "date" + CustomFields::Inputs::Date.new(builder, **form_args) + when "bool" + CustomFields::Inputs::Bool.new(builder, **form_args) + when "user" + CustomFields::Inputs::SingleUserSelectList.new(builder, **form_args) + when "version" + CustomFields::Inputs::SingleVersionSelectList.new(builder, **form_args) + end + end + + def multi_value_custom_field_input(builder, custom_field) + form_args = { custom_field:, object: @project, wrapper_id: @wrapper_id } + + case custom_field.field_format + when "list" + CustomFields::Inputs::MultiSelectList.new(builder, **form_args) + when "user" + CustomFields::Inputs::MultiUserSelectList.new(builder, **form_args) + when "version" + CustomFields::Inputs::MultiVersionSelectList.new(builder, **form_args) + end + end + end +end diff --git a/app/forms/work_packages/share/invitee.rb b/app/forms/work_packages/share/invitee.rb index f0fe0940af68..ad08a74c2837 100644 --- a/app/forms/work_packages/share/invitee.rb +++ b/app/forms/work_packages/share/invitee.rb @@ -28,12 +28,14 @@ module WorkPackages::Share class Invitee < ApplicationForm form do |user_invite_form| - user_invite_form.user_autocompleter( + user_invite_form.autocompleter( name: :user_id, label: I18n.t('work_package.sharing.label_search'), visually_hide_label: true, data: { 'work-packages--share--user-limit-target': 'autocompleter' }, autocomplete_options: { + component: 'opce-user-autocompleter', + defaultData: false, id: "op-share-wp-invite-autocomplete", placeholder: I18n.t('work_package.sharing.label_search_placeholder'), data: { diff --git a/app/helpers/custom_fields_helper.rb b/app/helpers/custom_fields_helper.rb index 8cf2720cc997..4e9434ff4ee6 100644 --- a/app/helpers/custom_fields_helper.rb +++ b/app/helpers/custom_fields_helper.rb @@ -41,12 +41,6 @@ def custom_fields_tabs path: custom_fields_path(tab: :TimeEntryCustomField), label: :label_spent_time }, - { - name: 'ProjectCustomField', - partial: 'custom_fields/tab', - path: custom_fields_path(tab: :ProjectCustomField), - label: :label_project_plural - }, { name: 'VersionCustomField', partial: 'custom_fields/tab', diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 25557a74abec..c35a073b51de 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -63,6 +63,7 @@ def selected_projects_columns_options Setting .enabled_projects_columns .map { |c| projects_columns_options.find { |o| o[:id].to_s == c } } + .compact end def protected_projects_columns_options diff --git a/app/models/custom_field.rb b/app/models/custom_field.rb index 2aa2e7109ae8..ef07583c9300 100644 --- a/app/models/custom_field.rb +++ b/app/models/custom_field.rb @@ -236,6 +236,7 @@ def self.filter def attribute_name(format = nil) return "customField#{id}" if format == :camel_case + return "custom-field-#{id}" if format == :kebab_case "custom_field_#{id}" end diff --git a/app/models/custom_field_section.rb b/app/models/custom_field_section.rb new file mode 100644 index 000000000000..8ca5ae147a02 --- /dev/null +++ b/app/models/custom_field_section.rb @@ -0,0 +1,35 @@ +#-- 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. +#++ + +class CustomFieldSection < ApplicationRecord + acts_as_list scope: [:type] + + validates :name, presence: true + + default_scope { order(:position) } +end diff --git a/app/models/custom_value.rb b/app/models/custom_value.rb index e569001ec92a..ea8fde497060 100644 --- a/app/models/custom_value.rb +++ b/app/models/custom_value.rb @@ -35,6 +35,8 @@ class CustomValue < ApplicationRecord validate :validate_type_of_value validate :validate_length_of_value + after_create :activate_custom_field_in_customized_project, if: -> { customized.is_a?(Project) } + delegate :typed_value, :formatted_value, to: :strategy @@ -66,6 +68,16 @@ def default? || value_is_same_as_default? end + def activate_custom_field_in_customized_project + return if default? || value.blank? + + # if a custom value is created for a project via CustomValue.create(...), + # the custom field needs to be activated in the project + unless customized&.project_custom_fields&.include?(custom_field) + customized.project_custom_fields << custom_field + end + end + protected def value_is_included_in_multi_value_default? diff --git a/app/models/custom_value/formattable_strategy.rb b/app/models/custom_value/formattable_strategy.rb index dd7c19c3a596..884c9346d1dc 100644 --- a/app/models/custom_value/formattable_strategy.rb +++ b/app/models/custom_value/formattable_strategy.rb @@ -26,8 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class - CustomValue::FormattableStrategy < CustomValue::FormatStrategy +class CustomValue::FormattableStrategy < CustomValue::FormatStrategy def formatted_value OpenProject::TextFormatting::Renderer.format_text value end diff --git a/app/models/permitted_params.rb b/app/models/permitted_params.rb index 68d61fe64b8c..6ed1a45ae846 100644 --- a/app/models/permitted_params.rb +++ b/app/models/permitted_params.rb @@ -293,6 +293,11 @@ def project whitelist.merge(custom_field_values(:project)) end + def project_custom_field_project_mapping + params.require(:project_custom_field_project_mapping) + .permit(*self.class.permitted_attributes[:project_custom_field_project_mapping]) + end + def news params.require(:news).permit(:title, :summary, :description) end @@ -475,6 +480,7 @@ def self.permitted_attributes :possible_values, :multi_value, :content_right_to_left, + :custom_field_section_id, :allow_non_open_versions, { custom_options_attributes: %i(id value default_value position) }, { type_ids: [] } @@ -556,6 +562,11 @@ def self.permitted_attributes :name, { type_ids: [] } ], + project_custom_field_project_mapping: %i( + project_id + custom_field_id + custom_field_section_id + ), query: %i( name display_sums diff --git a/app/models/project.rb b/app/models/project.rb index 111f98228327..d92a7c22307e 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -33,8 +33,11 @@ class Project < ApplicationRecord include Projects::Activity include Projects::Hierarchy include Projects::AncestorsFromRoot + include ::Scopes::Scoped + include Projects::ActsAsCustomizablePatches + # Maximum length for project identifiers IDENTIFIER_MAX_LENGTH = 100 @@ -87,7 +90,9 @@ class Project < ApplicationRecord has_many :project_storages, dependent: :destroy, class_name: 'Storages::ProjectStorage' has_many :storages, through: :project_storages - acts_as_customizable + acts_as_customizable # partially overridden via Projects::ActsAsCustomizablePatches in order to support sections and + # project-leval activation of custom fields + acts_as_searchable columns: %W(#{table_name}.name #{table_name}.identifier #{table_name}.description), date_column: "#{table_name}.created_at", project_key: 'id', diff --git a/app/models/project_custom_field.rb b/app/models/project_custom_field.rb index d6b5d61a68ef..d1aa6fe6077f 100644 --- a/app/models/project_custom_field.rb +++ b/app/models/project_custom_field.rb @@ -27,6 +27,17 @@ #++ class ProjectCustomField < CustomField + belongs_to :project_custom_field_section, class_name: "ProjectCustomFieldSection", foreign_key: :custom_field_section_id, + inverse_of: :custom_fields + has_many :project_custom_field_project_mappings, class_name: "ProjectCustomFieldProjectMapping", foreign_key: :custom_field_id, + dependent: :destroy, inverse_of: :project_custom_field + + acts_as_list column: :position_in_custom_field_section, scope: [:custom_field_section_id] + + after_save :activate_required_field_in_all_projects + + validates :custom_field_section_id, presence: true + def type_name :label_project_plural end @@ -38,4 +49,15 @@ def self.visible(user = User.current) where(visible: true) end end + + def activate_required_field_in_all_projects + return unless required? + + already_activated_in_project_ids = ProjectCustomFieldProjectMapping.where(custom_field_id: id).pluck(:project_id) + + mappings = Project.where.not(id: already_activated_in_project_ids).map do |project| + { project_id: project.id, custom_field_id: id } + end + ProjectCustomFieldProjectMapping.create!(mappings) + end end diff --git a/app/models/project_custom_field_project_mapping.rb b/app/models/project_custom_field_project_mapping.rb new file mode 100644 index 000000000000..217e06eadd42 --- /dev/null +++ b/app/models/project_custom_field_project_mapping.rb @@ -0,0 +1,35 @@ +#-- 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. +#++ + +class ProjectCustomFieldProjectMapping < ApplicationRecord + belongs_to :project + belongs_to :project_custom_field, class_name: 'ProjectCustomField', foreign_key: 'custom_field_id', + inverse_of: :project_custom_field_project_mappings + + validates :custom_field_id, uniqueness: { scope: :project_id } +end diff --git a/app/models/project_custom_field_section.rb b/app/models/project_custom_field_section.rb new file mode 100644 index 000000000000..c88ef5f11b19 --- /dev/null +++ b/app/models/project_custom_field_section.rb @@ -0,0 +1,32 @@ +#-- 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. +#++ + +class ProjectCustomFieldSection < CustomFieldSection + has_many :custom_fields, class_name: "ProjectCustomField", dependent: :destroy, foreign_key: :custom_field_section_id, + inverse_of: :project_custom_field_section +end diff --git a/app/models/projects/acts_as_customizable_patches.rb b/app/models/projects/acts_as_customizable_patches.rb new file mode 100644 index 000000000000..41441fe6cc7f --- /dev/null +++ b/app/models/projects/acts_as_customizable_patches.rb @@ -0,0 +1,164 @@ +#-- 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::ActsAsCustomizablePatches + extend ActiveSupport::Concern + + attr_accessor :_limit_custom_fields_validation_to_section_id, :_query_available_custom_fields_on_global_level + + # attr_accessor :_limit_custom_fields_validation_to_field_id + # not needed for now, but might be relevant if we want to have edit dialogs just for one custom field + + included do + has_many :project_custom_field_project_mappings, class_name: 'ProjectCustomFieldProjectMapping', foreign_key: :project_id, + dependent: :destroy, inverse_of: :project + has_many :project_custom_fields, through: :project_custom_field_project_mappings, class_name: 'ProjectCustomField' + + # we need to reset the query_available_custom_fields_on_global_level already after validation + # as the update service just calls .valid? and returns if invalid + # after_save is not touched in this case which causes the flag to stay active + after_validation :set_query_available_custom_fields_to_project_level + + before_update :set_query_available_custom_fields_to_global_level + + before_create :reject_section_scoped_validation_for_creation + before_create :build_missing_project_custom_field_project_mappings + + after_create :disable_custom_fields_with_empty_values + after_save :reset_section_scoped_validation, :set_query_available_custom_fields_to_project_level + + def build_missing_project_custom_field_project_mappings + # activate custom fields for this project (via mapping table) if values have been provided for custom_fields but no mapping exists + custom_field_ids = project.custom_values + .select { |cv| cv.value.present? } + .pluck(:custom_field_id).uniq + activated_custom_field_ids = project_custom_field_project_mappings.pluck(:custom_field_id).uniq + + mappings = (custom_field_ids - activated_custom_field_ids).uniq + .map { |pcf_id| { project_id: id, custom_field_id: pcf_id } } + + project_custom_field_project_mappings.build(mappings) + end + + def reset_section_scoped_validation + # reset the section scope after saving + # in order not to silently carry this setting in this instance + self._limit_custom_fields_validation_to_section_id = nil + end + + def set_query_available_custom_fields_to_global_level + # query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + self._query_available_custom_fields_on_global_level = true + end + + def set_query_available_custom_fields_to_project_level + # reset the query_available_custom_fields_on_global_level after saving + # in order not to silently carry this setting in this instance + self._query_available_custom_fields_on_global_level = nil + end + + def reject_section_scoped_validation_for_creation + if _limit_custom_fields_validation_to_section_id.present? + raise ArgumentError, + 'Section scoped validation is not supported for project creation, only for project updates' + end + end + + def disable_custom_fields_with_empty_values + # run only on initial creation! (otherwise we would deactivate custom fields with empty values on every update!) + # + # ideally, `build_missing_project_custom_field_project_mappings` would not activate custom fields with empty values + # but: + # this hook is required as acts_as_customizable build custom values with their default value even if a blank value was provided in the project creation form + # `build_missing_project_custom_field_project_mappings` will then activate the custom field although the user explicitly provided a blank value + # in order to not patch `acts_as_customizable` further, we simply identify these custom values and deactivate the custom field + custom_field_ids = project.custom_values.select { |cv| cv.value.blank? && !cv.required? }.pluck(:custom_field_id) + + project_custom_field_project_mappings.where(custom_field_id: custom_field_ids).destroy_all + end + + def with_all_available_custom_fields + # query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + self._query_available_custom_fields_on_global_level = true + result = yield + self._query_available_custom_fields_on_global_level = false + + result + end + + def available_custom_fields(global: false) + # overrides acts_as_customizable + # in contrast to acts_as_customizable, custom_fields are enabled per project + # thus we need to check the project_custom_field_project_mappings + custom_fields = ProjectCustomField + .visible + .includes(:project_custom_field_section) + .order("custom_field_sections.position", :position_in_custom_field_section) + + # available_custom_fields is called from within the acts_as_customizable module + # we don't want to adjust these calls, but need a way to query the available custom fields on a global level in some cases + # thus we pass in this parameter as an instance flag implicitly here, + # which is not nice but helps us to touch acts_as_customizable as little as possible + # + # additionally we provide the `global` parameter to allow querying the available custom fields on a global level + # when we have explicit control over the call of `available_custom_fields` + unless global || new_record? || _query_available_custom_fields_on_global_level + custom_fields = custom_fields + .where(id: project_custom_field_project_mappings.select(:custom_field_id)) + .or(ProjectCustomField.required) + end + + custom_fields + end + + def validate_custom_values + # validate custom values only of a specified section + # instead of validating ALL custom values like done in acts_as_customizable + custom_field_section_ids = CustomField + .where(id: custom_field_values.pluck(:custom_field_id)) + .where(custom_field_section_id: _limit_custom_fields_validation_to_section_id) + .pluck(:id) + + super(custom_field_section_ids) + end + + # we need to query the available custom fields on a global level when updating custom field values + # in order to support implicit activation of custom fields when values are provided during an update + def custom_field_values=(values) + with_all_available_custom_fields { super } + end + + # we need to query the available custom fields on a global level when + # trying to set a custom field which is not enabled via e.g. custom_field_123="foo" + def for_custom_field_accessor(method_symbol) + with_all_available_custom_fields { super } + end + end +end diff --git a/app/models/queries/projects/filters/custom_field_context.rb b/app/models/queries/projects/filters/custom_field_context.rb index 34fad024bf30..55199abde705 100644 --- a/app/models/queries/projects/filters/custom_field_context.rb +++ b/app/models/queries/projects/filters/custom_field_context.rb @@ -44,10 +44,15 @@ def where_subselect_joins(custom_field) cv_db_table = CustomValue.table_name project_db_table = Project.table_name - "LEFT OUTER JOIN #{cv_db_table} - ON #{cv_db_table}.customized_type='Project' - AND #{cv_db_table}.customized_id=#{project_db_table}.id - AND #{cv_db_table}.custom_field_id=#{custom_field.id}" + <<~SQL.squish + LEFT OUTER JOIN #{cv_db_table} + ON #{cv_db_table}.customized_type='Project' + AND #{cv_db_table}.customized_id=#{project_db_table}.id + AND #{cv_db_table}.custom_field_id=#{custom_field.id} + INNER JOIN project_custom_field_project_mappings + ON project_custom_field_project_mappings.project_id = projects.id + AND project_custom_field_project_mappings.custom_field_id = #{custom_field.id} + SQL end end end diff --git a/app/models/queries/projects/selects/custom_field.rb b/app/models/queries/projects/selects/custom_field.rb index e7d285c2ce39..c1b54dc70b52 100644 --- a/app/models/queries/projects/selects/custom_field.rb +++ b/app/models/queries/projects/selects/custom_field.rb @@ -55,4 +55,8 @@ def custom_field .visible .find_by(id: self.class.key.match(attribute)[1]) end + + def available? + custom_field.present? + end end diff --git a/app/models/queries/selects/available_selects.rb b/app/models/queries/selects/available_selects.rb index 54224313581a..9082a126f1bc 100644 --- a/app/models/queries/selects/available_selects.rb +++ b/app/models/queries/selects/available_selects.rb @@ -30,7 +30,14 @@ module Queries module Selects module AvailableSelects def select_for(key) - (find_available_select(key) || ::Queries::Selects::NotExistingSelect).new(key.to_sym) + select = (find_available_select(key) || ::Queries::Selects::NotExistingSelect) + .new(key.to_sym) + + # It might be that while the class of selects is available, the instantiated select isn't. + # This can e.g. be the case for custom fields that had once been available and have a key that + # leads to them being found by the find_available_select but when instantiated, the custom + # field they refer to is no longer available. + select.available? ? select : ::Queries::Selects::NotExistingSelect.new(key.to_sym) end def available_selects diff --git a/app/models/queries/selects/base.rb b/app/models/queries/selects/base.rb index 223dae6b6da4..09737d5c0d4e 100644 --- a/app/models/queries/selects/base.rb +++ b/app/models/queries/selects/base.rb @@ -55,4 +55,8 @@ def caption def initialize(attribute) self.attribute = attribute end + + def available? + true + end end diff --git a/app/models/user.rb b/app/models/user.rb index 51cce45acae2..d7cd19c38e7b 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -129,7 +129,7 @@ def self.blocked_condition(blocked) validates :login, uniqueness: { if: Proc.new { |user| user.login.present? }, case_sensitive: false } validates :mail, uniqueness: { allow_blank: true, case_sensitive: false } # Login must contain letters, numbers, underscores only - validates :login, format: { with: /\A[a-z0-9_\-@.+ ]*\z/i } + validates :login, format: { with: /\A[\p{L}0-9_\-@.+ ]*\z/i } validates :login, length: { maximum: 256 } validates :firstname, :lastname, length: { maximum: 256 } diff --git a/app/services/oauth_clients/connection_manager.rb b/app/services/oauth_clients/connection_manager.rb index ac0d6e70462b..6875c40902c1 100644 --- a/app/services/oauth_clients/connection_manager.rb +++ b/app/services/oauth_clients/connection_manager.rb @@ -55,7 +55,7 @@ def get_access_token(state: nil) # Return the Nextcloud OAuth authorization URI that a user needs to open to grant access and eventually obtain # a token. - @redirect_url = get_authorization_uri(state:) + @redirect_url = @config.authorization_uri(state:) ServiceResult.failure(result: @redirect_url) end @@ -90,19 +90,6 @@ def refresh_token end end - # rubocop:enable Metrics/AbcSize - - # Returns the URI of the "authorize" endpoint of the OAuth2 Authorization Server. - # @param state (OAuth2 RFC) is a nonce referencing a cookie containing the calling page (URL + params) to which to - # return to at the end of the whole flow. - # @param scope (OAuth2 RFC) specifies the resources to access. Nextcloud has only one global scope. - def get_authorization_uri(state: nil) - client = rack_oauth_client # Configure and start the rack-oauth2 client - client.authorization_uri(scope: @config.scope, state:) - end - - # rubocop:disable Metrics/AbcSize - # Called by callback_page with a cryptographic "code" that indicates # that the user has successfully authorized the OAuth2 Authorization Server. # We now are going to exchange this code to a token (bearer+refresh) @@ -140,42 +127,6 @@ def code_to_token(code) # rubocop:enable Metrics/AbcSize - # Called by StorageRepresenter to inquire about the status of the OAuth2 - # authentication server. - # Returns :connected/:authorization_failed or :error for a general error. - # We have decided to distinguish between only these 3 cases, because the - # front-end (and a normal user) probably wouldn't know how to deal with - # other options. - def authorization_state - oauth_client_token = get_existing_token - return :failed_authorization unless oauth_client_token - - state = @config.authorization_state_check(oauth_client_token.access_token) - case state - when :success - :connected - when :refresh_needed - service_result = refresh_token - if service_result.success? - :connected - elsif service_result.errors.data.payload[:error] == 'invalid_request' - :failed_authorization - else - :error - end - else - state - end - rescue StandardError - :error - end - - %i[connected failed_authorization error].each do |authorization_result| - define_method(:"authorization_state_#{authorization_result}?") do - authorization_state == authorization_result - end - end - # @returns ServiceResult with result to be :error or any type of object with data def request_with_token_refresh(oauth_client_token) # `yield` needs to returns a ServiceResult: diff --git a/app/services/project_custom_field_project_mappings/bulk_update_service.rb b/app/services/project_custom_field_project_mappings/bulk_update_service.rb new file mode 100644 index 000000000000..8dd4b69f0441 --- /dev/null +++ b/app/services/project_custom_field_project_mappings/bulk_update_service.rb @@ -0,0 +1,107 @@ +#-- 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 BulkUpdateService < ::BaseServices::BaseCallable + def initialize(user:, project:, project_custom_field_section:) + super() + @user = user + @project = project + @project_custom_field_section = project_custom_field_section + end + + def perform(params) + service_call = validate_permissions + service_call = perform_bulk_edit(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.allowed_in_project?(:select_project_custom_fields, @project) + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def perform_bulk_edit(service_call, params) + action = params[:action] + custom_field_ids = fetch_custom_field_ids + + begin + case action + when :enable + enable_custom_fields(custom_field_ids) + when :disable + disable_custom_fields(custom_field_ids) + end + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + service_call + end + + def fetch_custom_field_ids + # only custom fields which are not set to required can be disabled + ProjectCustomField + .visible(@user) + .where(custom_field_section_id: @project_custom_field_section.id) + .where(is_required: false) + .pluck(:id) + end + + def enable_custom_fields(custom_field_ids) + existing_mapping_ids = existing_mappings(custom_field_ids) + new_mapping_ids = custom_field_ids - existing_mapping_ids + + create_mappings(new_mapping_ids) if new_mapping_ids.any? + end + + def disable_custom_fields(custom_field_ids) + ProjectCustomFieldProjectMapping + .where(project_id: @project.id, custom_field_id: custom_field_ids) + .delete_all + end + + def existing_mappings(custom_field_ids) + ProjectCustomFieldProjectMapping + .where(project_id: @project.id, custom_field_id: custom_field_ids) + .pluck(:custom_field_id) + end + + def create_mappings(custom_field_ids) + new_mappings = custom_field_ids.map do |id| + { project_id: @project.id, custom_field_id: id } + end + ProjectCustomFieldProjectMapping.insert_all(new_mappings) + end + end +end diff --git a/app/services/project_custom_field_project_mappings/set_attributes_service.rb b/app/services/project_custom_field_project_mappings/set_attributes_service.rb new file mode 100644 index 000000000000..4a6c3007920f --- /dev/null +++ b/app/services/project_custom_field_project_mappings/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- 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 SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_custom_field_project_mappings/toggle_service.rb b/app/services/project_custom_field_project_mappings/toggle_service.rb new file mode 100644 index 000000000000..c9acb5ae4d7b --- /dev/null +++ b/app/services/project_custom_field_project_mappings/toggle_service.rb @@ -0,0 +1,62 @@ +#-- 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 ToggleService < ::BaseServices::Write + def persist(service_result) + if service_result.result.persisted? + # destroy the mapping if it exists and catch any errors which would not be caught by active record + begin + service_result.result.destroy + rescue StandardError => e + service_result.errors = e.message + service_result.success = false + end + else + # create the mapping if it does not exist + unless service_result.result.save + service_result.errors = service_result.result.errors + service_result.success = false + end + end + + service_result + end + + def instance(params) + instance_class.find_or_initialize_by( + project_id: params[:project_id], + custom_field_id: params[:custom_field_id] + ) + end + + def default_contract_class + ProjectCustomFieldProjectMappings::UpdateContract + end + end +end diff --git a/app/services/project_custom_field_sections/create_service.rb b/app/services/project_custom_field_sections/create_service.rb new file mode 100644 index 000000000000..01cf2b20db79 --- /dev/null +++ b/app/services/project_custom_field_sections/create_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class CreateService < ::BaseServices::Create + end +end diff --git a/app/services/project_custom_field_sections/delete_service.rb b/app/services/project_custom_field_sections/delete_service.rb new file mode 100644 index 000000000000..823c96e8c41e --- /dev/null +++ b/app/services/project_custom_field_sections/delete_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/app/services/project_custom_field_sections/set_attributes_service.rb b/app/services/project_custom_field_sections/set_attributes_service.rb new file mode 100644 index 000000000000..db7da29c9cd5 --- /dev/null +++ b/app/services/project_custom_field_sections/set_attributes_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/app/services/project_custom_field_sections/update_service.rb b/app/services/project_custom_field_sections/update_service.rb new file mode 100644 index 000000000000..7611c6d4cd76 --- /dev/null +++ b/app/services/project_custom_field_sections/update_service.rb @@ -0,0 +1,32 @@ +#-- 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 ProjectCustomFieldSections + class UpdateService < ::BaseServices::Update + end +end diff --git a/app/services/project_custom_fields/drop_service.rb b/app/services/project_custom_fields/drop_service.rb new file mode 100644 index 000000000000..dfd91263c6b4 --- /dev/null +++ b/app/services/project_custom_fields/drop_service.rb @@ -0,0 +1,93 @@ +#-- 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 ProjectCustomFields + class DropService < ::BaseServices::BaseCallable + def initialize(user:, project_custom_field:) + super() + @user = user + @project_custom_field = project_custom_field + end + + def perform(params) + service_call = validate_permissions + service_call = perform_drop(service_call, params) if service_call.success? + + service_call + end + + def validate_permissions + if @user.admin? + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def perform_drop(service_call, params) + begin + section_changed, current_section, old_section = check_and_update_section_if_changed(params) + update_position(params[:position]&.to_i) + + service_call.success = true + service_call.result = { section_changed:, current_section:, old_section: } + rescue StandardError => e + service_call.success = false + service_call.errors = e.message + end + + service_call + end + + private + + def check_and_update_section_if_changed(params) + current_section = @project_custom_field.project_custom_field_section + new_section_id = params[:target_id]&.to_i + + if current_section.id != new_section_id + old_section = current_section + current_section = update_section(new_section_id) + return [true, current_section, old_section] + end + + [false, current_section, nil] + end + + def update_section(new_section_id) + current_section = ProjectCustomFieldSection.find(new_section_id) + @project_custom_field.remove_from_list + @project_custom_field.update(project_custom_field_section: current_section) + current_section + end + + def update_position(new_position) + @project_custom_field.insert_at(new_position) + end + end +end diff --git a/app/services/projects/copy_service.rb b/app/services/projects/copy_service.rb index 97863455514c..dea7dff54097 100644 --- a/app/services/projects/copy_service.rb +++ b/app/services/projects/copy_service.rb @@ -80,6 +80,15 @@ def before_perform(params, service_call) end end + def after_perform(call) + copy_activated_custom_fields(call) + + super + end + + def copy_activated_custom_fields(call) + call.result.project_custom_field_ids = source.project_custom_field_ids + end def contract_options { copy_source: source, validate_model: true } end diff --git a/app/views/account/_password_login_form.html.erb b/app/views/account/_password_login_form.html.erb index b40d8496fb78..f58caaeefb76 100644 --- a/app/views/account/_password_login_form.html.erb +++ b/app/views/account/_password_login_form.html.erb @@ -55,10 +55,11 @@ See COPYRIGHT and LICENSE files for more details. <% end %> - <%= submit_tag t(:button_login), - name: :login, - class: 'button -primary button_no-margin', - data: { disable_with: t(:label_loading) } %> + <% end %>
+<% content_controller 'admin--custom-fields', + dynamic: true, + 'admin--custom-fields-format-value': @custom_field.field_format +%> + +<% local_assigns[:additional_breadcrumb] = @custom_field.name %> + +<%= render(Settings::ProjectCustomFields::EditFormHeaderComponent.new(custom_field: @custom_field)) %> + +<%= error_messages_for 'custom_field' %> + +<%= labelled_tabular_form_for @custom_field, as: :custom_field, + url: admin_settings_project_custom_field_path(@custom_field), + html: {method: :put, id: 'custom_field_form'} do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> + <% end %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<% end %> + + + diff --git a/app/views/admin/settings/project_custom_fields/index.html.erb b/app/views/admin/settings/project_custom_fields/index.html.erb new file mode 100644 index 000000000000..14b91d79930d --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/index.html.erb @@ -0,0 +1,34 @@ +<%#-- 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. + +++#%> +
+ <%= render(Settings::ProjectCustomFields::HeaderComponent.new()) %> + <%= render(Settings::ProjectCustomFieldSections::IndexComponent.new( + project_custom_field_sections: @project_custom_field_sections + )) %> +
diff --git a/app/views/admin/settings/project_custom_fields/new.html.erb b/app/views/admin/settings/project_custom_fields/new.html.erb new file mode 100644 index 000000000000..143135f47ec5 --- /dev/null +++ b/app/views/admin/settings/project_custom_fields/new.html.erb @@ -0,0 +1,48 @@ +<%#-- 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. + +++#%> +<% content_controller 'admin--custom-fields', + dynamic: true, + 'admin--custom-fields-format-value': @custom_field.field_format +%> + +<% local_assigns[:additional_breadcrumb] = t('settings.project_attributes.new.heading') %> + +<%= render(Settings::ProjectCustomFields::NewFormHeaderComponent.new) %> + +<%= error_messages_for 'custom_field' %> + +<%= labelled_tabular_form_for @custom_field, as: :custom_field, + url: admin_settings_project_custom_fields_path, + html: { id: 'custom_field_form' } do |f| %> + <%= render partial: 'custom_fields/form', locals: { f: f, custom_field: @custom_field } %> + <% if @custom_field.new_record? %> + <%= hidden_field_tag 'type', @custom_field.type %> + <% end %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> +<% end %> diff --git a/app/views/admin/settings/projects_settings/show.html.erb b/app/views/admin/settings/projects_settings/show.html.erb index b81e0a93a2dd..25ad606a3a4f 100644 --- a/app/views/admin/settings/projects_settings/show.html.erb +++ b/app/views/admin/settings/projects_settings/show.html.erb @@ -26,9 +26,9 @@ Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. See COPYRIGHT and LICENSE files for more details. ++#%> -<%= toolbar title: t(:label_project_plural) %> +<%= toolbar title: t(:label_project_settings) %> -<%= styled_form_tag(admin_settings_update_projects_path, method: :patch) do %> +<%= styled_form_tag(admin_settings_projects_path, method: :patch) do %>
<%= t('settings.projects.section_new_projects') %> @@ -60,6 +60,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> +
<%= angular_component_tag 'opce-draggable-autocompleter', @@ -101,4 +102,4 @@ See COPYRIGHT and LICENSE files for more details.
<%= styled_button_tag t(:button_save), class: '-primary -with-icon icon-checkmark' %> -<% end %> +<% end %> \ No newline at end of file diff --git a/app/views/admin/settings/work_packages_settings/show.html.erb b/app/views/admin/settings/work_packages_settings/show.html.erb index a0826dff7ea9..9708867f0c34 100644 --- a/app/views/admin/settings/work_packages_settings/show.html.erb +++ b/app/views/admin/settings/work_packages_settings/show.html.erb @@ -58,7 +58,7 @@ See COPYRIGHT and LICENSE files for more details. } %> <% if EnterpriseToken.allows_to? :conditional_highlighting %> -
+
<%= setting_multiselect :work_package_list_default_highlighted_attributes, Query.available_columns(nil).select(&:highlightable).map { |column| [column.caption, column.name.to_s] diff --git a/app/views/custom_fields/_form.html.erb b/app/views/custom_fields/_form.html.erb index bc95729ebcfd..16dc232c5ac9 100644 --- a/app/views/custom_fields/_form.html.erb +++ b/app/views/custom_fields/_form.html.erb @@ -30,6 +30,15 @@ See COPYRIGHT and LICENSE files for more details.
<%= f.text_field :name, required: true, container_class: '-middle' %>
+ <% if @custom_field.type == 'ProjectCustomField' %> +
+ <%= f.select :custom_field_section_id, + ProjectCustomFieldSection.all.collect { |s| [s.name, s.id] }, + { container_class: '-slim' }, + required: true + %> +
+ <% end %>
<%= f.select :field_format, custom_field_formats_for_select(@custom_field), @@ -93,15 +102,15 @@ See COPYRIGHT and LICENSE files for more details. <% end %>
<%= render partial: "custom_fields/custom_options", locals: { custom_field: @custom_field, f: f } %> - +
<% end %> diff --git a/app/views/members/_member_form.html.erb b/app/views/members/_member_form.html.erb index 4831a7138396..dec20dd95623 100644 --- a/app/views/members/_member_form.html.erb +++ b/app/views/members/_member_form.html.erb @@ -59,7 +59,7 @@ See COPYRIGHT and LICENSE files for more details. <%= angular_component_tag 'opce-members-autocompleter', inputs: { - inputName: "member[user_ids][]", + inputName: "member[user_ids]", inputBindValue: "id", url: autocomplete_for_member_project_members_path + '.json', appendTo: "body", diff --git a/app/views/projects/settings/project_custom_fields/show.html.erb b/app/views/projects/settings/project_custom_fields/show.html.erb new file mode 100644 index 000000000000..c6fae909ca8f --- /dev/null +++ b/app/views/projects/settings/project_custom_fields/show.html.erb @@ -0,0 +1,42 @@ +<%#-- 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. + +++#%> +
+ <%= render Primer::OpenProject::PageHeader.new do |header| %> + <%= header.with_title(variant: :default) { t('projects.settings.project_custom_fields.header.title') } %> + <%= header.with_description { t('projects.settings.project_custom_fields.header.description', + overview_url: project_path(@project), + admin_settings_url: admin_settings_project_custom_fields_path + ).html_safe } %> + <% end %> + <%= render(Projects::Settings::ProjectCustomFieldSections::IndexComponent.new( + project: @project, + project_custom_field_sections: @project_custom_field_sections, + )) %> +
+ diff --git a/config/constants/settings/definition.rb b/config/constants/settings/definition.rb index 28df4a724e10..306827d022d4 100644 --- a/config/constants/settings/definition.rb +++ b/config/constants/settings/definition.rb @@ -769,6 +769,20 @@ class Definition allowed: (0..), default: 3 }, + httpx_operation_timeout: { + description: '', + format: :float, + writable: false, + allowed: (0..), + default: 10 + }, + httpx_request_timeout: { + description: '', + format: :float, + writable: false, + allowed: (0..), + default: 10 + }, httpx_read_timeout: { description: "", format: :float, diff --git a/config/initializers/menus.rb b/config/initializers/menus.rb index ec7662bd4824..2e1af1838766 100644 --- a/config/initializers/menus.rb +++ b/config/initializers/menus.rb @@ -330,6 +330,24 @@ caption: Proc.new { Workflow.model_name.human }, parent: :admin_work_packages + menu.push :admin_projects_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_plural, + icon: 'projects' + + menu.push :project_custom_fields_settings, + { controller: '/admin/settings/project_custom_fields', action: :index }, + if: Proc.new { User.current.admin? }, + caption: :label_project_attributes_plural, + parent: :admin_projects_settings + + menu.push :projects_settings, + { controller: '/admin/settings/projects_settings', action: :show }, + if: Proc.new { User.current.admin? }, + caption: :label_setting_plural, + parent: :admin_projects_settings + menu.push :custom_fields, { controller: "/custom_fields" }, if: Proc.new { User.current.admin? }, @@ -624,8 +642,9 @@ icon: "settings2", allow_deeplink: true - { + project_menu_items = { general: :label_information_plural, + project_custom_fields: :label_project_attributes_plural, modules: :label_module_plural, types: :label_work_package_types, custom_fields: :label_custom_field_plural, @@ -634,7 +653,9 @@ repository: :label_repository, time_entry_activities: :enumeration_activities, storage: :label_required_disk_storage - }.each do |key, caption| + } + + project_menu_items.each do |key, caption| menu.push :"settings_#{key}", { controller: "/projects/settings/#{key}", action: "show" }, caption:, diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index 4098e318acf5..a99f979e97e0 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -109,6 +109,13 @@ permissible_on: :project, require: :member + map.permission :select_project_custom_fields, + { + 'projects/settings/project_custom_fields': %i[show toggle enable_all_of_section disable_all_of_section] + }, + permissible_on: :project, + require: :member + map.permission :manage_members, { members: %i[index new create update destroy autocomplete_for_member menu], diff --git a/config/locales/crowdin/cs.yml b/config/locales/crowdin/cs.yml index 3477811b391f..e237a99b252e 100644 --- a/config/locales/crowdin/cs.yml +++ b/config/locales/crowdin/cs.yml @@ -1594,7 +1594,7 @@ cs: journals: changes_retracted: "Změny byly staženy." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Zápis atributů pouze pro čtení" dates_changed: "Data změněna" system_update: "Aktualizace systému OpenProject:" cause_descriptions: @@ -2776,9 +2776,9 @@ cs: setting_apiv3_cors_origins: "API V3 Cross-Origin Resource Sharing (CORS) povolené počátky" setting_apiv3_cors_origins_text_html: > Pokud je funkce CORS povolena, je to původ, který má přístup k OpenProject API.
Zkontrolujte Dokumentaci na záhlaví Origin o tom, jak specifikovat očekávané hodnoty. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Přístup k atributům pouze pro čtení" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Pokud je tato možnost povolena, rozhraní API umožní správcům zapisovat při vytváření statické atributy pouze pro čtení, například časová razítka createdAt a updatedAt.
Upozornění: Toto nastavení má využití např. při importu dat, ale umožňuje správcům vydávat se při vytváření položek za jiné uživatele. Všechny požadavky na vytvoření se však zaznamenávají se skutečným autorem.
Další informace o atributech a podporovaných zdrojích naleznete na adrese %{api_documentation_link}. setting_apiv3_max_page_size: "Maximální velikost stránky API" setting_apiv3_max_page_instructions_html: > Nastavte maximální velikost stránky, se kterou bude API reagovat. Nebude možné provádět API požadavky, které vracejí více hodnot na jedné stránce.
Varování: Změňte tuto hodnotu pouze pokud jste si jisti, proč ji potřebujete. Nastavení na vysokou hodnotu bude mít výrazný dopad na výkon, zatímco hodnota nižší, než jsou možnosti na stránce, způsobí chyby v stránkovaných zobrazeních. @@ -2922,7 +2922,7 @@ cs: clamav_socket_html: Zadejte soket démona clamd, např. %{example}. clamav_host_html: Zadejte název hostitele a port démona clamd oddělený dvojtečkou. např. %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Vyberte režim, ve kterém by měla fungovat integrace antivirového scanneru.
  • %{disabled_option}: Nahrané soubory nejsou naskenovány pro viry.
  • %{socket_option}: Nastavili jste ClamAV na stejném serveru jako OpenProject a scan daemon clamd běží na pozadí
  • %{host_option}: Vysíláte soubory do externího hostitele pro skenování virů.
brute_force_prevention: "Automatizované blokování uživatelů" date_format: first_date_of_week_and_year_set: > @@ -3159,7 +3159,7 @@ cs: status_user_and_brute_force: "%{user} a %{brute_force}" status_change: "Změna stavu" text_change_disabled_for_provider_login: "Jméno je nastaveno vaším administrátorem přihlášení, a proto jej nelze změnit." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "Jméno a e-mail jsou nastaveny pomocí LDAP, a nelze je tedy měnit." unlock: "Odemknout" unlock_and_reset_failed_logins: "Odemknout a resetovat nezdařené přihlášení" version_status_closed: "uzavřeno" @@ -3233,7 +3233,7 @@ cs: text_empty_search_header: "Nenašli jsme žádné odpovídající výsledky." text_empty_state_description: "Pracovní balíček zatím nebyl s nikým sdílen." text_empty_state_header: "Není sdíleno" - text_user_limit_reached: "Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package." + text_user_limit_reached: "Přidáním dalších uživatelů bude aktuální limit překročen. Pro zvýšení limitu uživatelů kontaktujte správce, abyste zajistili přístup externích uživatelů k tomuto pracovnímu balíčku." text_user_limit_reached_admins: 'Adding additional users will exceed the current limit. Please upgrade your plan to be able to add more users.' warning_user_limit_reached: > Adding additional users will exceed the current limit. Please contact an administrator to increase the user limit to ensure external users are able to access this work package. diff --git a/config/locales/crowdin/js-pt-BR.yml b/config/locales/crowdin/js-pt-BR.yml index 54544b894355..226c8fbad3c0 100644 --- a/config/locales/crowdin/js-pt-BR.yml +++ b/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: ajax: hide: "Ocultar" diff --git a/config/locales/crowdin/js-pt-PT.yml b/config/locales/crowdin/js-pt-PT.yml index a71080afd879..d6ad4659f73a 100644 --- a/config/locales/crowdin/js-pt-PT.yml +++ b/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: ajax: hide: "Ocultar" @@ -352,7 +352,7 @@ pt: standard: learn_about_link: https://www.openproject.org/blog/openproject-13-4-release/ new_features_html: > - The release contains various new features and improvements:
  • GitLab integration (originally developed by Community contributors)
  • Advanced features for custom project lists
  • Advanced features for the Meetings module
  • Virus scanning functionality with ClamAV (Enterprise add-on)
  • PDF Export: Lists in table cells are supported
  • WebAuthn/FIDO/U2F is added as a second factor
  • More languages added to the default available set
+ A versão contém uma série de novas funcionalidades e melhorias:
  • Integração com o GitLab (originalmente desenvolvido por colaboradores da Comunidade)
  • Funcionalidades avançadas para listas de projetos personalizadas
  • Funcionalidades avançadas para o módulo Reuniões
  • Funcionalidade de verificação de vírus com o ClamAV (suplemento Enterprise)
  • Exportação de PDF: são suportadas listas em células de tabela
  • O WebAuthn/FIDO/U2F foi adicionado como um segundo fator
  • Foram adicionados mais idiomas ao conjunto disponível por defeito
ical_sharing_modal: title: "Subscrever o calendário" inital_setup_error_message: "Ocorreu um erro ao recuperar os dados." diff --git a/config/locales/crowdin/js-zh-TW.yml b/config/locales/crowdin/js-zh-TW.yml index f6ff00ebd067..b2869e881948 100644 --- a/config/locales/crowdin/js-zh-TW.yml +++ b/config/locales/crowdin/js-zh-TW.yml @@ -75,7 +75,7 @@ zh-TW: button_copy_to_clipboard: "複製到剪貼簿" button_copy_link_to_clipboard: "複製到剪貼簿" button_copy_to_other_project: "複製到其他專案" - button_custom-fields: "自訂欄位" + button_custom-fields: "客製欄位" button_delete: "删除" button_delete_watcher: "刪除監看者" button_details_view: "詳細檢視" @@ -190,7 +190,7 @@ zh-TW: text: "[Placeholder] 嵌入式日曆" admin: type_form: - custom_field: "自訂欄位" + custom_field: "客製欄位" inactive: "未啟用" drag_to_activate: "從這裡拖曳欄位來啟用它們" add_group: "增加群組屬性" @@ -351,7 +351,7 @@ zh-TW: standard: learn_about_link: https://www.openproject.org/blog/openproject-13-4-release/ new_features_html: > - The release contains various new features and improvements:
  • GitLab integration (originally developed by Community contributors)
  • Advanced features for custom project lists
  • Advanced features for the Meetings module
  • Virus scanning functionality with ClamAV (Enterprise add-on)
  • PDF Export: Lists in table cells are supported
  • WebAuthn/FIDO/U2F is added as a second factor
  • More languages added to the default available set
+ 此版本包含各種新功能和改進:
  • GitLab 整合(最初由社群貢獻者開發)
  • 自訂專案清單的高級功能
  • li>
  • 會議模組的進階功能
  • 使用ClamAV(企業附加元件)進行病毒掃描功能
  • PDF 匯出:支援表格儲存格中的列表
  • < li>WebAuthn/FIDO/U2F 被加入為第二個因素
  • 新加入更多語言
ical_sharing_modal: title: "訂閱日曆" inital_setup_error_message: "更新資料時發生錯誤" @@ -765,7 +765,7 @@ zh-TW: error_no_table_configured: "請為 %{group} 配置表。" reset_title: "重設表單配置" confirm_reset: > - 警告: 確實要重置表單配置嗎?這將重置屬性到其預設組, 並禁用所有自訂欄位。 + 警告: 確實要重置表單配置嗎?這將重置屬性到其預設組, 並禁用所有客製欄位。 upgrade_to_ee: "升級至地端企業版" upgrade_to_ee_text: "哇!如果您需要使用此功能,說明您非常專業!您是否願意成為企業版客戶來支持 OpenSource 開發人員?" more_information: "更多資訊" @@ -886,7 +886,7 @@ zh-TW: image: "圖片" work_packages: bulk_actions: - move: "Bulk change of project" + move: "專案整批變動" edit: "整批編輯" copy: "整批複製" delete: "整批刪除" @@ -988,16 +988,16 @@ zh-TW: updatedAt: "更新於" versionName: "版本" version: "版本" - work: "工作" - workAlternative: "Estimated time" - remainingTime: "剩餘工作" + work: "工時" + workAlternative: "預估時間" + remainingTime: "剩餘工時" default_queries: latest_activity: "最新活動" - created_by_me: "由我創建" + created_by_me: "由我建立" assigned_to_me: "分配給我" recently_created: "最近創建的" all_open: "所有「進行中」工作" - summary: "總覽" + summary: "大綱" shared_with_users: "分配給成員" shared_with_me: "分享給我的" jump_marks: @@ -1019,7 +1019,7 @@ zh-TW: move_column_left: "欄向左移" move_column_right: "欄向右移" hide_column: "隱藏欄" - insert_columns: "Insert columns" + insert_columns: "調整欄位" filters: "篩選條件" display_sums: "顯示加總" confirm_edit_cancel: "確實要取消編輯此版面的名稱嗎?標題將還原。" @@ -1037,14 +1037,14 @@ zh-TW: is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates." is_switched_from_manual_to_automatic: "The dates of this work package may need to be recalculated after switching from manual to automatic scheduling due to relationships with other work packages." sharing: - share: "分享" + share: "共享" title: "分享工作項目" show_all_users: "顯示所有使用者" selected_count: "已選取 %{count} 個" selection: mixed: "Mixed" upsale: - description: "Share work packages with users who are not members of the project." + description: "與不是專案成員的使用者共用工作項目。" table: configure_button: "設定工作項目" summary: "由工作項目和工作項目屬性列組成的表格。" @@ -1065,9 +1065,9 @@ zh-TW: show_timeline_hint: "在表格的右側顯示互動式甘特圖。可以通過在表和甘特圖之間拖動分隔線來更改其寬度。" highlighting: "突顯" highlighting_mode: - description: "以顏色凸顯" + description: "以顏色突顯" none: "無突顯" - inline: "凸顯屬性" + inline: "突顯屬性" inline_all: "所有屬性" entire_row_by: "按類型劃分的整行" status: "狀態" @@ -1086,7 +1086,7 @@ zh-TW: relation_filters: filter_work_packages_by_relation_type: "Filter work packages by relation type" tabs: - overview: 概要 + overview: 總覽 activity: 活動 relations: 關聯 watchers: 監看者 @@ -1278,14 +1278,14 @@ zh-TW: does_not_match_search: "沒有符合搜尋條件" no_results: "沒有符合搜尋條件" baseline: - toggle_title: "基準日期" + toggle_title: "差異線" clear: "清除" apply: "套用" - header_description: "基準日期開始,突顯修改過的資料" - enterprise_header_description: "Highlight changes made to this list since any point in the past with Enterprise edition." + header_description: "差異線開始,突顯修改過的資料" + enterprise_header_description: "差異線開始,突顯修改過的資料(企業版功能)" show_changes_since: "顯示差異" - baseline_comparison: "基準日比較" - help_description: "Reference time zone for the baseline." + baseline_comparison: "比較差異" + help_description: "差異線參考時區。" time_description: "當地時間: %{datetime}" time: "時間" from: "寄件者" diff --git a/config/locales/crowdin/lt.yml b/config/locales/crowdin/lt.yml index 2d13220c268a..cef202c11340 100644 --- a/config/locales/crowdin/lt.yml +++ b/config/locales/crowdin/lt.yml @@ -26,8 +26,8 @@ lt: no_results_title_text: Šiuo periodu projekte nieko neįvyko. admin: plugins: - no_results_title_text: There are currently no plugins installed. - no_results_content_text: See our integrations and plugins page for more information. + no_results_title_text: Šio metu nėra įdiegtų priedų. + no_results_content_text: Daugiau informacijos rasite mūsų integracijų ir priedų puslapyje. custom_styles: color_theme: "Spalvų tema" color_theme_custom: "(Pasirinktinis)" @@ -1591,7 +1591,7 @@ lt: journals: changes_retracted: "Pakeitimai buvo atšaukti." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Tik-skaitymo atributai įrašyti" dates_changed: "Datos pasikeitė" system_update: "OpenProject sistemos atnaujinimas" cause_descriptions: @@ -2770,9 +2770,9 @@ lt: setting_apiv3_cors_origins: "API V3 Cross-Origin Resource Sharing (CORS) leidžiami kilmės domenai" setting_apiv3_cors_origins_text_html: > Jei CORS yra įgalinta, tai yra kilmės domenai, kuriems leidžiama pasiekti OpenProject API.
Kaip nurodyti reikšmes aprašoma Origin antraštės dokumentacijoje. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Rašymo prieiga prie tik skaitymui skirtų atributų" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Įjungus API leis administratoriams kūrimo metu rašyti statinius tik skaitymui skirtus atributus, tokiu kaip createdAt ir updatedAt laiko žymas.
Įspėjimas: Šis nustatymas naudojamas pavyzdžiui importuojant duomenis, bet leidžia administratoriams apsimesti, kad elementus sukuria kiti naudotojai. Visos kūrimo užklausos žurnalizuojamos su tikru autoriumi.
Daugiau informacijos apie atributus ir palaikomus resursus rasite %{api_documentation_link}. setting_apiv3_max_page_size: "Maksimalus API puslapio dydis" setting_apiv3_max_page_instructions_html: > Nustatykite maksimalų API atsakymo puslapio dydį. Bus neįmanoma vykdyti API užklausas, kurios grąžina daugiau reikšmių viename puslapyje.
Įspėjimas Prašome keisti šią reikšmę jei jums to tikrai reikia. Nustatyta didelė reikšmė stipriai įtakos greitaveiką, o maža reikšmė mažesnė už puslapiavimo parinktis reikš klaida puslapiuojamuose vaizduose. @@ -3153,7 +3153,7 @@ lt: status_user_and_brute_force: "%{user} ir %{brute_force}" status_change: "Būsenos keitimas" text_change_disabled_for_provider_login: "Vardą nustatė jūsų prisijungimo tarnyba, todėl jis negali būti pakeistas." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "Vardą ir e-paštą nustato LDAP ir todėl to negalima keisti." unlock: "Atrakinti" unlock_and_reset_failed_logins: "Atrakintiir iš naujo nustatyti nepavykusius prisijungimus" version_status_closed: "uždarytas" diff --git a/config/locales/crowdin/ms.yml b/config/locales/crowdin/ms.yml index a74b29fdac00..263e8074d387 100644 --- a/config/locales/crowdin/ms.yml +++ b/config/locales/crowdin/ms.yml @@ -2030,7 +2030,7 @@ ms: label_remove_columns: "Remove selected columns" label_renamed: "renamed" label_reply_plural: "Replies" - label_report: "Report" + label_report: "Laporan" label_report_bug: "Report a bug" label_report_plural: "Reports" label_reported_work_packages: "Reported work packages" diff --git a/config/locales/crowdin/pt-BR.seeders.yml b/config/locales/crowdin/pt-BR.seeders.yml index 6bb58afb32fc..70607b6171e3 100644 --- a/config/locales/crowdin/pt-BR.seeders.yml +++ b/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: common: colors: diff --git a/config/locales/crowdin/pt-BR.yml b/config/locales/crowdin/pt-BR.yml index 1bac2adc8519..4d14ff8c5de0 100644 --- a/config/locales/crowdin/pt-BR.yml +++ b/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: no_results_title_text: Atualmente, não há o que exibir. activities: index: @@ -2718,7 +2718,7 @@ pt: Se o CORS estiver habilitado, essas são as origens que têm permissão para acessar a API OpenProject.
Por favor, verifique a documentação na header da Origin sobre como especificar os valores esperados. setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Se ativada, a API permitirá que os administradores escrevam atributos estáticos somente leitura durante a criação, como os campos data/hora createdAt e updatedAt.
Aviso: Esta configuração tem um caso de uso para, por exemplo, importar dados, mas permite que os administradores personifiquem a criação de itens como outros usuários. No entanto, todas as solicitações de criação estão sendo registradas com o verdadeiro autor.
Para obter mais informações sobre atributos e recursos compatíveis, consulte o site %{api_documentation_link}. setting_apiv3_max_page_size: "Tamanho máximo da página de API" setting_apiv3_max_page_instructions_html: > Defina o tamanho máximo de página com o qual a API responderá. Não será possível realizar solicitações de API que retornem mais valores em uma única página.
Aviso: Somente altere este valor se tiver certeza do motivo de sua necessidade. Definir um valor alto resultará em impactos significativos no desempenho, enquanto um valor menor que as opções por página causará erros nas visualizações paginadas. @@ -3097,7 +3097,7 @@ pt: status_user_and_brute_force: "%{user} e %{brute_force}" status_change: "Mudança de situação" text_change_disabled_for_provider_login: "O nome é definido por seu fornecedor de início de sessão e, desta forma, não pode ser alterado." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "O nome e o e-mail são definidos pelo LDAP e, portanto, não podem ser alterados." unlock: "Desbloquear" unlock_and_reset_failed_logins: "Desbloquear e redefinir logins com falha" version_status_closed: "fechado" diff --git a/config/locales/crowdin/pt-PT.seeders.yml b/config/locales/crowdin/pt-PT.seeders.yml index 37bb5c241ee6..97e72a7f968c 100644 --- a/config/locales/crowdin/pt-PT.seeders.yml +++ b/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: common: colors: diff --git a/config/locales/crowdin/pt-PT.yml b/config/locales/crowdin/pt-PT.yml index 6f5ef5ddce1c..5e674f4ae333 100644 --- a/config/locales/crowdin/pt-PT.yml +++ b/config/locales/crowdin/pt-PT.yml @@ -19,21 +19,21 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: no_results_title_text: Atualmente, não há nada para exibir. activities: index: no_results_title_text: Não ocorreu nenhuma atividade neste projeto dentro deste espaço de tempo. admin: plugins: - no_results_title_text: There are currently no plugins installed. - no_results_content_text: See our integrations and plugins page for more information. + no_results_title_text: Atualmente não há plugins instalados. + no_results_content_text: Para mais informações, consulte a nossa página de integrações e plugins. custom_styles: color_theme: "Cores do tema" color_theme_custom: "(Personalizado)" colors: - primary-button-color: "Primary button" - accent-color: "Accent" + primary-button-color: "Botão principal" + accent-color: "Destaque" header-bg-color: "Fundo do cabeçalho" header-item-bg-hover-color: "Fundo do cabeçalho ao passar o rato" header-item-font-color: "Fonte do cabeçalho" @@ -52,8 +52,8 @@ pt: enterprise_more_info: "Nota: o logótipo usado estará acessível ao público." manage_colors: "Editar opções de selecção de cor" instructions: - primary-button-color: "Strong accent color, used for the most important button on a screen." - accent-color: "Color for links and other decently highlighted elements." + primary-button-color: "Cor de contraste forte, utilizada no botão mais importante de um ecrã." + accent-color: "Cor para as ligações e outros elementos bem realçados." header-item-bg-hover-color: "Cor de fundo de itens do cabeçalho clicáveis quando se passa com o rato por cima." header-item-font-color: "Cor da fonte dos itens clicáveis do cabeçalho." header-item-font-hover-color: "Cor da fonte de itens clicáveis do cabeçalho quando se passa com o mouse por cima." @@ -1527,7 +1527,7 @@ pt: postgres_migration: "A migrar a sua instalação para PostgreSQL" user_guides: "Guia do Utilizador" faq: "FAQ (Perguntas Mais Frequentes)" - impressum: "Legal notice" + impressum: "Aviso legal" glossary: "Glossário" shortcuts: "Atalhos" blog: "Blog de OpenProject" @@ -1538,7 +1538,7 @@ pt: journals: changes_retracted: "As mudanças foram retraídas." caused_changes: - default_attribute_written: "Read-only attributes written" + default_attribute_written: "Escrita de atributos só de leitura" dates_changed: "Datas alteradas" system_update: "Atualização do sistema OpenProject:" cause_descriptions: @@ -1919,7 +1919,7 @@ pt: label_member_new: "Novo Membro" label_member_all_admin: "(Todas as funções devidas ao status do administrador)" label_member_plural: "Membros" - label_membership_plural: "Memberships" + label_membership_plural: "Associações" lable_membership_added: "Membro adicionado" lable_membership_updated: "Membro atualizado" label_menu_badge: @@ -2714,9 +2714,9 @@ pt: setting_apiv3_cors_origins: "Partilha de recursos entre origens (CORS) permitidos pela API V3" setting_apiv3_cors_origins_text_html: > Se o CORS estiver habilitado, estas são as origens que têm permissão para aceder ao API OpenProject.
Por favor, verifique a documentação no cabeçalho da Origem sobre como especificar os valores esperados. - setting_apiv3_write_readonly_attributes: "Write access to read-only attributes" + setting_apiv3_write_readonly_attributes: "Acesso de escrita a atributos só de leitura" setting_apiv3_write_readonly_attributes_instructions_html: > - If enabled, the API will allow administrators to write static read-only attributes during creation, such as createdAt and updatedAt timestamps.
Warning: This setting has a use-case for e.g., importing data, but allows administrators to impersonate the creation of items as other users. All creation requests are being logged however with the true author.
For more information on attributes and supported resources, please see the %{api_documentation_link}. + Se estiver ativada, a API irá permitir que os administradores escrevam atributos estáticos só de leitura durante a criação, como os carimbos de data/hora createdAt e updatedAt.
Aviso: esta definição tem um caso de utilização para, por exemplo, importar dados, mas permite que os administradores se façam passar por outros utilizadores na criação de itens. No entanto, todos os pedidos de criação são registados com o verdadeiro autor.
Para obter mais informações sobre os atributos e os recursos suportados, consulte a %{api_documentation_link}. setting_apiv3_max_page_size: "Tamanho máximo da página de API" setting_apiv3_max_page_instructions_html: > Defina o tamanho máximo de página com a qual a API vai responder. Não será possível fazer solicitações de API que retornem mais valores numa única página.
Aviso: Altere este valor apenas se tiver certeza do motivo pelo qual precisa de o fazer. Definir um valor alto resulta em impactos significativos no desempenho, enquanto um valor menor que as opções por página causa erros nas visualizações paginadas. @@ -2740,7 +2740,7 @@ pt: setting_app_subtitle: "Subtítulo da aplicação" setting_app_title: "Título da aplicação" setting_attachment_max_size: "Tamanho máx. do anexo" - setting_antivirus_scan_mode: "Scan mode" + setting_antivirus_scan_mode: "Modo de digitalização" setting_antivirus_scan_action: "Ação para ficheiro infetado" setting_autofetch_changesets: "Alterações do repositório autofetch" setting_autologin: "Autologin" @@ -2750,7 +2750,7 @@ pt: setting_brute_force_block_minutes: "Tempo de bloqueio do utilizador" setting_cache_formatted_text: "Colocar formatação do texto na memória cache" setting_use_wysiwyg_description: "Selecione para ativar o editor WYSIWYG CKEditor5 para todos os utilizadores por padrão. CKEditor tem funcionalidade limitada para GFM Markdown." - setting_column_options: "Default work package lists columns" + setting_column_options: "Colunas de listas de pacotes de trabalho predefinidas" setting_commit_fix_keywords: "Palavras-chave fixas" setting_commit_logs_encoding: "Codificação das mensagens de confirmação" setting_commit_logtime_activity_id: "Atividade para tempo registado" @@ -2772,7 +2772,7 @@ pt: setting_emails_header: "Cabeçalho de e-mails" setting_email_login: "Utilizar o email como início de sessão" setting_enabled_scm: "SCM ativado" - setting_enabled_projects_columns: "Columns in a projects list displayed by default" + setting_enabled_projects_columns: "Colunas numa lista de projetos apresentadas por predefinição" setting_feeds_enabled: "Permitir Feeds" setting_ical_enabled: "Ativar subscrições do iCalendar" setting_feeds_limit: "Limite de conteúdo feed" @@ -2842,25 +2842,25 @@ pt: Defina uma lista de extensões de ficheiros e/ou de tipos mime para ficheiros carregados.
Insira extensões de ficheiro (por exemplo, %{ext_example}) ou tipos mime (ex., %{mime_example}).
Deixe em branco para permitir que qualquer tipo de ficheiro seja carregado. Vários valores permitidos (uma linha para cada valor). antivirus: title: "Verificação de vírus" - clamav_ping_failed: "Failed to connect the the ClamAV daemon. Double-check the configuration and try again." + clamav_ping_failed: "Não foi possível ligar o daemon do ClamAV. Verifique novamente a configuração e tente novamente." remaining_quarantined_files_html: > - Virus scanning has been disbled. %{file_count} remain in quarantine. To review quarantined files, please visit this link: %{link} + A verificação de vírus foi desativada. %{file_count} permanece(m) em quarentena. Para rever os ficheiros colocados em quarentena, aceda a esta ligação: %{link} remaining_scan_complete_html: > - Remaining files have been scanned. There are %{file_count} in quarantine. You are being redirected to the quarantine page. Use this page to delete or override quarantined files. + Os restantes ficheiros foram verificados. %{file_count} encontram-se em quarentena. Está a ser redirecionado para a página de quarentena. Utilize esta página para eliminar ou substituir ficheiros em quarentena. remaining_rescanned_files: > - Virus scanning has been enabled successfuly. There are %{file_count} that were uploaded previously and still need to be scanned. This process has been scheduled in the background. The files will remain accessible during the scan. + A verificação de vírus foi ativada com êxito. Existem %{file_count} que foram carregados anteriormente e que ainda precisam de ser verificados. Este processo foi agendado em segundo plano. Os ficheiros permanecerão acessíveis durante a verificação. upsale: - description: "Ensure uploaded files in OpenProject are scanned for viruses before being accessible by other users." + description: "Assegure-se que os ficheiros carregados no OpenProject são verificados quanto à presença de vírus antes de serem acessíveis a outros utilizadores." actions: delete: "Eliminar ficheiro" quarantine: "Colocar o ficheiro em quarentena" instructions_html: > Seleccione a ação a executar para os ficheiros em que foi detectado um vírus:
  • %{quarantine_option}: Coloque o ficheiro em quarentena, impedindo os utilizadores de lhe acederem. Os administradores podem rever e eliminar ficheiros em quarentena na administração.
  • %{delete_option}: Elimine o ficheiro imediatamente.
modes: - clamav_socket_html: Enter the socket to the clamd daemon, e.g., %{example} - clamav_host_html: Enter the hostname and port to the clamd daemon separated by colon. e.g., %{example} + clamav_socket_html: Introduza o socket para o daemon clamd, por exemplo, %{example} + clamav_host_html: Introduza o nome do anfitrião e a porta para o daemon clamd separados por dois pontos. Por exemplo, %{example} description_html: > - Select the mode in which the antivirus scanner integration should operate.
  • %{disabled_option}: Uploaded files are not scanned for viruses.
  • %{socket_option}: You have set up ClamAV on the same server as OpenProject and the scan daemon clamd is running in the background
  • %{host_option}: You are streaming files to an external virus scanning host.
+ Selecione o modo em que a integração do scanner antivírus deve funcionar.
  • %{disabled_option}: os ficheiros carregados não são verificados quanto a vírus.
  • %{socket_option}: configurou o ClamAV no mesmo servidor que o OpenProject e o daemon de verificação clamd está a ser executado em segundo plano
  • %{host_option}: está a transmitir ficheiros para um anfitrião externo de verificação de vírus.
brute_force_prevention: "Bloqueio de utilizador automatizado" date_format: first_date_of_week_and_year_set: > @@ -3095,7 +3095,7 @@ pt: status_user_and_brute_force: "%{user} e %{brute_force}" status_change: "Alteração de estado" text_change_disabled_for_provider_login: "O nome é definido pelo seu fornecedor de início de sessão, e portanto, não pode ser alterado." - text_change_disabled_for_ldap_login: "The name and email is set by LDAP and can thus not be changed." + text_change_disabled_for_ldap_login: "O nome e o e-mail são definidos pelo LDAP e não podem, portanto, ser alterados." unlock: "Desbloquear" unlock_and_reset_failed_logins: "Desbloquear e redefinir logins errados" version_status_closed: "fechado" diff --git a/config/locales/crowdin/zh-TW.seeders.yml b/config/locales/crowdin/zh-TW.seeders.yml index f0a52d9239f9..2e2d594367b5 100644 --- a/config/locales/crowdin/zh-TW.seeders.yml +++ b/config/locales/crowdin/zh-TW.seeders.yml @@ -218,7 +218,7 @@ zh-TW: name: 里程碑 work_packages: item_0: - subject: Start of project + subject: 專案開始 item_1: subject: Organize open source conference children: diff --git a/config/locales/crowdin/zh-TW.yml b/config/locales/crowdin/zh-TW.yml index 760fbb414b6a..cf74ebd1e785 100644 --- a/config/locales/crowdin/zh-TW.yml +++ b/config/locales/crowdin/zh-TW.yml @@ -77,16 +77,16 @@ zh-TW: buttons: upgrade: "立即更新" contact: "Contact us for a demo" - enterprise_info_html: "is an Enterprise add-on." + enterprise_info_html: "是企業版功能 。" upgrade_info: "Please upgrade to a paid plan to activate and start using it in your team." journal_aggregation: explanation: text: "Individual actions of a user (e.g. updating a work package twice) are aggregated into a single action if their age difference is less than the specified timespan. They will be displayed as a single action within the application. This will also delay notifications by the same amount of time reducing the number of emails being sent and will also affect %{webhook_link} delay." link: "webhook" announcements: - show_until: 顯示直到 + show_until: 只顯示到 is_active: 當前已顯示 - is_inactive: 當前未顯示 + is_inactive: 尚未顯示 antivirus_scan: not_processed_yet_message: "Downloading is blocked, as file was not scanned for viruses yet. Please try again later." quarantined_message: "A virus was detected in file '%{filename}'. It has been quarantined and is not available for download." @@ -202,7 +202,7 @@ zh-TW: description: "Custom actions are one-click shortcuts to a set of pre-defined actions that you can make available on certain work packages based on status, role, type or project." custom_fields: text_add_new_custom_field: > - 要加入自訂欄位至一個專案,您必須先建立欄位才能將它們加入至此專案。 + 要加入客製欄位至一個專案,您必須先建立欄位才能將它們加入至此專案。 is_enabled_globally: "已全域啟用" enabled_in_project: "已在專案中啟用" contained_in_type: "已包含在類型中" @@ -210,9 +210,9 @@ zh-TW: reorder_alphabetical: "Reorder values alphabetically" reorder_confirmation: "Warning: The current order of available values will be lost. Continue?" instructions: - is_required: "Mark the custom field as required. This will make it mandatory to fill in the field when creating new or updating existing resources." - is_for_all: "將自訂欄位標記為「所有現有專案」和「新專案」中可用。" - searchable: "Include the field values when using the global search functionality." + is_required: "將此欄位定義成必填。\n" + is_for_all: "將客製欄位標記為「所有現有專案」和「新專案」中可用。" + searchable: "使用全域搜尋功能時包含此欄位。" editable: "允許使用者自行編輯此欄位" visible: "Make field visible for all users (non-admins) in the project overview and displayed in the project details widget on the Project Overview." is_filter: > @@ -281,7 +281,7 @@ zh-TW: no_results_title_text: 目前沒有工作項目類別 no_results_content_text: 建立一個新工作項目類別 custom_fields: - no_results_title_text: 目前沒有自訂欄位可用。 + no_results_title_text: 目前沒有客製欄位可用。 types: no_results_title_text: 目前沒有可用的類型 form: @@ -422,7 +422,7 @@ zh-TW: copy_failed: "此工作項目無法被複製" move_failed: "此工作項目無法被移動" could_not_be_saved: "以下文檔無法被保存" - none_could_be_saved: "None of the %{total} work packages could be updated." + none_could_be_saved: " %{total} 工作項目無法更新" x_out_of_y_could_be_saved: "%{failing} out of the %{total} work packages could not be updated while %{success} could." selected_because_descendants: "While %{selected} work packages where selected, in total %{total} work packages are affected which includes descendants." descendant: "descendant of selected" @@ -485,7 +485,7 @@ zh-TW: activerecord: attributes: announcements: - show_until: "顯示直到" + show_until: "只顯示到" attachment: attachment_content: "附件內容" attachment_file_name: "附件檔案名稱" @@ -526,7 +526,7 @@ zh-TW: min_length: "最小長度" multi_value: "允許多選" possible_values: "可能的值" - regexp: "正則運算式" + regexp: "正規表示式" searchable: "搜索" visible: "可見" custom_value: @@ -849,7 +849,7 @@ zh-TW: group_by_hierarchies_exclusive: "與按照 \"%{group_by}\" 分組的群組相互排斥。不能同時啟動兩者。" filters: custom_fields: - inexistent: "篩選條件沒有自訂欄位。" + inexistent: "篩選條件無客製欄位。" queries/filters/base: attributes: values: @@ -982,7 +982,7 @@ zh-TW: principal: unassignable: "無法分派到專案。" version: - undeletable_archived_projects: "The version cannot be deleted as it has work packages attached to it." + undeletable_archived_projects: "該版本無法刪除,因為包含工作項目。" undeletable_work_packages_attached: "The version cannot be deleted as it has work packages attached to it." status: readonly_default_exlusive: "can not be activated for statuses that are marked default." @@ -996,7 +996,7 @@ zh-TW: category: "類別" comment: "留言" custom_action: "自訂動作" - custom_field: "自訂欄位" + custom_field: "客製欄位" "doorkeeper/application": "Oauth 應用程式" forum: "討論區" global_role: "Global role" @@ -1055,8 +1055,8 @@ zh-TW: #common attributes of all models attributes: active: "啟用" - assigned_to: "負責執行者" - assignee: "負責執行者" + assigned_to: "執行者" + assignee: "執行者" attachments: "附加檔" author: "作者" base: "一般錯誤:" @@ -1068,12 +1068,12 @@ zh-TW: color: "顏色" created_at: "建立於" custom_options: "可能的值" - custom_values: "自訂欄位" + custom_values: "客製欄位" date: "日期" default_columns: "預設欄" description: "說明" derived_due_date: "Derived finish date" - derived_estimated_hours: "Total work" + derived_estimated_hours: "總工時" derived_start_date: "Derived start date" display_sums: "顯示加總" due_date: "完成日期" @@ -1096,7 +1096,7 @@ zh-TW: password: "密碼" priority: "優先等級" project: "專案" - responsible: "可信賴的" + responsible: "負責人" role: "角色" roles: "角色" start_date: "起始日期" @@ -1127,7 +1127,7 @@ zh-TW: implications: > Enabling backups will allow any user with the required permissions and this backup token to download a backup containing all data of this OpenProject installation. This includes the data of all other users. info: > - You will need to generate a backup token to be able to create a backup. Each time you want to request a backup you will have to provide this token. You can delete the backup token to disable backups for this user. + 您將需要產生備份token才能建立備份。 每次您想要請求備份時,您都必須提供此token。 您可以刪除備份token以停用該備份。 verification: > Enter %{word} to confirm you want to %{action} the backup token. verification_word_reset: 重置 @@ -1213,15 +1213,15 @@ zh-TW: consent: checkbox_label: 我已注意到並同意上述情況。 failure_message: 同意失敗, 無法繼續。 - title: 使用者同意資料使用 + title: 使用者同意個資意向書 decline_warning_message: 您已拒絕同意並已登出。 user_has_consented: 使用者已同意您在給定時間配置的語句。 not_yet_consented: 使用者尚未同意, 將在下次登錄時請求。 - contact_mail_instructions: 使用者可以提出更改或移除郵寄地址。 + contact_mail_instructions: 使用者可透過系統更改或移除郵寄地址。 contact_your_administrator: 如果您想刪除您的帳戶, 請與您的管理員聯繫。 contact_this_mail_address: 如果要刪除帳戶, 請聯繫 %{mail_address}。 - text_update_consent_time: 若更改上述資訊時,請使用者再次允許。 - update_consent_last_time: "允許時間: %{update_time}" + text_update_consent_time: 若更改上述資訊時,請使用者再次同意。 + update_consent_last_time: "同意日期: %{update_time}" copy_project: title: '複製專案 %{source_project_name}' started: '開始複製專案從「%{source_project_name}」到 「%{target_project_name}」。當「%{target_project_name}」 可以使用以後,我們會盡快用郵件通知您。' @@ -1229,7 +1229,7 @@ zh-TW: failed_internal: "內部錯誤導致複製失敗。" succeeded: "專案 %{target_project_name} 建立成功" errors: "錯誤" - project_custom_fields: "專案上的自訂欄位" + project_custom_fields: "專案上的客製欄位" x_objects_of_this_type: zero: "No objects of this type" one: "One object of this type" @@ -1381,7 +1381,7 @@ zh-TW: error_auth_source_sso_failed: "單一登入 (SSO) 使用者 '%{value}' 失敗" error_can_not_archive_project: "這個專案無法封存:%{errors}" error_can_not_delete_entry: "無法刪除項目" - error_can_not_delete_custom_field: "無法刪除自訂欄位" + error_can_not_delete_custom_field: "無法刪除客製欄位" error_can_not_delete_in_use_archived_undisclosed: "There are also work packages in archived projects. You need to ask an administrator to perform the deletion to see which projects are affected." error_can_not_delete_in_use_archived_work_packages: "There are also work packages in archived projects. You need to reactivate the following projects first, before you can change the attribute of the respective work packages: %{archived_projects_urls}" error_can_not_delete_type: @@ -1684,7 +1684,7 @@ zh-TW: label_calendars_and_dates: "行事曆與日期" label_calendar_show: "顯示行事曆" label_category: "類別" - label_consent_settings: "使用者同意" + label_consent_settings: "使用者同意個資意向書" label_wiki_menu_item: 維基選單項目 label_select_main_menu_item: 選擇新的主選單項目 label_required_disk_storage: "所使用的磁碟空間" @@ -1730,8 +1730,8 @@ zh-TW: label_current_status: "目前狀態" label_current_version: "目前版本" label_custom_field_add_no_type: "新增此欄位至一個工作項目類別" - label_custom_field_new: "新自訂欄位" - label_custom_field_plural: "自訂欄位" + label_custom_field_new: "新增欄位" + label_custom_field_plural: "客製欄位" label_custom_field_default_type: "空類型" label_custom_style: "設計" label_dashboard: "儀表板" @@ -1747,7 +1747,7 @@ zh-TW: label_delete_user: "刪除使用者" label_delete_project: "刪除專案" label_deleted: "刪除線" - label_deleted_custom_field: "(已刪除的自訂欄位)" + label_deleted_custom_field: "(已刪除的客製欄位)" label_deleted_custom_option: "(deleted option)" label_empty_element: "(空)" label_missing_or_hidden_custom_option: "(missing value or lacking permissions to access)" @@ -1772,7 +1772,7 @@ zh-TW: label_edit: "編輯" label_edit_x: "Edit: %{x}" label_enable_multi_select: "取用複選" - label_enabled_project_custom_fields: "開啟自訂欄位" + label_enabled_project_custom_fields: "開啟客製欄位" label_enabled_project_modules: "啟用的模組" label_enabled_project_activities: "啟用的時間追蹤活動" label_end_to_end: "尾端到尾端" @@ -1877,11 +1877,11 @@ zh-TW: label_logged_as: "登入為" label_login: "登入" label_custom_logo: "自訂logo" - label_custom_export_logo: "Custom export logo" + label_custom_export_logo: "客製匯出的Logo" label_custom_export_cover: "Custom export cover background" label_custom_export_cover_overlay: "Custom export cover background overlay" label_custom_export_cover_text_color: "文字顏色" - label_custom_pdf_export_settings: "Custom PDF export settings" + label_custom_pdf_export_settings: "匯出PDF設定" label_custom_favicon: "自訂圖示" label_custom_touch_icon: "自訂觸控圖示" label_logout: "登出" @@ -2270,7 +2270,7 @@ zh-TW: create_account: "To access this work package, you will need to create and activate an account on %{instance}." open_work_package: "開啟工作項目" subject: "有個工作項目 #%{id} 分享給您" - enterprise_text: "Share work packages with users who are not members of the project." + enterprise_text: "與不是專案成員的使用者共用工作項目。" summary: user: "%{user} 分享一個工作項目給您,權限:%{role_rights}\"" group: "%{user} 分享一個工作項目給您所在的群組: %{group}" @@ -2411,7 +2411,7 @@ zh-TW: notice_automatic_set_of_standard_type: "自動設定標準類型" notice_logged_out: "您已登出" notice_wont_delete_auth_source: The LDAP connection cannot be deleted as long as there are still users using it. - notice_project_cannot_update_custom_fields: "無法更新專案的可用自訂欄位。專案無效:%{errors}" + notice_project_cannot_update_custom_fields: "無法更新專案的可用客製欄位。專案無效:%{errors}" notice_attachment_migration_wiki_page: > 此頁面是因為更新 OpenProject 而自動產生的。他包含了所有之前與 %{container_type} "%{container_name}" 有關聯的附加檔案。 #Default format for numbers @@ -2564,9 +2564,9 @@ zh-TW: #which are not attributes of an AR-Model. query_fields: active_or_archived: "啟用或封存" - assigned_to_role: "負責執行者的角色" + assigned_to_role: "執行者的角色" assignee_or_group: "被指派或所屬的人或群組" - member_of_group: "負責執行者的群組" + member_of_group: "執行者的群組" name_or_identifier: "名稱或識別碼" only_subproject_id: "只限子專案" shared_with_user: "分配給成員" @@ -2730,10 +2730,10 @@ zh-TW: setting_commit_logtime_activity_id: "紀錄時間的動作" setting_commit_logtime_enabled: "啟用時間日誌" setting_commit_ref_keywords: "參照關鍵字" - setting_consent_time: "允許時間" + setting_consent_time: "同意日期" setting_consent_info: "條文" setting_consent_required: "是否需要使用者允許" - setting_consent_decline_mail: "允許列在通訊錄" + setting_consent_decline_mail: "同意郵件列在通訊錄" setting_cross_project_work_package_relations: "允許跨專案的工作項目關聯" setting_first_week_of_year: "一年的第一週從哪一天開始" setting_date_format: "日期" @@ -2903,9 +2903,9 @@ zh-TW: text_comment_wiki_page: "Wiki 頁面(%{page})的評論" text_custom_field_possible_values_info: "每個值一行" text_custom_field_hint_activate_per_project: > - 當使用自訂欄位:請留意自訂欄位需要分別被每個專案個別啟用。 + 當使用客製欄位:請留意欄位需要依專案個別啟用。 text_custom_field_hint_activate_per_project_and_type: > - 自訂欄位需要個別被工作項目類別和專案啟用。 + 客製欄位需要個別被工作項目類別和專案啟用。 text_wp_status_read_only_html: > 企業版將提供額外模組到工作項目:
  • 允許將特定狀態的工作項目設定為唯讀
text_project_custom_field_html: > @@ -2913,13 +2913,13 @@ zh-TW: text_custom_logo_instructions: > 建議使用背景透明的白色標誌。為了確保在一般及視網膜螢幕上有最佳效果,請確保您的圖片解析度在 460px X 60px。 text_custom_export_logo_instructions: > - This is the logo that appears in your PDF exports. It needs to be a PNG or JPEG image file. A black or colored logo on transparent or white background is recommended. + 這是出現在 PDF 匯出中的Logo。 它必須是 PNG 或 JPEG 影像檔。 建議在透明或白色背景上使用黑色或彩色標誌。 text_custom_export_cover_instructions: > - This is the image that appears in the background of a cover page in your PDF exports. It needs to be an about 800px width by 500px height sized PNG or JPEG image file. + 這是出現在 PDF 匯出的封面背景中的圖像。 它需要是大約 800 像素寬 x 500 像素高大小的 PNG 或 JPEG 影像檔案。 text_custom_favicon_instructions: > - This is the tiny icon that appears in your browser window/tab next to the page's title. It needs to be a squared 32 by 32 pixels sized PNG image file with a transparent background. + 這是顯示在您的瀏覽器視窗/標籤頁的標題旁邊的小圖示。它必需要是 32px X 32px 大小背景透明的 PNG 圖像檔。 text_custom_touch_icon_instructions: > - This is the icon that appears in your mobile or tablet when you place a bookmark on your homescreen. It needs to be a squared 180 by 180 pixels sized PNG image file. Please make sure the image's background is not transparent otherwise it will look bad on iOS. + 這個圖示將會顯示在您的手機或平板的主畫面中。它必須是 180px X 180px 大小的 PNG 圖檔。請確保圖片的背景不是透明的,否則在 iOS 上會看起來很醜。 text_database_allows_tsv: "資料庫允許 TSVector (非必要)" text_default_administrator_account_changed: "預設管理者帳號已更改" text_default_encoding: "預設值: UTF-8" @@ -3138,7 +3138,7 @@ zh-TW: view: "檢視" view_description: "查看此工作項目" remove: "刪除" - share: "分享" + share: "共享" text_empty_search_description: "There are no users with the current filter criteria." text_empty_search_header: "We couldn't find any matching results." text_empty_state_description: "此工作項目尚未與任何人分享" @@ -3158,7 +3158,7 @@ zh-TW: invite_resent: "已重傳邀請" not_project_member: "非專案的成員" project_group: "Group members might have additional privileges (as project members)" - not_project_group: "Group (shared with all members)" + not_project_group: "成員共享群組" additional_privileges_project: "Might have additional privileges (as project member)" additional_privileges_group: "Might have additional privileges (as group member)" additional_privileges_project_or_group: "Might have additional privileges (as project or group member)" diff --git a/config/locales/en.yml b/config/locales/en.yml index 07df1e411386..b149931946a7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -315,6 +315,18 @@ en: no_results_content_text: Create a new work package category custom_fields: no_results_title_text: There are currently no custom fields available. + project_custom_fields: + header: + title: "Project attributes" + description: "These project attributes will be displayed in your project overview page under their respective sections. You can enable or disable individual attributes. +Project attributes and sections are defined in the administration settings by the administrator of the instance. " + filter: + label: "Search project attribute" + actions: + label_enable_single: "Active in this project, click to disable" + label_disable_single: "Inactive in this project, click to enable" + label_enable_all: "Enable all" + label_disable_all: "Disable all" types: no_results_title_text: There are currently no types available. form: @@ -709,6 +721,8 @@ en: redirect_existing_links: "Redirect existing links" planning_element_type_color: hexcode: Hex code + project_custom_field: + custom_field_section: Section work_package: begin_insertion: "Begin of the insertion" begin_deletion: "Begin of the deletion" @@ -1953,6 +1967,7 @@ en: label_ifc_model_plural: "Ifc Models" label_import: "Import" label_export_to: "Also available in:" + label_expand: "Expand" label_expanded_click_to_collapse: "Expanded. Click to collapse" label_f_hour: "%{value} hour" label_f_hour_plural: "%{value} hours" @@ -2160,7 +2175,10 @@ en: label_project_hierarchy: "Project hierarchy" label_project_new: "New project" label_project_plural: "Projects" + label_project_attributes_plural: "Project attributes" + label_project_custom_field_plural: "Project attributes" label_project_settings: "Project settings" + label_project_attributes_settings: "Project attributes settings" label_project_storage_plural: "File Storages" label_project_storage_project_folder: "File Storages: Project folders" label_projects_storage_information: "%{count} projects using %{storage} disk storage" @@ -2678,6 +2696,7 @@ en: permission_save_queries: "Save views" permission_search_project: "Search project" permission_select_custom_fields: "Select custom fields" + permission_select_project_custom_fields: "Select project attributes" permission_select_project_modules: "Select project modules" permission_share_work_packages: "Share work packages" permission_manage_types: "Select types" @@ -2729,6 +2748,10 @@ en: archive: are_you_sure: "Are you sure you want to archive the project '%{name}'?" archived: "Archived" + count: + zero: "0 Projects" + one: "1 Project" + other: "%{count} Projects" project_module_activity: "Activity" project_module_forums: "Forums" @@ -3087,6 +3110,20 @@ en: delay_minutes_explanation: "Email sending can be delayed to allow users with configured in app notification to confirm the notification within the application before a mail is sent out. Users who read a notification within the application will not receive an email for the already read notification." other: "Other" passwords: "Passwords" + project_attributes: + heading: "Project attributes" + label_new_attribute: "Project attribute" + label_new_section: "Section" + label_edit_section: "Edit title" + label_section_actions: "Section actions" + heading_description: "These project attributes appear in the overview page of each project. You can add new attributes, group them into sections and re-order them as you please. These attributes can be enabled or disabled but not re-ordered at a project level." + label_project_custom_field_actions: "Project attribute actions" + label_no_project_custom_fields: "No project attributes defined in this section" + edit: + description: "Changes to this project attribute will be reflected in all projects where it is enabled. Required attributes cannot be disabled on a per-project basis." + new: + heading: "New attribute" + description: "Changes to this project attribute will be reflected in all projects where it is enabled. Required attributes cannot be disabled on a per-project basis." projects: missing_dependencies: "Project module %{module} was checked which depends on %{dependencies}. You need to check these dependencies as well." section_new_projects: "Settings for new projects" diff --git a/config/routes.rb b/config/routes.rb index 89c4963a26d3..bdf07d65f68b 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -190,6 +190,15 @@ resource :general, only: %i[show], controller: "general" resource :modules, only: %i[show update] resource :types, only: %i[show update] + resource :project_custom_fields, only: %i[show] do + member do + post :toggle + end + collection do + put :enable_all_of_section + put :disable_all_of_section + end + end resource :custom_fields, only: %i[show update] resource :repository, only: %i[show], controller: "repository" resource :versions, only: %i[show] @@ -435,6 +444,22 @@ resource :mail_notifications, controller: "/admin/settings/mail_notifications_settings", only: %i[show update] resource :api, controller: "/admin/settings/api_settings", only: %i[show update] resource :work_packages, controller: "/admin/settings/work_packages_settings", only: %i[show update] + resource :projects, controller: "/admin/settings/projects_settings", only: %i[show update] + resources :project_custom_fields, controller: "/admin/settings/project_custom_fields" do + member do + delete "options/:option_id", action: "delete_option", as: :delete_option_of + post :reorder_alphabetical + put :move + put :drop + end + end + resources :project_custom_field_sections, controller: "/admin/settings/project_custom_field_sections", + only: %i[create update destroy] do + member do + put :move + put :drop + end + end resource :working_days, controller: "/admin/settings/working_days_settings", only: %i[show update] resource :users, controller: "/admin/settings/users_settings", only: %i[show update] resource :date_format, controller: "/admin/settings/date_format_settings", only: %i[show update] diff --git a/db/migrate/20190507132517_add_board_view_to_roles.rb b/db/migrate/20190507132517_add_board_view_to_roles.rb index e407292e2ada..42583fd0f181 100644 --- a/db/migrate/20190507132517_add_board_view_to_roles.rb +++ b/db/migrate/20190507132517_add_board_view_to_roles.rb @@ -34,7 +34,7 @@ def up .add(:view_work_packages, :show_board_views) - unless Setting.default_projects_modules.include?("board_view") + if Setting.default_projects_modules_writable? && Setting.default_projects_modules&.exclude?("board_view") Setting.default_projects_modules = Setting.default_projects_modules + ["board_view"] end end diff --git a/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb b/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb new file mode 100644 index 000000000000..424625295314 --- /dev/null +++ b/db/migrate/20231031133334_create_project_custom_field_project_mappings.rb @@ -0,0 +1,34 @@ +class CreateProjectCustomFieldProjectMappings < ActiveRecord::Migration[7.0] + def up + create_table :project_custom_field_project_mappings do |t| + t.references :custom_field, foreign_key: true, index: { + name: 'index_project_cf_project_mappings_on_custom_field_id' + } + t.references :project, foreign_key: true + + t.timestamps + end + + create_default_mapping + end + + def down + drop_table :project_custom_field_project_mappings + end + + private + + def create_default_mapping + project_ids = Project.pluck(:id) + custom_field_ids = ProjectCustomField.pluck(:id) + mappings = [] + + project_ids.each do |project_id| + custom_field_ids.each do |custom_field_id| + mappings << { custom_field_id:, project_id: } + end + end + + ProjectCustomFieldProjectMapping.create!(mappings) + end +end diff --git a/db/migrate/20231123111357_create_custom_field_sections.rb b/db/migrate/20231123111357_create_custom_field_sections.rb new file mode 100644 index 000000000000..5d2d259cc1e1 --- /dev/null +++ b/db/migrate/20231123111357_create_custom_field_sections.rb @@ -0,0 +1,36 @@ +class CreateCustomFieldSections < ActiveRecord::Migration[7.0] + def up + create_table :custom_field_sections do |t| + t.integer :position + t.string :name + t.string :type # project or nil (-> work_package) + + t.timestamps + end + + add_reference :custom_fields, :custom_field_section + add_column :custom_fields, :position_in_custom_field_section, :integer, null: true + + create_and_assign_default_section + end + + def down + remove_reference :custom_fields, :custom_field_section + remove_column :custom_fields, :position_in_custom_field_section + drop_table :custom_field_sections + end + + private + + def create_and_assign_default_section + # for project custom fields only + section = ProjectCustomFieldSection.create!( + name: "Project attributes" + ) + + # trigger acts_as_list callbacks via updating each record instead of bulk update + ProjectCustomField.find_each do |project_custom_field| + project_custom_field.update!(custom_field_section_id: section.id) + end + end +end diff --git a/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb b/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb index 80ce0a01129c..2e745e036b5f 100644 --- a/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb +++ b/db/migrate/20240201115019_add_gantt_module_to_default_modules.rb @@ -1,6 +1,6 @@ class AddGanttModuleToDefaultModules < ActiveRecord::Migration[7.0] def up - unless Setting.default_projects_modules.include?("gantt") + if Setting.default_projects_modules_writable? && Setting.default_projects_modules&.exclude?("gantt") Setting.default_projects_modules = Setting.default_projects_modules + ["gantt"] end end diff --git a/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb new file mode 100644 index 000000000000..9168d770fb80 --- /dev/null +++ b/db/migrate/20240208100316_enable_required_project_custom_fields_in_all_projects.rb @@ -0,0 +1,29 @@ +class EnableRequiredProjectCustomFieldsInAllProjects < ActiveRecord::Migration[7.1] + def up + required_custom_field_ids = ProjectCustomField.required.ids + + # Gather the custom_field_ids for every project, then add a new mapping + # of {project_id:, custom_field_id:} for every project that does not have + # the required required_custom_field_ids activated. + missing_custom_field_attributes = + Project + .includes(:project_custom_field_project_mappings) + .pluck(:id, 'project_custom_field_project_mappings.custom_field_id') + .group_by(&:first) + .transform_values { |values| values.map(&:last) } + .reduce([]) do |acc, (project_id, custom_field_ids)| + + missing_custom_field_ids = required_custom_field_ids - custom_field_ids + + acc + missing_custom_field_ids.map do |custom_field_id| + { project_id: , custom_field_id: } + end + end + + ProjectCustomFieldProjectMapping.insert_all!(missing_custom_field_attributes) + end + + def down + # reversing this migration is not possible as we don't store the original state + end +end diff --git a/docs/development/ldap/README.md b/docs/development/ldap/README.md new file mode 100644 index 000000000000..2f3a9dfb3fcd --- /dev/null +++ b/docs/development/ldap/README.md @@ -0,0 +1,31 @@ +--- +sidebar_navigation: + title: LDAP development setup + priority: 920 +--- + +# Set up a development LDAP server + +**Note:** This guide is targeted only at development with OpenProject. For the LDAP configuration guide, please see this [here](../../system-admin-guide/authentication/ldap-authentication/) + + +OpenProject comes with a built-in LDAP server for development purposes. This server uses [ladle gem](https://github.com/NUBIC/ladle) +to run an underlying apacheDS server. + +This guide will show you how to set it up in your development instance. + +## Prerequisites + +- A local java/JRE environment installed (openjdk, java installed via homebrew, etc.) +- A development setup of OpenProject (or any other configurable installation) + +## Running the LDAP server + +You only need to run this rake task to start the server: + +```bash +./bin/rails ldap_groups:development:ldap_server +``` + +It will both output the different users and groups, as well as connection details. Starting this task will ensure +an LDAP connection is created or updated to make sure you can use it right away. diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 916c75b717ae..fd78e766c3a9 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -20474,9 +20474,9 @@ } }, "node_modules/webpack-dev-server/node_modules/webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "dependencies": { "colorette": "^2.0.10", "memfs": "^3.4.3", @@ -35863,9 +35863,9 @@ } }, "webpack-dev-middleware": { - "version": "5.3.3", - "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.3.tgz", - "integrity": "sha512-hj5CYrY0bZLB+eTO+x/j67Pkrquiy7kWepMHmUMoPsmcUaeEnQJqFzHJOyxgWlq746/wUuA64p9ta34Kyb01pA==", + "version": "5.3.4", + "resolved": "https://registry.npmjs.org/webpack-dev-middleware/-/webpack-dev-middleware-5.3.4.tgz", + "integrity": "sha512-BVdTqhhs+0IfoeAf7EoH5WE+exCmqGerHfDM0IL096Px60Tq2Mn9MAbnaGUe6HiMa41KMCYF19gyzZmBcq/o4Q==", "requires": { "colorette": "^2.0.10", "memfs": "^3.4.3", diff --git a/frontend/src/app/features/overview/overview.component.ts b/frontend/src/app/features/overview/overview.component.ts index 93b900c79c21..9746c96c12a5 100644 --- a/frontend/src/app/features/overview/overview.component.ts +++ b/frontend/src/app/features/overview/overview.component.ts @@ -13,7 +13,19 @@ export class OverviewComponent extends GridPageComponent { return 'overviews'; } + protected isTurboFrameSidebarEnabled():boolean { + return true; + } + + protected turboFrameSidebarSrc():string { + return `${this.pathHelper.staticBase}/projects/${this.currentProject.identifier ?? ''}/project_custom_fields_sidebar`; + } + + protected turboFrameSidebarId():string { + return 'project-custom-fields-sidebar'; + } + protected gridScopePath():string { - return this.pathHelper.projectPath(this.currentProject.identifier!); + return this.pathHelper.projectPath(this.currentProject.identifier ?? ''); } } diff --git a/frontend/src/app/features/projects/components/new-project/new-project.component.html b/frontend/src/app/features/projects/components/new-project/new-project.component.html index bea81db1c3f1..d3abe682b053 100644 --- a/frontend/src/app/features/projects/components/new-project/new-project.component.html +++ b/frontend/src/app/features/projects/components/new-project/new-project.component.html @@ -2,7 +2,7 @@ class="op-form" [formGroup]="templateForm" > -
+
+ + + boolean; - @Input() public trackByFn ? = null; + @Input() public trackByFn = this.defaultTrackByFunction(); - @Input() public compareWith ? = (a:unknown, b:unknown):boolean => a === b; + @Input() public compareWith = this.defaultCompareWithFunction(); @Input() public clearOnBackspace?:boolean = true; @@ -303,7 +305,7 @@ export class OpAutocompleterComponent { this.model = resource as unknown as T; this.syncHiddenField(this.mappedInputValue); @@ -345,13 +347,13 @@ export class OpAutocompleterComponent el[this.inputBindValue as 'id']).join(','); + return this.model.map((el) => el[this.inputBindValue as 'id'] as string); } return this.model[this.inputBindValue as 'id'] as string; @@ -537,10 +539,10 @@ export class OpAutocompleterComponent unknown)|null { + return null; + } + + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { + return (a, b) => a === b; + } } diff --git a/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts b/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts index 055da1999dfe..835d7cd402f5 100644 --- a/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts +++ b/frontend/src/app/shared/components/autocompleter/op-autocompleter/services/op-autocompleter.service.ts @@ -8,7 +8,7 @@ import { ApiV3WorkPackagePaths } from 'core-app/core/apiv3/endpoints/work_packag import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { Observable } from 'rxjs'; +import { forkJoin, Observable } from 'rxjs'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; @Injectable() @@ -33,7 +33,17 @@ export class OpAutocompleterService extends UntilDestroyedMixin { } // A method for fetching the object for a provided value using the API - public loadValue(id:string, resource:TOpAutocompleterResource):Observable { + public loadValue(id:string|string[], resource:TOpAutocompleterResource, multiple:boolean):Observable { + if (multiple) { + const calls = (id as string[]) + .map((singleId) => this.loadSingleValue(singleId, resource)); + return forkJoin(calls); + } + + return this.loadSingleValue(id as string, resource); + } + + protected loadSingleValue(id:string, resource:TOpAutocompleterResource) { return (this.apiV3Service[resource] as ApiV3ResourceCollection) .id(id) diff --git a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts index 86ffcf98a837..c8a3e3a612d0 100644 --- a/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts +++ b/frontend/src/app/shared/components/autocompleter/user-autocompleter/user-autocompleter.component.ts @@ -44,6 +44,7 @@ import { UserAutocompleterTemplateComponent, } from 'core-app/shared/components/autocompleter/user-autocompleter/user-autocompleter-template.component'; import { IUser } from 'core-app/core/state/principals/user.model'; +import { compareByAttribute } from 'core-app/shared/helpers/angular/tracking-functions'; export const usersAutocompleterSelector = 'op-user-autocompleter'; @@ -134,4 +135,12 @@ export class UserAutocompleterComponent extends OpAutocompleterComponent unknown|null { + return (item) => item.href || item.name; + } + + protected defaultCompareWithFunction():(a:unknown, b:unknown) => boolean { + return compareByAttribute('href', 'name'); + } } diff --git a/frontend/src/app/shared/components/forms/form.sass b/frontend/src/app/shared/components/forms/form.sass index b0d640fddf39..320267dedb3a 100644 --- a/frontend/src/app/shared/components/forms/form.sass +++ b/frontend/src/app/shared/components/forms/form.sass @@ -20,13 +20,18 @@ > .spot-form-field, > .spot-selector-field, > .op-option-list, - > .op-primaryed-input, + > .op-highlighted-input, > .button &:not(:last-child) margin-bottom: 1rem + &--fieldset, &--section-header - margin-top: 0.5rem + width: 100% + margin-top: 1.5rem + + &:first-child + margin-top: 0 &--section-header-title @extend %form--fieldset-legend-or-section-title diff --git a/frontend/src/app/shared/components/forms/highlighted-input.sass b/frontend/src/app/shared/components/forms/highlighted-input.sass index bfc976f06ed5..399d9ec59267 100644 --- a/frontend/src/app/shared/components/forms/highlighted-input.sass +++ b/frontend/src/app/shared/components/forms/highlighted-input.sass @@ -1,4 +1,4 @@ -.op-primaryed-input +.op-highlighted-input padding: 1rem 1rem 0.5rem 0.75rem display: flex flex-direction: column diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html index 7a98248adcb9..adec64191cbe 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.html @@ -21,6 +21,27 @@

- +
+
+ +
+
+ + + + + + + + + + + +
+
+ +
+ +
diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass index c9a867a68b69..70df6265df3f 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.sass @@ -13,7 +13,31 @@ &--toolbar-items padding-right: 20px + &--grid-container + display: grid + // grid-template-columns: auto 324px + // firefox issue when using auto or 1fr + // inner content width of the main-content is calculated dynamically + // chrome and firefox seem to come to different results + // temporary fix: use more specific width rules, e.g. 75% and 25% + // same issue with flex-box approach + grid-template-columns: 75% 25% + gap: 20px + + &--sidebar + margin-top: 20px + margin-right: 40px + @media only screen and (max-width: $breakpoint-sm) .op-grid-page--title-container margin-left: 0 margin-bottom: 0 + +@media only screen and (max-width: $breakpoint-lg) + .op-grid-page--grid-container + grid-template-columns: 100% + gap: 0 + + .op-grid-page--sidebar + diplay: none + diff --git a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts index b8a738012aad..a56368d46d95 100644 --- a/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts +++ b/frontend/src/app/shared/components/grids/grid/page/grid-page.component.ts @@ -9,6 +9,7 @@ import { GridResource } from 'core-app/features/hal/resources/grid-resource'; import { GridAddWidgetService } from 'core-app/shared/components/grids/grid/add-widget.service'; import { GridAreaService } from 'core-app/shared/components/grids/grid/area.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; @Directive() export abstract class GridPageComponent implements OnInit, OnDestroy { @@ -17,7 +18,8 @@ export abstract class GridPageComponent implements OnInit, OnDestroy { html_title: this.i18n.t(`js.${this.i18nNamespace()}.label`), }; - constructor(readonly gridInitialization:GridInitializationService, + constructor( + readonly gridInitialization:GridInitializationService, // not used in the base class but will be used throughout the subclasses readonly pathHelper:PathHelperService, readonly currentProject:CurrentProjectService, @@ -26,10 +28,17 @@ export abstract class GridPageComponent implements OnInit, OnDestroy { readonly title:Title, readonly addWidget:GridAddWidgetService, readonly renderer:Renderer2, - readonly areas:GridAreaService) {} + readonly areas:GridAreaService, + readonly configurationService:ConfigurationService, + ) {} public grid:GridResource; + protected isTurboFrameSidebarEnabled():boolean { + // may be overridden by subclasses + return false; + } + ngOnInit() { this.renderer.addClass(document.body, 'widget-grid-layout'); this diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html index 5e35609e7036..6474c838dde0 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.html @@ -9,22 +9,11 @@
- -
- -
- {{ cf.label }} - -
-
- -
-
-
-
+

+ Project details have now moved to a column on the right edge of this page. +

+

+ Starting with version 13.5, project attributes can be grouped in sections and enabled and disabled at a project level. Learn more +

+ This widget can now be removed or replaced. It will be deleted in subsequent versions.
diff --git a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts index e6caae2e84c4..ba1022c6eb0d 100644 --- a/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts +++ b/frontend/src/app/shared/components/grids/widgets/project-details/project-details.component.ts @@ -32,81 +32,31 @@ import { Component, ElementRef, Injector, - OnInit, ViewChild, } from '@angular/core'; import { AbstractWidgetComponent } from 'core-app/shared/components/grids/widgets/abstract-widget.component'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { CurrentProjectService } from 'core-app/core/current-project/current-project.service'; -import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; -import { Observable } from 'rxjs'; -import { ProjectResource } from 'core-app/features/hal/resources/project-resource'; -import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; -import { SchemaAttributeObject } from 'core-app/features/hal/resources/schema-attribute-object'; @Component({ templateUrl: './project-details.component.html', changeDetection: ChangeDetectionStrategy.OnPush, - providers: [ - HalResourceEditingService, - ], }) -export class WidgetProjectDetailsComponent extends AbstractWidgetComponent implements OnInit { +export class WidgetProjectDetailsComponent extends AbstractWidgetComponent { @ViewChild('contentContainer', { static: true }) readonly contentContainer:ElementRef; - public customFields:{ key:string, label:string }[] = []; - - public project$:Observable; - - constructor(protected readonly i18n:I18nService, + constructor( + protected readonly i18n:I18nService, protected readonly injector:Injector, protected readonly apiV3Service:ApiV3Service, protected readonly currentProject:CurrentProjectService, - protected readonly cdRef:ChangeDetectorRef) { + protected readonly cdRef:ChangeDetectorRef, + ) { super(i18n, injector); } - ngOnInit():void { - this.loadAndRender(); - if (this.currentProject.id) { - this.project$ = this - .apiV3Service - .projects - .id(this.currentProject.id) - .requireAndStream(); - } - } - public get isEditable():boolean { return false; } - - private loadAndRender():void { - void Promise.all([ - this.loadProjectSchema(), - ]) - .then(([schema]) => { - this.setCustomFields(schema); - }); - } - - private loadProjectSchema():Promise { - return this - .apiV3Service - .projects - .schema - .get() - .toPromise(); - } - - private setCustomFields(schema:SchemaResource) { - Object.entries(schema).forEach(([key, keySchema]) => { - if (/customField\d+/.exec(key)) { - this.customFields.push({ key, label: (keySchema as SchemaAttributeObject).name }); - } - }); - - this.cdRef.detectChanges(); - } } diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts index 316fdfe8c6e7..2ed8f9ab3255 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu.service.ts @@ -33,11 +33,13 @@ export class OPContextMenuService { // Allow temporarily disabling the close handler private isOpening = false; - constructor(private componentFactoryResolver:ComponentFactoryResolver, + constructor( + private componentFactoryResolver:ComponentFactoryResolver, readonly FocusHelper:FocusHelperService, private appRef:ApplicationRef, private $transitions:TransitionService, - private injector:Injector) { + private injector:Injector, + ) { const hostElement = this.portalHostElement = document.createElement('div'); hostElement.classList.add('op-context-menu--overlay'); document.body.appendChild(hostElement); diff --git a/frontend/src/app/shared/helpers/angular/tracking-functions.ts b/frontend/src/app/shared/helpers/angular/tracking-functions.ts index 050740708f72..d21042019458 100644 --- a/frontend/src/app/shared/helpers/angular/tracking-functions.ts +++ b/frontend/src/app/shared/helpers/angular/tracking-functions.ts @@ -4,10 +4,12 @@ export function halHref(_index:number, item:T):string|nul return item.href; } -export function compareByAttribute(attribute:string) { +export function compareByAttribute(...attributes:string[]) { return (a:any, b:any) => { const bothNil = !a && !b; - return bothNil || (!!a && !!b && a[attribute] === b[attribute]); + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const same = !!a && !!b && attributes.every((attribute) => a[attribute] === b[attribute]); + return bothNil || (!!a && !!b && same); }; } diff --git a/frontend/src/global_styles/layout/work_packages/_mobile.sass b/frontend/src/global_styles/layout/work_packages/_mobile.sass index 400df8f4005a..ce7401d65250 100644 --- a/frontend/src/global_styles/layout/work_packages/_mobile.sass +++ b/frontend/src/global_styles/layout/work_packages/_mobile.sass @@ -123,5 +123,6 @@ #content padding: 15px 0 !important - .toolbar-container + .toolbar-container, + .work-packages--filters-optional-container margin-left: 15px diff --git a/frontend/src/global_styles/openproject.sass b/frontend/src/global_styles/openproject.sass index bd808ea1c7b0..e2bcd8896927 100644 --- a/frontend/src/global_styles/openproject.sass +++ b/frontend/src/global_styles/openproject.sass @@ -19,6 +19,7 @@ // Module specific Styles @import "../../../modules/meeting/app/components/_index.sass" +@import "../../../modules/overviews/app/components/_index.sass" @import "../../../modules/storages/app/components/_index.sass" // Component specific Styles diff --git a/frontend/src/global_styles/openproject/_primer-adjustments.sass b/frontend/src/global_styles/openproject/_primer-adjustments.sass index 9c340ca77dd8..c764da5e6f64 100644 --- a/frontend/src/global_styles/openproject/_primer-adjustments.sass +++ b/frontend/src/global_styles/openproject/_primer-adjustments.sass @@ -56,6 +56,14 @@ ul.tabnav-tabs a pointer-events: none +.op-primer-adjustments__toggle-switch--hidden-loading-indicator + .ToggleSwitch-statusIcon + display: none + +.Overlay + &-body_autocomplete_height + min-height: 300px + /* TODO: The actions within the PageHeader are currently not aligned correctly. The pageHeader itself already uses center alignment but the actions themselves default to 'normal'. Will be added to our primer repository */ diff --git a/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts new file mode 100644 index 000000000000..cbe9cd9c06bd --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/generic-drag-and-drop.controller.ts @@ -0,0 +1,203 @@ +/* + * -- 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 * as Turbo from '@hotwired/turbo'; +import { Controller } from '@hotwired/stimulus'; +import { Drake } from 'dragula'; +import { debugLog } from 'core-app/shared/helpers/debug_output'; + +interface TargetConfig { + container:Element; + allowedDragType:string|null; + targetId:string|null; +} + +export default class extends Controller { + drake:Drake|undefined; + targetConfigs:TargetConfig[]; + + containerTargets:Element[]; + + connect() { + this.initDrake(); + } + + initDrake() { + this.setContainerTargetsAndConfigs(); + + // reinit drake if it already exists + if (this.drake) { + this.drake.destroy(); + } + + this.drake = dragula( + this.containerTargets, + { + moves: (_el, _source, handle, _sibling) => !!handle?.classList.contains('octicon-grabber'), + accepts: (el:Element, target:Element, source:Element, sibling:Element) => this.accepts(el, target, source, sibling), + revertOnSpill: true, // enable reverting of elements if they are dropped outside of a valid target + }, + ) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on('drag', this.drag.bind(this)) + // eslint-disable-next-line @typescript-eslint/no-misused-promises + .on('drop', this.drop.bind(this)); + + // Setup autoscroll + void window.OpenProject.getPluginContext().then((pluginContext) => { + // eslint-disable-next-line no-new + new pluginContext.classes.DomAutoscrollService( + [ + document.getElementById('content-wrapper') as HTMLElement, + ], + { + margin: 25, + maxSpeed: 10, + scrollWhenOutside: true, + autoScroll: () => this.drake?.dragging, + }, + ); + }); + } + + reInitDrakeContainers() { + this.setContainerTargetsAndConfigs(); + if (this.drake) { + this.drake.containers = this.containerTargets; + } + } + + setContainerTargetsAndConfigs():void { + const rawTargets = Array.from( + this.element.querySelectorAll('[data-is-drag-and-drop-target="true"]'), + ); + this.targetConfigs = []; + let processedTargets:Element[] = []; + + rawTargets.forEach((target:Element) => { + const targetConfig:TargetConfig = { + container: target, + allowedDragType: target.getAttribute('data-target-allowed-drag-type'), + targetId: target.getAttribute('data-target-id'), + }; + + // if the target has a container accessor, use that as the container instead of the element itself + // we need this e.g. in Primer's boderbox component as we cannot add required data attributes to the ul element there + const containerAccessor = target.getAttribute('data-target-container-accessor'); + + if (containerAccessor) { + target = target.querySelector(containerAccessor) as Element; + targetConfig.container = target; + } + + // we need to save the targetConfigs separately as we need to pass the pure container elements to drake + // but need the configuration of the targets when dropping elements + this.targetConfigs.push(targetConfig); + + processedTargets = processedTargets.concat(target); + }); + + this.containerTargets = processedTargets; + } + + accepts(el:Element, target:Element, _source:Element|null, _sibling:Element|null) { + const targetConfig = this.targetConfigs.find((config) => config.container === target); + const acceptedDragType = targetConfig?.allowedDragType as string|undefined; + + const draggableType = el.getAttribute('data-draggable-type'); + + if (draggableType !== acceptedDragType) { + debugLog('Element is not allowed to be dropped here'); + return false; + } + + return true; + } + + drag(_:Element, _source:Element|null) { + // discover new target containers if they have been added to the DOM via Turbo streams + this.reInitDrakeContainers(); + } + + async drop(el:Element, target:Element, _source:Element|null, _sibling:Element|null) { + const dropUrl = el.getAttribute('data-drop-url'); + + let targetPosition = Array.from(target.children).indexOf(el); + if (target.children.length > 0 && target.children[0].getAttribute('data-empty-list-item') === 'true') { + // if the target container is empty, a list item showing an empty message might be shown + // this should not be counted as a list item + // thus we need to subtract 1 from the target position + targetPosition -= 1; + } + + const targetConfig = this.targetConfigs.find((config) => config.container === target); + const targetId = targetConfig?.targetId as string|undefined; + + const data = new FormData(); + + data.append('position', (targetPosition + 1).toString()); + + if (targetId) { + data.append('target_id', targetId.toString()); + } + + if (dropUrl) { + const response = await fetch(dropUrl, { + method: 'PUT', + body: data, + headers: { + 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement).content, + Accept: 'text/vnd.turbo-stream.html', + }, + credentials: 'same-origin', + }); + + if (!response.ok) { + debugLog('Failed to sort item'); + } else { + const text = await response.text(); + Turbo.renderStreamMessage(text); + // reinit drake containers as the DOM will be updated by Turbo streams + // otherwise the DOM references in the Drake instance will be outdated + setTimeout(() => this.reInitDrakeContainers(), 100); + } + } + + if (this.drake) { + this.drake.cancel(true); // necessary to prevent "copying" behaviour + } + } + + disconnect() { + if (this.drake) { + this.drake.destroy(); + } + } +} diff --git a/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts new file mode 100644 index 000000000000..2cf7982df6ba --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/projects/settings/project-custom-fields-mapping-filter.controller.ts @@ -0,0 +1,95 @@ +/* + * -- 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 ProjectCustomFieldsMappingFilterController extends Controller { + static targets = [ + 'filter', + 'searchItem', + 'bulkActionContainer', + ]; + + declare readonly filterTarget:HTMLInputElement; + declare readonly searchItemTargets:HTMLInputElement[]; + declare readonly bulkActionContainerTargets:HTMLInputElement[]; + + connect():void { + this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.addEventListener('click', () => { + this.resetFilterViaClearButton(); + }); + } + + disconnect():void { + this.element.querySelector('#project-custom-fields-mapping-filter-clear-button')?.removeEventListener('click', () => { + this.resetFilterViaClearButton(); + }); + } + + filterLists() { + const query = this.filterTarget.value.toLowerCase(); + + if (query.length > 0) { + this.hideBulkActionContainers(); + } else { + this.showBulkActionContainers(); + } + + this.searchItemTargets.forEach((item) => { + const text = item.textContent?.toLowerCase(); + + if (text?.includes(query)) { + (item as HTMLElement).classList.remove('hidden-by-filter'); + } else { + (item as HTMLElement).classList.add('hidden-by-filter'); + } + }); + } + + resetFilterViaClearButton() { + this.showBulkActionContainers(); + + this.searchItemTargets.forEach((item) => { + (item as HTMLElement).classList.remove('hidden-by-filter'); + }); + } + + hideBulkActionContainers() { + this.bulkActionContainerTargets.forEach((item) => { + (item as HTMLElement).classList.add('hidden-by-filter'); + }); + } + + showBulkActionContainers() { + this.bulkActionContainerTargets.forEach((item) => { + (item as HTMLElement).classList.remove('hidden-by-filter'); + }); + } +} diff --git a/lib/api/v3/principals/principals_api.rb b/lib/api/v3/principals/principals_api.rb index 09e3202bb8eb..8ddaf82484f2 100644 --- a/lib/api/v3/principals/principals_api.rb +++ b/lib/api/v3/principals/principals_api.rb @@ -32,9 +32,23 @@ module Principals class PrincipalsAPI < ::API::OpenProjectAPI resource :principals do get &::API::V3::Utilities::Endpoints::SqlFallbackedIndex - .new(model: Principal, - scope: -> { Principal.includes(:preference) }) - .mount + .new(model: Principal, + scope: -> { Principal.includes(:preference) }) + .mount + + params do + requires :id, desc: 'Principal ID' + end + route_param :id, type: Integer, desc: 'Principal ID' do + after_validation do + @principal = Principal.visible.find(params[:id]) + end + + get &::API::V3::Utilities::Endpoints::Show + .new(model: Principal, + render_representer: PrincipalRepresenterFactory) + .mount + end end end end diff --git a/lib/api/v3/render/render_api.rb b/lib/api/v3/render/render_api.rb index e295fb3ed1ca..07a8d3371005 100644 --- a/lib/api/v3/render/render_api.rb +++ b/lib/api/v3/render/render_api.rb @@ -36,7 +36,7 @@ class RenderAPI < ::API::OpenProjectAPI resources :render do helpers do SUPPORTED_CONTEXT_NAMESPACES ||= %w(work_packages projects news posts wiki_pages meeting_contents).freeze - SUPPORTED_MEDIA_TYPE ||= 'text/plain'.freeze + SUPPORTED_MEDIA_TYPE ||= "text/plain".freeze def allowed_content_types [SUPPORTED_MEDIA_TYPE] @@ -46,8 +46,8 @@ def check_content_type actual = request.content_type unless actual&.starts_with?(SUPPORTED_MEDIA_TYPE) - bad_type = actual || I18n.t('api_v3.errors.missing_content_type') - message = I18n.t('api_v3.errors.invalid_content_type', + bad_type = actual || I18n.t("api_v3.errors.missing_content_type") + message = I18n.t("api_v3.errors.invalid_content_type", content_type: SUPPORTED_MEDIA_TYPE, actual: bad_type) @@ -57,24 +57,24 @@ def check_content_type def check_format(format) unless ::OpenProject::TextFormatting::Formats.supported?(format) - fail ::API::Errors::NotFound, I18n.t('api_v3.errors.code_404') + fail ::API::Errors::NotFound, I18n.t("api_v3.errors.code_404") end end def setup_response status 200 - content_type 'text/html' + content_type "text/html" end def request_body - env['api.request.body'] + env["api.request.body"] end def context_object try_context_object rescue ::ActiveRecord::RecordNotFound fail ::API::Errors::InvalidRenderContext.new( - I18n.t('api_v3.errors.render.context_object_not_found') + I18n.t("api_v3.errors.render.context_object_not_found") ) end @@ -83,9 +83,9 @@ def try_context_object context = parse_context namespace = context[:namespace] - klass = if namespace == 'posts' + klass = if namespace == "posts" Message - elsif SUPPORTED_CONTEXT_NAMESPACES.without('posts').include?(namespace) + elsif SUPPORTED_CONTEXT_NAMESPACES.without("posts").include?(namespace) namespace.camelcase.singularize.constantize end @@ -100,12 +100,12 @@ def parse_context if context.nil? fail ::API::Errors::InvalidRenderContext.new( - I18n.t('api_v3.errors.render.context_not_parsable') + I18n.t("api_v3.errors.render.context_not_parsable") ) - elsif !SUPPORTED_CONTEXT_NAMESPACES.include?(context[:namespace]) || - context[:version] != '3' + elsif SUPPORTED_CONTEXT_NAMESPACES.exclude?(context[:namespace]) || + context[:version] != "3" fail ::API::Errors::InvalidRenderContext.new( - I18n.t('api_v3.errors.render.unsupported_context') + I18n.t("api_v3.errors.render.unsupported_context") ) else context diff --git a/lib/open_project.rb b/lib/open_project.rb index a3cf8b290252..3df1ddc1bf9d 100644 --- a/lib/open_project.rb +++ b/lib/open_project.rb @@ -59,6 +59,8 @@ def self.httpx .with( timeout: { connect_timeout: OpenProject::Configuration.httpx_connect_timeout, + operation_timeout: OpenProject::Configuration.httpx_operation_timeout, + request_timeout: OpenProject::Configuration.httpx_request_timeout, write_timeout: OpenProject::Configuration.httpx_write_timeout, read_timeout: OpenProject::Configuration.httpx_read_timeout, keep_alive_timeout: OpenProject::Configuration.httpx_keep_alive_timeout diff --git a/lib/open_project/static/links.rb b/lib/open_project/static/links.rb index c89d47ff0ec8..50d40a70cd45 100644 --- a/lib/open_project/static/links.rb +++ b/lib/open_project/static/links.rb @@ -58,7 +58,7 @@ def dynamic_links dynamic = { help: { href: help_link, - label: 'top_menu.help_and_support' + label: "top_menu.help_and_support" } } @@ -75,215 +75,215 @@ def dynamic_links def static_links { upsale: { - href: 'https://www.openproject.org/enterprise-edition', - label: 'homescreen.links.upgrade_enterprise_edition' + href: "https://www.openproject.org/enterprise-edition", + label: "homescreen.links.upgrade_enterprise_edition" }, upsale_benefits_features: { - href: 'https://www.openproject.org/enterprise-edition/#premium-features', - label: 'noscript_learn_more' + href: "https://www.openproject.org/enterprise-edition/#premium-features", + label: "noscript_learn_more" }, upsale_benefits_installation: { - href: 'https://www.openproject.org/enterprise-edition/#installation', - label: 'noscript_learn_more' + href: "https://www.openproject.org/enterprise-edition/#installation", + label: "noscript_learn_more" }, upsale_benefits_security: { - href: 'https://www.openproject.org/enterprise-edition/#security-features', - label: 'noscript_learn_more' + href: "https://www.openproject.org/enterprise-edition/#security-features", + label: "noscript_learn_more" }, upsale_benefits_support: { - href: 'https://www.openproject.org/enterprise-edition/#professional-support', - label: 'noscript_learn_more' + href: "https://www.openproject.org/enterprise-edition/#professional-support", + label: "noscript_learn_more" }, upsale_get_quote: { - href: 'https://www.openproject.org/request-quote/', - label: 'admin.enterprise.get_quote' + href: "https://www.openproject.org/request-quote/", + label: "admin.enterprise.get_quote" }, user_guides: { - href: 'https://www.openproject.org/docs/user-guide/', - label: 'homescreen.links.user_guides' + href: "https://www.openproject.org/docs/user-guide/", + label: "homescreen.links.user_guides" }, installation_guides: { - href: 'https://www.openproject.org/docs/installation-and-operations/installation/', + href: "https://www.openproject.org/docs/installation-and-operations/installation/", label: :label_installation_guides }, packager_installation: { - href: 'https://www.openproject.org/docs/installation-and-operations/installation/packaged/', - label: 'Packaged installation' + href: "https://www.openproject.org/docs/installation-and-operations/installation/packaged/", + label: "Packaged installation" }, docker_installation: { - href: 'https://www.openproject.org/docs/installation-and-operations/installation/docker/', - label: 'Docker installation' + href: "https://www.openproject.org/docs/installation-and-operations/installation/docker/", + label: "Docker installation" }, manual_installation: { - href: 'https://www.openproject.org/docs/installation-and-operations/installation/manual/', - label: 'Manual installation' + href: "https://www.openproject.org/docs/installation-and-operations/installation/manual/", + label: "Manual installation" }, upgrade_guides: { - href: 'https://www.openproject.org/docs/installation-and-operations/operation/upgrading/', + href: "https://www.openproject.org/docs/installation-and-operations/operation/upgrading/", label: :label_upgrade_guides }, postgres_migration: { - href: 'https://www.openproject.org/docs/installation-and-operations/misc/packaged-postgresql-migration/', - label: :'homescreen.links.postgres_migration' + href: "https://www.openproject.org/docs/installation-and-operations/misc/packaged-postgresql-migration/", + label: :"homescreen.links.postgres_migration" }, postgres_13_upgrade: { - href: 'https://www.openproject.org/docs/installation-and-operations/misc/migration-to-postgresql13/' + href: "https://www.openproject.org/docs/installation-and-operations/misc/migration-to-postgresql13/" }, configuration_guide: { - href: 'https://www.openproject.org/docs/installation-and-operations/configuration/', - label: 'links.configuration_guide' + href: "https://www.openproject.org/docs/installation-and-operations/configuration/", + label: "links.configuration_guide" }, contact: { - href: 'https://www.openproject.org/contact/', - label: 'links.get_in_touch' + href: "https://www.openproject.org/contact/", + label: "links.get_in_touch" }, glossary: { - href: 'https://www.openproject.org/docs/glossary/', - label: 'homescreen.links.glossary' + href: "https://www.openproject.org/docs/glossary/", + label: "homescreen.links.glossary" }, shortcuts: { - href: 'https://www.openproject.org/docs/user-guide/keyboard-shortcuts-access-keys/', - label: 'homescreen.links.shortcuts' + href: "https://www.openproject.org/docs/user-guide/keyboard-shortcuts-access-keys/", + label: "homescreen.links.shortcuts" }, forums: { - href: 'https://community.openproject.org/projects/openproject/forums', - label: 'homescreen.links.forums' + href: "https://community.openproject.org/projects/openproject/forums", + label: "homescreen.links.forums" }, enterprise_support_as_community: { - href: 'https://www.openproject.org/pricing/#support', + href: "https://www.openproject.org/pricing/#support", label: :label_enterprise_support }, enterprise_support: { - href: 'https://www.openproject.org/docs/enterprise-guide/support/', + href: "https://www.openproject.org/docs/enterprise-guide/support/", label: :label_enterprise_support }, website: { - href: 'https://www.openproject.org', - label: 'label_openproject_website' + href: "https://www.openproject.org", + label: "label_openproject_website" }, newsletter: { - href: 'https://www.openproject.org/newsletter', - label: 'homescreen.links.newsletter' + href: "https://www.openproject.org/newsletter", + label: "homescreen.links.newsletter" }, blog: { - href: 'https://www.openproject.org/blog', - label: 'homescreen.links.blog' + href: "https://www.openproject.org/blog", + label: "homescreen.links.blog" }, release_notes: { - href: 'https://www.openproject.org/docs/release-notes/', + href: "https://www.openproject.org/docs/release-notes/", label: :label_release_notes }, data_privacy: { - href: 'https://www.openproject.org/legal/privacy/', + href: "https://www.openproject.org/legal/privacy/", label: :label_privacy_policy }, digital_accessibility: { - href: 'https://www.openproject.org/de/rechtliches/erklaerung-zur-digitalen-barrierefreiheit/', + href: "https://www.openproject.org/de/rechtliches/erklaerung-zur-digitalen-barrierefreiheit/", label: :label_digital_accessibility }, report_bug: { - href: 'https://www.openproject.org/docs/development/report-a-bug/', + href: "https://www.openproject.org/docs/development/report-a-bug/", label: :label_report_bug }, roadmap: { - href: 'https://community.openproject.org/projects/openproject/roadmap', + href: "https://community.openproject.org/projects/openproject/roadmap", label: :label_development_roadmap }, crowdin: { - href: 'https://www.openproject.org/docs/development/translate-openproject/', + href: "https://www.openproject.org/docs/development/translate-openproject/", label: :label_add_edit_translations }, api_docs: { - href: 'https://www.openproject.org/docs/api/', + href: "https://www.openproject.org/docs/api/", label: :label_api_doc }, text_formatting: { - href: 'https://www.openproject.org/docs/user-guide/wysiwyg/', + href: "https://www.openproject.org/docs/user-guide/wysiwyg/", label: :setting_text_formatting }, oauth_authorization_code_flow: { - href: 'https://oauth.net/2/grant-types/authorization-code/', - label: 'oauth.flows.authorization_code' + href: "https://oauth.net/2/grant-types/authorization-code/", + label: "oauth.flows.authorization_code" }, client_credentials_code_flow: { - href: 'https://oauth.net/2/grant-types/client-credentials/', - label: 'oauth.flows.client_credentials' + href: "https://oauth.net/2/grant-types/client-credentials/", + label: "oauth.flows.client_credentials" }, ldap_encryption_documentation: { - href: 'https://www.rubydoc.info/gems/net-ldap/Net/LDAP#constructor_details' + href: "https://www.rubydoc.info/gems/net-ldap/Net/LDAP#constructor_details" }, origin_mdn_documentation: { - href: 'https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin' + href: "https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Origin" }, security_badge_documentation: { - href: 'https://www.openproject.org/docs/system-admin-guide/information/#security-badge' + href: "https://www.openproject.org/docs/system-admin-guide/information/#security-badge" }, date_format_settings_documentation: { - href: 'https://www.openproject.org/docs/system-admin-guide/calendars-and-dates/#date-format' + href: "https://www.openproject.org/docs/system-admin-guide/calendars-and-dates/#date-format" }, chargebee: { - href: 'https://js.chargebee.com/v2/chargebee.js' + href: "https://js.chargebee.com/v2/chargebee.js" }, webinar_videos: { - href: 'https://www.youtube.com/watch?v=un6zCm8_FT4' + href: "https://www.youtube.com/watch?v=un6zCm8_FT4" }, get_started_videos: { - href: 'https://www.youtube.com/playlist?list=PLGzJ4gG7hPb8WWOWmeXqlfMfhdXReu-RJ' + href: "https://www.youtube.com/playlist?list=PLGzJ4gG7hPb8WWOWmeXqlfMfhdXReu-RJ" }, openproject_docs: { - href: 'https://www.openproject.org/docs/' + href: "https://www.openproject.org/docs/" }, contact_us: { - href: 'https://www.openproject.org/contact/' + href: "https://www.openproject.org/contact/" }, pricing: { - href: 'https://www.openproject.org/pricing/' + href: "https://www.openproject.org/pricing/" }, enterprise_docs: { form_configuration: { - href: 'https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-types/#work-package-form-configuration-enterprise-add-on' + href: "https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-types/#work-package-form-configuration-enterprise-add-on" }, attribute_highlighting: { - href: 'https://www.openproject.org/docs/user-guide/work-packages/work-package-table-configuration/#attribute-highlighting-enterprise-add-on' + href: "https://www.openproject.org/docs/user-guide/work-packages/work-package-table-configuration/#attribute-highlighting-enterprise-add-on" }, boards: { - href: 'https://www.openproject.org/docs/user-guide/agile-boards/#action-boards-enterprise-add-on' + href: "https://www.openproject.org/docs/user-guide/agile-boards/#action-boards-enterprise-add-on" }, custom_field_projects: { - href: 'https://www.openproject.org/docs/system-admin-guide/custom-fields/custom-fields-projects/' + href: "https://www.openproject.org/docs/system-admin-guide/custom-fields/custom-fields-projects/" }, custom_field_multiselect: { - href: 'https://www.openproject.org/docs/system-admin-guide/custom-fields/#create-a-multi-select-custom-field' + href: "https://www.openproject.org/docs/system-admin-guide/custom-fields/#create-a-multi-select-custom-field" }, status_read_only: { - href: 'https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-status/#create-a-new-work-package-status' + href: "https://www.openproject.org/docs/system-admin-guide/manage-work-packages/work-package-status/#create-a-new-work-package-status" } }, storage_docs: { setup: { - href: 'https://www.openproject.org/docs/system-admin-guide/integrations/storage/' + href: "https://www.openproject.org/docs/system-admin-guide/integrations/storage/" }, nextcloud_setup: { - href: 'https://www.openproject.org/docs/system-admin-guide/integrations/nextcloud/' + href: "https://www.openproject.org/docs/system-admin-guide/integrations/nextcloud/" }, one_drive_setup: { - href: 'https://www.openproject.org/docs/system-admin-guide/integrations/one-drive/' + href: "https://www.openproject.org/docs/system-admin-guide/integrations/one-drive/" }, one_drive_drive_id_guide: { - href: 'https://www.openproject.org/docs/system-admin-guide/integrations/one-drive/drive-guide/' + href: "https://www.openproject.org/docs/system-admin-guide/integrations/one-drive/drive-guide/" }, nextcloud_oauth_application: { - href: 'https://apps.nextcloud.com/apps/integration_openproject' + href: "https://apps.nextcloud.com/apps/integration_openproject" }, one_drive_oauth_application: { - href: 'https://portal.azure.com/' + href: "https://portal.azure.com/" } }, ical_docs: { - href: 'https://www.openproject.org/docs/user-guide/calendar/#subscribe-to-a-calendar' + href: "https://www.openproject.org/docs/user-guide/calendar/#subscribe-to-a-calendar" }, integrations: { - href: 'https://www.openproject.org/docs/system-admin-guide/integrations/' + href: "https://www.openproject.org/docs/system-admin-guide/integrations/" } } end diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb index 5298cd8f2bac..9b3c555f437e 100644 --- a/lib/primer/open_project/forms/autocompleter.html.erb +++ b/lib/primer/open_project/forms/autocompleter.html.erb @@ -1,22 +1,22 @@ -<%= render(FormControl.new(input: @input)) do %> +<%= render(FormControl.new(input: @input, data: @wrapper_data_attributes)) do %> <% if decorated_select? %> <%= render partial: '/augmented/autocomplete_select_decoration', locals: { - input_name: builder.field_name(@input.name), - input_id: builder.field_id(@input.name), + input_name: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name) }, + input_id: @autocomplete_options.fetch(:inputId) { builder.field_id(@input.name) }, select_options: select_options.map(&:to_h), multiple: @autocomplete_options.fetch(:multiple, false), key: @autocomplete_options.fetch(:resource, ''), append_to: @autocomplete_options.fetch(:append_to, 'body') } %> <% else %> - <%= angular_component_tag 'opce-autocompleter', + <%= angular_component_tag @autocomplete_options.fetch(:component, 'opce-autocompleter'), data: @autocomplete_options.delete(:data) { {} }, inputs: @autocomplete_options.merge( classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: builder.field_name(@input.name), - inputValue: builder.object.send(@input.name), - defaultData: 'true' + inputName: @autocomplete_options.fetch(:inputName) { builder.field_name(@input.name) }, + inputValue: @autocomplete_options.fetch(:inputValue) { builder.object.send(@input.name) }, + defaultData: @autocomplete_options.fetch(:defaultData) { true } ) %> <% end %> diff --git a/lib/primer/open_project/forms/autocompleter.rb b/lib/primer/open_project/forms/autocompleter.rb index 51aedf8b1577..3767fd2b0714 100644 --- a/lib/primer/open_project/forms/autocompleter.rb +++ b/lib/primer/open_project/forms/autocompleter.rb @@ -9,10 +9,11 @@ class Autocompleter < Primer::Forms::BaseComponent delegate :builder, :form, :select_options, to: :@input - def initialize(input:, autocomplete_options:) + def initialize(input:, autocomplete_options:, wrapper_data_attributes: {}) super() @input = input @autocomplete_options = autocomplete_options + @wrapper_data_attributes = wrapper_data_attributes end def decorated_select? diff --git a/lib/primer/open_project/forms/dsl/autocompleter_input.rb b/lib/primer/open_project/forms/dsl/autocompleter_input.rb index 706037c19422..ea4355547802 100644 --- a/lib/primer/open_project/forms/dsl/autocompleter_input.rb +++ b/lib/primer/open_project/forms/dsl/autocompleter_input.rb @@ -5,7 +5,7 @@ module OpenProject module Forms module Dsl class AutocompleterInput < Primer::Forms::Dsl::Input - attr_reader :name, :label, :autocomplete_options, :select_options + attr_reader :name, :label, :autocomplete_options, :select_options, :wrapper_data_attributes class Option attr_reader :label, :value, :selected @@ -25,10 +25,11 @@ def to_h end end - def initialize(name:, label:, autocomplete_options:, **system_arguments) + def initialize(name:, label:, autocomplete_options:, wrapper_data_attributes: {}, **system_arguments) @name = name @label = label @autocomplete_options = autocomplete_options + @wrapper_data_attributes = wrapper_data_attributes @select_options = [] super(**system_arguments) @@ -41,7 +42,7 @@ def option(**args) end def to_component - Autocompleter.new(input: self, autocomplete_options:) + Autocompleter.new(input: self, autocomplete_options:, wrapper_data_attributes:) end def type diff --git a/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb b/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb deleted file mode 100644 index 14c2f098bb96..000000000000 --- a/lib/primer/open_project/forms/dsl/user_autocompleter_input.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module Primer - module OpenProject - module Forms - module Dsl - class UserAutocompleterInput < OpenProject::Forms::Dsl::AutocompleterInput - attr_reader :name, :label, :autocomplete_options, :select_options - - def initialize(name:, label:, autocomplete_options:, **system_arguments) - @name = name - @label = label - @autocomplete_options = autocomplete_options - @select_options = [] - - super(name:, label:, autocomplete_options:, **system_arguments) - - yield(self) if block_given? - end - - def to_component - UserAutocompleter.new(input: self, autocomplete_options:) - end - - def type - :user_autocompleter - end - end - end - end - end -end diff --git a/lib/primer/open_project/forms/user_autocompleter.html.erb b/lib/primer/open_project/forms/user_autocompleter.html.erb deleted file mode 100644 index 0302fda7c1ce..000000000000 --- a/lib/primer/open_project/forms/user_autocompleter.html.erb +++ /dev/null @@ -1,10 +0,0 @@ -<%= render(FormControl.new(input: @input)) do %> - <%= angular_component_tag 'opce-user-autocompleter', - data: @data_attributes, - inputs: @autocomplete_options.merge( - classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}", - inputName: builder.field_name(@input.name, multiple: @autocomplete_options[:multiple]), - inputValue: builder.object.send(@input.name), - ) - %> -<% end %> diff --git a/lib/primer/open_project/forms/user_autocompleter.rb b/lib/primer/open_project/forms/user_autocompleter.rb deleted file mode 100644 index e03a85889a0b..000000000000 --- a/lib/primer/open_project/forms/user_autocompleter.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module Primer - module OpenProject - module Forms - # :nodoc: - class UserAutocompleter < Primer::Forms::BaseComponent - include AngularHelper - - delegate :builder, :form, :select_options, to: :@input - - def initialize(input:, autocomplete_options:) - super() - @input = input - @data_attributes = autocomplete_options.delete(:data) { {} } - @autocomplete_options = autocomplete_options - end - end - end - end -end diff --git a/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb b/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb index a775da0dccd4..a98e168399a2 100644 --- a/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb +++ b/lib_static/plugins/acts_as_customizable/lib/acts_as_customizable.rb @@ -233,11 +233,12 @@ def set_default_values! self.custom_field_values = new_values end - def validate_custom_values + def validate_custom_values(custom_field_ids = []) set_default_values! if new_record? custom_field_values .reject(&:marked_for_destruction?) + .select { |cv| custom_field_ids.empty? || custom_field_ids.include?(cv.custom_field_id) } .select(&:invalid?) .each { |custom_value| add_custom_value_errors! custom_value } end diff --git a/modules/avatars/config/locales/crowdin/js-hu.yml b/modules/avatars/config/locales/crowdin/js-hu.yml index 26c3e7f0d945..378bab010131 100644 --- a/modules/avatars/config/locales/crowdin/js-hu.yml +++ b/modules/avatars/config/locales/crowdin/js-hu.yml @@ -10,5 +10,5 @@ hu: Töltsön fel saját, 128x128 képpont méretű profilképet. A nagyobb fájlok átméretezésre és levágásra kerülnek, hogy illeszkedjenek a méretkorláthoz. Feltöltés előtt megjelenik a profilképének előnézete, miután kiválasztott egy képet. error_image_too_large: "Fájl mérete túl nagy." wrong_file_format: "A megengedett formátumok: jpg, png, gif" - empty_file_error: "Kérjük, töltsön fel egy érvényes képet (jpg, png, gif)." + empty_file_error: "Kérjük, érvényes képet töltsön fel (jpg, png, gif)." diff --git a/modules/avatars/config/locales/crowdin/js-pt-BR.yml b/modules/avatars/config/locales/crowdin/js-pt-BR.yml index caa968380f55..3b29c9deb4af 100644 --- a/modules/avatars/config/locales/crowdin/js-pt-BR.yml +++ b/modules/avatars/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: label_preview: 'Pré-visualizar' button_update: 'Atualizar' diff --git a/modules/avatars/config/locales/crowdin/js-pt-PT.yml b/modules/avatars/config/locales/crowdin/js-pt-PT.yml index ee292ad27c0f..495cfbe40307 100644 --- a/modules/avatars/config/locales/crowdin/js-pt-PT.yml +++ b/modules/avatars/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: label_preview: 'Pré visualizar' button_update: 'Atualizar' diff --git a/modules/avatars/config/locales/crowdin/pt-BR.yml b/modules/avatars/config/locales/crowdin/pt-BR.yml index 1e6746f8d7cd..1dbac87dd408 100644 --- a/modules/avatars/config/locales/crowdin/pt-BR.yml +++ b/modules/avatars/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_avatars: name: "Imagens do perfil" description: >- diff --git a/modules/avatars/config/locales/crowdin/pt-PT.yml b/modules/avatars/config/locales/crowdin/pt-PT.yml index d1d9bcfcad09..f313f033434e 100644 --- a/modules/avatars/config/locales/crowdin/pt-PT.yml +++ b/modules/avatars/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_avatars: name: "Avatares" description: >- diff --git a/modules/backlogs/config/locales/crowdin/hu.yml b/modules/backlogs/config/locales/crowdin/hu.yml index 83c2483d9f84..b3823af7176d 100644 --- a/modules/backlogs/config/locales/crowdin/hu.yml +++ b/modules/backlogs/config/locales/crowdin/hu.yml @@ -64,8 +64,8 @@ hu: properties: "Tulajdonságok" rebuild: "Újraépítés" rebuild_positions: "Pozíciók újraépítése" - remaining_hours: "Hátralévő munka" - remaining_hours_ideal: "Hátralévő munka (ideális)" + remaining_hours: "Fennmaradó órák" + remaining_hours_ideal: "Fennmaradó órák (ideális)" show_burndown_chart: "Napi teendő ábra" story: "Sztori" story_points: "Story pontok" @@ -141,18 +141,18 @@ hu: points_resolved: "points resolved" points_to_accept: "points not accepted" points_to_resolve: "points not resolved" - project_module_backlogs: "Backlogs" + project_module_backlogs: "Elvégzendő feladatok" rb_label_copy_tasks: "Copy work packages" rb_label_copy_tasks_all: "Mind" rb_label_copy_tasks_none: "None" rb_label_copy_tasks_open: "Open" rb_label_link_to_original: "Include link to original story" - remaining_hours: "hátralévő munka" + remaining_hours: "Fennmaradó órák" required_burn_rate_hours: "required burn rate (hours)" required_burn_rate_points: "required burn rate (points)" - todo_work_package_description: "%{summary}: %{url}\n%{description}" + todo_work_package_description: "%{összegzés}: %{url}\n%{leírás}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Column in backlog" + version_settings_display_label: "Hátralévő feladatok oszlopa" version_settings_display_option_left: "balra" version_settings_display_option_none: "none" version_settings_display_option_right: "jobbra" diff --git a/modules/backlogs/config/locales/crowdin/js-pt-BR.yml b/modules/backlogs/config/locales/crowdin/js-pt-BR.yml index 003815df6623..7845c9211a02 100644 --- a/modules/backlogs/config/locales/crowdin/js-pt-BR.yml +++ b/modules/backlogs/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: properties: diff --git a/modules/backlogs/config/locales/crowdin/js-pt-PT.yml b/modules/backlogs/config/locales/crowdin/js-pt-PT.yml index b75a09cb2553..fb1abc58136d 100644 --- a/modules/backlogs/config/locales/crowdin/js-pt-PT.yml +++ b/modules/backlogs/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: properties: diff --git a/modules/backlogs/config/locales/crowdin/ms.yml b/modules/backlogs/config/locales/crowdin/ms.yml index 6e749610528d..6bdf1f780351 100644 --- a/modules/backlogs/config/locales/crowdin/ms.yml +++ b/modules/backlogs/config/locales/crowdin/ms.yml @@ -99,7 +99,7 @@ ms: error_backlogs_task_cannot_be_story: "Seting tidak sah. Jenis tugasan yang dipilih tidak boleh dijadikan jenis cerita juga." error_intro_plural: "Ralat-ralat berikut telah ditemui:" error_intro_singular: "Ralat berikut telah ditemui:" - error_outro: "Sila betulkan ralat-ralat diatas sebelum menghantar semula." + error_outro: "Sila betulkan ralat-ralat di atas sebelum menghantar semula." event_sprint_description: "%{summary}: %{url}\n%{description}" event_sprint_summary: "%{project}: %{summary}" ideal: "ideal" @@ -112,7 +112,7 @@ ms: label_burndown: "Burndown" label_column_in_backlog: "Kolum dalam tunggakan" label_hours: "jam" - label_work_package_hierarchy: "Hierarki Pakej kerja " + label_work_package_hierarchy: "Hierarki Pakej kerja" label_master_backlog: "Tunggakan Utama" label_not_prioritized: "tidak diutamakan" label_points: "mata" @@ -152,7 +152,7 @@ ms: required_burn_rate_points: "kadar pembakaran yang diperlukan (mata)" todo_work_package_description: "%{summary}: %{url}\n%{description}" todo_work_package_summary: "%{type}: %{summary}" - version_settings_display_label: "Kolum dalam backlog" + version_settings_display_label: "Kolum dalam tunggakan" version_settings_display_option_left: "kiri" version_settings_display_option_none: "tiada" version_settings_display_option_right: "kanan" diff --git a/modules/backlogs/config/locales/crowdin/pt-BR.yml b/modules/backlogs/config/locales/crowdin/pt-BR.yml index b8c6e3776b33..63172ca2d9e8 100644 --- a/modules/backlogs/config/locales/crowdin/pt-BR.yml +++ b/modules/backlogs/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_backlogs: name: "Backlogs OpenProject" description: "Este módulo acrescenta recursos que permitem que as equipes ágeis trabalhem com o OpenProject em projetos Scrum." diff --git a/modules/backlogs/config/locales/crowdin/pt-PT.yml b/modules/backlogs/config/locales/crowdin/pt-PT.yml index 04cea10164e7..c77ddaacd14b 100644 --- a/modules/backlogs/config/locales/crowdin/pt-PT.yml +++ b/modules/backlogs/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_backlogs: name: "Repositórios OpenProject" description: "Este módulo acrescenta funcionalidades que permitem às equipas Agile trabalhar com o OpenProject em projetos Scrum." diff --git a/modules/backlogs/config/locales/crowdin/zh-TW.yml b/modules/backlogs/config/locales/crowdin/zh-TW.yml index 537ebfc53b75..a80e037699c9 100644 --- a/modules/backlogs/config/locales/crowdin/zh-TW.yml +++ b/modules/backlogs/config/locales/crowdin/zh-TW.yml @@ -37,7 +37,7 @@ zh-TW: can_only_contain_work_packages_of_current_sprint: "只可以在現在進度中包含工作項目的 IDs" must_block_at_least_one_work_package: "必須包含至少一個項目的 ID" version_id: - task_version_must_be_the_same_as_story_version: "必須與「使用者故事」(User Story)的版本相同。" + task_version_must_be_the_same_as_story_version: "必須與上層「使用者需求」(User Story)的版本相同。" sprint: cannot_end_before_it_starts: "進度不可以在開始前結束" backlogs: @@ -64,8 +64,8 @@ zh-TW: properties: "屬性" rebuild: "重建" rebuild_positions: "重建位置" - remaining_hours: "剩餘工作" - remaining_hours_ideal: "剩餘工作(ideal)" + remaining_hours: "剩餘工時" + remaining_hours_ideal: "剩餘工時(ideal)" show_burndown_chart: "未完成圖" story: "使用者需求" story_points: "需求重點" @@ -147,7 +147,7 @@ zh-TW: rb_label_copy_tasks_none: "無" rb_label_copy_tasks_open: "開啟" rb_label_link_to_original: "包含連結到原本的使用者需求" - remaining_hours: "剩餘工作" + remaining_hours: "剩餘工時" required_burn_rate_hours: "必須的完成率 (小時)" required_burn_rate_points: "必須的完成率 (點數)" todo_work_package_description: "%{summary}: %{url}\n%{description}" diff --git a/modules/bim/config/locales/crowdin/cs.seeders.yml b/modules/bim/config/locales/crowdin/cs.seeders.yml index 1c42c958c73f..c7e36b8c2348 100644 --- a/modules/bim/config/locales/crowdin/cs.seeders.yml +++ b/modules/bim/config/locales/crowdin/cs.seeders.yml @@ -578,10 +578,10 @@ cs: * ... item_3: subject: Odesílání modelu BIM - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazený typům "Clash" a "Request", představuje tedy obecnou poznámku. item_5: subject: Koordinace, první cyklus - description: This type is hierarchically a parent of the types "Clash" and "Request", thus represents a general note. + description: Tento typ je hierarchicky nadřazeným typem "Clash" a "Request", proto představuje obecnou poznámku. children: item_0: subject: Koordinace různých modelů BIM diff --git a/modules/bim/config/locales/crowdin/hu.seeders.yml b/modules/bim/config/locales/crowdin/hu.seeders.yml index d68e615ba116..b1bd901e30f4 100644 --- a/modules/bim/config/locales/crowdin/hu.seeders.yml +++ b/modules/bim/config/locales/crowdin/hu.seeders.yml @@ -105,7 +105,7 @@ hu: item_1: name: Milestones item_2: - name: Tasks + name: Feladatok item_3: name: Csoport tervező boards: diff --git a/modules/bim/config/locales/crowdin/js-ms.yml b/modules/bim/config/locales/crowdin/js-ms.yml index 0cdd0ea94493..24303eab2ae8 100644 --- a/modules/bim/config/locales/crowdin/js-ms.yml +++ b/modules/bim/config/locales/crowdin/js-ms.yml @@ -4,26 +4,26 @@ ms: bcf: label_bcf: 'BCF' import: 'Import' - import_bcf_xml_file: 'Import fail BCF XML (BCF version 2.1)' + import_bcf_xml_file: 'Import fail BCF XML (BCF versi 2.1)' export: 'Eksport' - export_bcf_xml_file: 'Eksport fail BCF XML (BCF version 2.1)' + export_bcf_xml_file: 'Eksport fail BCF XML (BCF versi 2.1)' viewpoint: 'Sudut pandangan' add_viewpoint: 'Tambah sudut pandangan' show_viewpoint: 'Tunjuk sudut pandangan' delete_viewpoint: 'Padam sudut pandangan' management: "Pengurusan BCF\n" - refresh: 'Refresh' - refresh_work_package: 'Refresh work package' + refresh: 'Muat semula ' + refresh_work_package: 'Muat semula pakej kerja' ifc_models: - empty_warning: "This project does not yet have any IFC models." - use_this_link_to_manage: "Use this link to upload and manage your IFC models" - keyboard_input_disabled: "Viewer does not have keyboard controls. Click on the viewer to give keyboard control to the viewer." + empty_warning: "Projek ini belum mempunyai sebarang model IFC." + use_this_link_to_manage: "Guna pautan ini untuk kemas kini dan urus model IFC anda" + keyboard_input_disabled: "Pemerhati tidak ada kawalan papan kekunci. Klik pada pemerhati untuk memberikan kawalan papan kekunci kepada pemerhati." models: - ifc_models: 'IFC models' + ifc_models: 'Model IFC' views: - viewer: 'Viewer' - split: 'Viewer and table' - split_cards: 'Viewer and cards' + viewer: 'Pemerhati' + split: 'Pemerhati dan jadual' + split_cards: 'Pemerhati dan kad' revit: revit_add_in: "Revit Add-In" - revit_add_in_settings: "Revit Add-In settings" + revit_add_in_settings: "Seting Revit Add-In" diff --git a/modules/bim/config/locales/crowdin/js-pt-BR.yml b/modules/bim/config/locales/crowdin/js-pt-BR.yml index 646cc77181a5..7425b318dc7a 100644 --- a/modules/bim/config/locales/crowdin/js-pt-BR.yml +++ b/modules/bim/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: bcf: label_bcf: 'BCF' diff --git a/modules/bim/config/locales/crowdin/js-pt-PT.yml b/modules/bim/config/locales/crowdin/js-pt-PT.yml index eb8a6792f08f..3c9b05524a14 100644 --- a/modules/bim/config/locales/crowdin/js-pt-PT.yml +++ b/modules/bim/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: bcf: label_bcf: 'BCF' diff --git a/modules/bim/config/locales/crowdin/ms.seeders.yml b/modules/bim/config/locales/crowdin/ms.seeders.yml index 95f86dffaca7..fd3a24e2e9c8 100644 --- a/modules/bim/config/locales/crowdin/ms.seeders.yml +++ b/modules/bim/config/locales/crowdin/ms.seeders.yml @@ -135,9 +135,9 @@ ms: 4. _Cipta dan kemaskini carta Gantt_: → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. 5. _Mengaktifkan modul lanjutan_: → Pergi ke [Seting projek → Modul]({{opSetting:base_url}}/projects/demo-construction-project/settings/modules). 6. _Lihat paparan til untuk dapatkan gambar keseluruhan isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-construction-project/boards) + 7. _Pemangkin kerja? Semak board kami yang baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-construction-project/boards) - Disini anda akan jumpa [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan jumpa [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami jika anda mempunyai sebarang soalan atau memerlukan sokongan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -185,14 +185,14 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. - 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 3. _Cipta satu pakej kerja baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 4. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi project. - 5. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). - 6. _Pemangkin kerja? Cipta satu panel baharu:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-planning-constructing-project/members) dalam navigasi projek. + 2. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 3. _Cipta satu pakej kerja baru:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 4. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-planning-constructing-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-planning-constructing-project/settings/modules). + 6. _Pemangkin kerja? Cipta satu board baharu:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-planning-constructing-project/boards) - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Di sini anda akan menemui [Panduan Pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -209,7 +209,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-construction-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penilaian asas description: Jenis ini adalah induk secara hierarki bagi jenis "Konflik" dan "Permintaan", oleh itu mewakili catatan umum. @@ -318,7 +318,7 @@ ms: ## Huraian * Menyediakan tapak untuk projek - * Kumpulkan pasukan + * Kumpulkan pasukan * ... item_1: subject: Asas @@ -388,7 +388,7 @@ ms: * Menyiapkan pemasangan sistem perkhidmatan bangunan * Menyiapkan pembinaan dalaman - * MEnyiapkan muka bangunan + * Menyiapkan muka bangunan * ... item_6: subject: Majlis perasmian rumah baru @@ -439,17 +439,17 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bim-project/members) dalam navigasi projek. 2. _Muat naik dan paparkan model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang berkaitan dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. - 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Work packages → Create]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). - 6. _Cipta dan kemaskini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). - 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Work packages]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) - 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bim-project/boards) - - Di sini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + 4. _Lihat kerja dalam projek anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 5. _Cipta satu pakej pekerjaan baharu:_ → Pergi ke [Pakej kerja → Cipta]({{opSetting:base_url}}/projects/demo-bim-project/work_packages/new?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22bcfIssueAssociated%22%2C%22o%22%3A%22%3D%22%2C%22v%22%3A%5B%22f%22%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D&type=11). + 6. _Cipta dan kemaskini carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22assignee%22%2C%22responsible%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22list%22%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul]({{opSetting:base_url}}/projects/demo-bim-project/settings/modules). + 8. _Semak paparan til untuk mendapatkan gambaran keseluruhan mengenai isu-isu BCF anda:_ → Pergi ke [Pakej kerja]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22priority%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) + 9. _Pemangkin kerja? Cipta satu panel baru:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bim-project/boards) + + Di sini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: @@ -466,7 +466,7 @@ ms: description: |- Permulaan projek menandakan permulaan projek di dalam syarikat anda. Setiap orang yang menjadi sebahagian daripada projek ini perlu dijemput untuk menyertai taklimat pertama projek. - Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Gantt chart]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). + Langkah seterusnya adalah menyemak jadual waktu dan menyesuaikan temujanji dengan melihat [Carta Gantt]({{opSetting:base_url}}/projects/demo-bim-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22weeks%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D). item_1: subject: Penyediaan projek description: Jenis ini adalah induk bagi jenis "Clash" dan "Request", oleh itu mewakili catatan umum. @@ -715,16 +715,16 @@ ms: _Cuba ikuti langkah berikut:_ - 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Members]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. + 1. _Jemput ahli baru ke projek anda:_ → Pergi ke [Ahli]({{opSetting:base_url}}/projects/demo-bcf-management-project/members?show_add_members=true) dalam navigasi projek. 2. _Muat naik dan lihat model 3d dalam format IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) dalam navigasi projek. 3. _Cipta dan urus isu BCF yang dihubung secara langsung dalam model IFC:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bim-project/bcf) → Cipta. 4. _Lihat fail BCF dalam projek anda:_ → Pergi ke [BCF]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22type%22%2C%22id%22%2C%22subject%22%2C%22status%22%2C%22assignee%22%2C%22priority%22%5D%2C%22hl%22%3A%22status%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22id%3Aasc%22%2C%22f%22%3A%5B%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%2C%22dr%22%3A%22card%22%7D) dalam navigasi projek. 5. _Muatkan fail BCF anda:_ → Pergi ke [BCF → Import.]({{opSetting:base_url}}/projects/demo-bcf-management-project/issues/upload) - 6. _Cipta dan kemas kini Gantt chart:_ → Pergi ke [Gantt chart]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. - 7. _Aktifkan modul lanjutan:_ → Pergi ke [Project settings → Modules.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) - 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Boards]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). + 6. _Cipta dan kemas kini Carta Gantt:_ → Pergi ke [Carta Gantt]({{opSetting:base_url}}/projects/demo-bcf-management-project/work_packages?query_props=%7B%22c%22%3A%5B%22id%22%2C%22subject%22%2C%22startDate%22%2C%22dueDate%22%5D%2C%22tv%22%3Atrue%2C%22tzl%22%3A%22days%22%2C%22hi%22%3Atrue%2C%22g%22%3A%22%22%2C%22t%22%3A%22startDate%3Aasc%22%2C%22f%22%3A%5B%7B%22n%22%3A%22status%22%2C%22o%22%3A%22o%22%2C%22v%22%3A%5B%5D%7D%5D%2C%22pa%22%3A1%2C%22pp%22%3A100%7D) dalam navigasi projek. + 7. _Aktifkan modul lanjutan:_ → Pergi ke [Seting Projek → Modul.]({{opSetting:base_url}}/projects/demo-bcf-management-project/settings/modules) + 8. _Anda suka pendekatan yang pantas ini? Cipta Board:_ → Pergi ke [Board]({{opSetting:base_url}}/projects/demo-bcf-management-project/boards). - Disini anda akan menemui [User Guides](https://www.openproject.org/docs/user-guide/) kami. + Disini anda akan menemui [Panduan pengguna](https://www.openproject.org/docs/user-guide/) kami. Sila beritahu kami untuk sebarang soalan atau bantuan. Hubungi kami: [support\[at\]openproject.com](mailto:support@openproject.com). item_4: options: diff --git a/modules/bim/config/locales/crowdin/ms.yml b/modules/bim/config/locales/crowdin/ms.yml index 014d2278eca5..b845326d6795 100644 --- a/modules/bim/config/locales/crowdin/ms.yml +++ b/modules/bim/config/locales/crowdin/ms.yml @@ -74,10 +74,10 @@ ms: oauth: scopes: bcf_v2_1: "Akses penuh untuk BCF v2.1 API" - bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk meaksanakan tindakan bagi pihak anda." + bcf_v2_1_text: "Aplikasi akan terima akses baca & tulis penuh di OpenProject BCF API v2.1 untuk melaksanakan tindakan bagi pihak anda." activerecord: models: - bim/ifc_models/ifc_model: "model IFC" + bim/ifc_models/ifc_model: "Model IFC" attributes: bim/ifc_models/ifc_model: ifc_attachment: "Fail IFC" @@ -88,7 +88,7 @@ ms: bim/ifc_models/ifc_model: attributes: base: - ifc_attachment_missing: "Tiada fail ifc yang dilampirkan" + ifc_attachment_missing: "Tiada fail IFC yang dilampirkan" invalid_ifc_file: "Fail yang ditetapkan adalah fail IFC yang tidak sah." bim/bcf/viewpoint: bitmaps_not_writable: "bitmaps tidak boleh ditulis kerana belum dilaksanakan." @@ -99,7 +99,7 @@ ms: invalid_orthogonal_camera: "kamera_serenjang adalah tidak sah." invalid_perspective_camera: "kamera_perspektif adalah tidak sah." mismatching_guid: "The guid in the json_viewpoint does not match the model's guid." - no_json: "Is not a well structured json." + no_json: "Bukan json yang tersusun dengan baik." snapshot_type_unsupported: "jenis_snapshot perlu sama ada 'png' atau 'jpg'." snapshot_data_blank: "data_snapshot perlu disediakan." unsupported_key: "Properti json yang tidak disokong disertakan." @@ -108,16 +108,16 @@ ms: ifc_models: label_ifc_models: 'Model IFC' label_new_ifc_model: 'Model IFC baharu' - label_show_defaults: 'Show defaults' + label_show_defaults: 'Tunjukkan defaults' label_default_ifc_models: 'Model default IFC ' label_edit_defaults: 'Edit defaults' no_defaults_warning: - title: 'No IFC model was set as default for this project.' + title: 'Tiada model IFC yang ditetapkan sebagai default untuk projek ini.' check_1: 'Semak bahawa anda telah memuat naik sekurang-kurangnya satu model IFC.' check_2: 'Semak bahawa salah satu model IFC ditetapkan sebagai "Default".' no_results: "Tiada model IFC yang telah dimuat naik di dalam projek ini." conversion_status: - label: 'Pemprosesan?' + label: 'Proses?' pending: 'Dalam proses' processing: 'Sedang di proses' completed: 'Selesai' @@ -127,7 +127,7 @@ ms: flash_messages: upload_successful: 'Muat naik berjaya. Akan diproses dan sedia untuk digunakan dalam masa beberapa minit.' conversion: - missing_commands: "Arahan penukar IFC berikut hilang pada sistem ini: %{names}" + missing_commands: "Arahan converter IFC berikut hilang dari sistem ini: %{names}" project_module_ifc_models: "Model IFC" permission_view_ifc_models: "Paparkan model IFC" permission_manage_ifc_models: "Import dan urus model IFC" diff --git a/modules/bim/config/locales/crowdin/pt-BR.seeders.yml b/modules/bim/config/locales/crowdin/pt-BR.seeders.yml index 7774cdba654f..0d82162c385c 100644 --- a/modules/bim/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/bim/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: bim: priorities: diff --git a/modules/bim/config/locales/crowdin/pt-BR.yml b/modules/bim/config/locales/crowdin/pt-BR.yml index 8cc38cdd8892..9c8b89a127b9 100644 --- a/modules/bim/config/locales/crowdin/pt-BR.yml +++ b/modules/bim/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_bim: name: "Funcionalidade BIM e BCF do OpenProject" description: "Este plugin do OpenProject introduz a funcionalidade BIM e BCF." diff --git a/modules/bim/config/locales/crowdin/pt-PT.seeders.yml b/modules/bim/config/locales/crowdin/pt-PT.seeders.yml index fa7f58af58a3..9de5a7939034 100644 --- a/modules/bim/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/bim/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: bim: priorities: diff --git a/modules/bim/config/locales/crowdin/pt-PT.yml b/modules/bim/config/locales/crowdin/pt-PT.yml index f24bbb2f7ef6..ce55bd40cda8 100644 --- a/modules/bim/config/locales/crowdin/pt-PT.yml +++ b/modules/bim/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_bim: name: "Funcionalidade BIM e BCF do OpenProject" description: "Este plugin do OpenProject introduz a funcionalidade BIM e BCF." diff --git a/modules/boards/config/locales/crowdin/js-ms.yml b/modules/boards/config/locales/crowdin/js-ms.yml index 46be2a3f4228..c1ac594abee6 100644 --- a/modules/boards/config/locales/crowdin/js-ms.yml +++ b/modules/boards/config/locales/crowdin/js-ms.yml @@ -42,15 +42,15 @@ ms: action_text: > Board dengan senarai yang disaring di atribut %{attribute}. Pemindahan pakej kerja ke senarai lain akan mengemas kini atribut mereka. action_text_subprojects: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. action_text_status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. action_text_assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. action_text_version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. action_type: assignee: wakil status: status diff --git a/modules/boards/config/locales/crowdin/js-pt-BR.yml b/modules/boards/config/locales/crowdin/js-pt-BR.yml index b316762e5133..aa4e60e5ce6b 100644 --- a/modules/boards/config/locales/crowdin/js-pt-BR.yml +++ b/modules/boards/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: boards: create_new: 'Criar novo quadro' diff --git a/modules/boards/config/locales/crowdin/js-pt-PT.yml b/modules/boards/config/locales/crowdin/js-pt-PT.yml index bc9108070da1..d447f2d2081c 100644 --- a/modules/boards/config/locales/crowdin/js-pt-PT.yml +++ b/modules/boards/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: boards: create_new: 'Criar novo quadro' diff --git a/modules/boards/config/locales/crowdin/ms.yml b/modules/boards/config/locales/crowdin/ms.yml index ca810b0ecf2b..b2e8a041615e 100644 --- a/modules/boards/config/locales/crowdin/ms.yml +++ b/modules/boards/config/locales/crowdin/ms.yml @@ -4,16 +4,16 @@ ms: name: "OpenProject Boards" description: "Provides board views." permission_show_board_views: "Paparkan papan" - permission_manage_board_views: "Manage boards" - project_module_board_view: "Boards" + permission_manage_board_views: "Urus board" + project_module_board_view: "Board" boards: label_board: "Board" - label_boards: "Boards" - label_create_new_board: "Create new board" - label_board_type: "Board type" + label_boards: "Board" + label_create_new_board: "Cipta board baru" + label_board_type: "Jenis board" board_types: free: "Asas\n" - action: "Action board (%{attribute})" + action: "Board tindakan (%{attribute})" board_type_attributes: assignee: Wakil status: Status @@ -23,17 +23,17 @@ ms: basic: "Asas\n" board_type_descriptions: basic: > - Start from scratch with a blank board. Manually add cards and columns to this board. + Bermula dari awal dengan board kosong. Tambah kad dan kolum secara manual kepada board ini. status: > - Basic kanban style board with columns for status such as To Do, In Progress, Done. + Board gaya kanban asas dengan kolum untuk status seperti Untuk Dilakukan, Dalam Pelaksanaan, Selesai. assignee: > - Board with automated columns based on assigned users. Ideal for dispatching work packages. + Board dengan kolum automatik berdasarkan pengguna yang ditentukan. Ideal untuk penghantaran pakej kerja. version: > - Board with automated columns based on the version attribute. Ideal for planning product development. + Board dengan kolum automatik berdasarkan versi atribut. Ideal untuk merancang pembangunan produk. subproject: > - Board with automated columns for subprojects. Dragging work packages to other lists updates the (sub-)project accordingly. + Board dengan kolum automatik untuk subprojek. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. subtasks: > - Board with automated columns for sub-elements. Dragging work packages to other lists updates the parent accordingly. + Board dengan kolum automatik untuk sub-elements. Menarik pakej kerja ke senarai lain akan mengemas kini (sub-)projek sewajarnya. 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.' upgrade: 'Naik taraf sekarang' diff --git a/modules/boards/config/locales/crowdin/pt-BR.seeders.yml b/modules/boards/config/locales/crowdin/pt-BR.seeders.yml index e4bdb243507c..d180fdf67bfc 100644 --- a/modules/boards/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/boards/config/locales/crowdin/pt-BR.seeders.yml @@ -5,4 +5,4 @@ #located in the modules directories are needed to have crowdin cli correctly #compute the path to the uploaded source file. #This file does not contain any i18n strings. -pt: +pt-BR: diff --git a/modules/boards/config/locales/crowdin/pt-BR.yml b/modules/boards/config/locales/crowdin/pt-BR.yml index 487980ca1315..0149cc4ef9ec 100644 --- a/modules/boards/config/locales/crowdin/pt-BR.yml +++ b/modules/boards/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_boards: name: "Quadros do OpenProject" description: "Fornece visualizações do quadro." diff --git a/modules/boards/config/locales/crowdin/pt-PT.seeders.yml b/modules/boards/config/locales/crowdin/pt-PT.seeders.yml index e4bdb243507c..bdd962ed457a 100644 --- a/modules/boards/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/boards/config/locales/crowdin/pt-PT.seeders.yml @@ -5,4 +5,4 @@ #located in the modules directories are needed to have crowdin cli correctly #compute the path to the uploaded source file. #This file does not contain any i18n strings. -pt: +pt-PT: diff --git a/modules/boards/config/locales/crowdin/pt-PT.yml b/modules/boards/config/locales/crowdin/pt-PT.yml index 5bfefefb0647..45805106c57c 100644 --- a/modules/boards/config/locales/crowdin/pt-PT.yml +++ b/modules/boards/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_boards: name: "Quadros do OpenProject" description: "Fornece vistas do quadro." diff --git a/modules/budgets/config/locales/crowdin/js-ms.yml b/modules/budgets/config/locales/crowdin/js-ms.yml index 8236365cf878..e9634ba74563 100644 --- a/modules/budgets/config/locales/crowdin/js-ms.yml +++ b/modules/budgets/config/locales/crowdin/js-ms.yml @@ -23,4 +23,4 @@ ms: js: work_packages: properties: - costObject: "Bajet" + costObject: "Anggaran" diff --git a/modules/budgets/config/locales/crowdin/js-pt-BR.yml b/modules/budgets/config/locales/crowdin/js-pt-BR.yml index b61d8f714f35..d821c6ce69c5 100644 --- a/modules/budgets/config/locales/crowdin/js-pt-BR.yml +++ b/modules/budgets/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: properties: diff --git a/modules/budgets/config/locales/crowdin/js-pt-PT.yml b/modules/budgets/config/locales/crowdin/js-pt-PT.yml index b61d8f714f35..c5a75d8a6e1e 100644 --- a/modules/budgets/config/locales/crowdin/js-pt-PT.yml +++ b/modules/budgets/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: properties: diff --git a/modules/budgets/config/locales/crowdin/ms.yml b/modules/budgets/config/locales/crowdin/ms.yml index 8fe78c4bb612..d4ceeae878a2 100644 --- a/modules/budgets/config/locales/crowdin/ms.yml +++ b/modules/budgets/config/locales/crowdin/ms.yml @@ -34,7 +34,7 @@ ms: status: "Status" subject: "Subjek" type: "Jenis kos" - labor_budget: "Kos buruh yang dirancang" + labor_budget: "Kos buruh terancang" material_budget: "Kos unit yang dirancang" work_package: budget_subject: "Tajuk anggaran" @@ -47,32 +47,32 @@ ms: attributes: budget: "Anggaran" button_add_budget_item: "Tambah kos yang dirancang" - button_add_budget: "Tambah bajet" + button_add_budget: "Tambah anggaran" button_add_cost_type: "Tambah jenis kos" - button_cancel_edit_budget: "Batalkan bajet penyuntingan" + button_cancel_edit_budget: "Batalkan anggaran penyuntingan" button_cancel_edit_costs: "Batalkan kos penyuntingan" caption_labor: "Buruh" caption_labor_costs: "Kos buruh sebenar" caption_material_costs: "Kos unit sebenar" - budgets_title: "Bajet" + budgets_title: "Anggaran" events: - budget: "Bajet yang diedit" - help_click_to_edit: "Klik sini untuk edit." + budget: "Anggaran yang diedit" + help_click_to_edit: "Klik di sini untuk mengedit" help_currency_format: "Format nilai mata wang yang dipaparkan. %n diganti dengan nilai mata wang, %u diganti dengan unit mata wang." help_override_rate: "Masukkan nilai disini untuk gantikan kadar default." - label_budget: "Bajet" - label_budget_new: "Bajet baru" - label_budget_plural: "Bajet" - label_budget_id: "Bajet #%{id}" - label_deliverable: "Bajet" + label_budget: "Anggaran" + label_budget_new: "Anggaran baru" + label_budget_plural: "Anggaran" + label_budget_id: "Anggaran #%{id}" + label_deliverable: "Anggaran" label_example_placeholder: 'e.g., %{decimal}' - label_view_all_budgets: "Paparkan semua bajet" + label_view_all_budgets: "Paparkan semua anggaran" label_yes: "Ya" notice_budget_conflict: "Work packages must be of the same project." - notice_no_budgets_available: "Tiada bajet available." + notice_no_budgets_available: "Tiada bajet yang tersedia." permission_edit_budgets: "Edit bajet" - permission_view_budgets: "Paparkan bajet" - project_module_budgets: "Bajet" - text_budget_reassign_to: "Pindahkan mereka ke bajet ini" - text_budget_delete: "Padam bajet dari semua pakej kerja" - text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk bajet ini. Apa yang anda ingin lakukan?" + permission_view_budgets: "Paparkan anggaran" + project_module_budgets: "Anggaran" + text_budget_reassign_to: "Pindahkan mereka ke anggaran ini:" + text_budget_delete: "Padam anggaran dari semua pakej kerja" + text_budget_destroy_assigned_wp: "Terdapat %{count} pakej kerja ditugaskan untuk anggaran ini. Apa yang anda ingin lakukan?" diff --git a/modules/budgets/config/locales/crowdin/pt-BR.yml b/modules/budgets/config/locales/crowdin/pt-BR.yml index 2ff55be55ffb..6dac6a7faae0 100644 --- a/modules/budgets/config/locales/crowdin/pt-BR.yml +++ b/modules/budgets/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_budgets_engine: name: 'Orçamentos' activerecord: diff --git a/modules/budgets/config/locales/crowdin/pt-PT.yml b/modules/budgets/config/locales/crowdin/pt-PT.yml index 90e2d6642b4e..1b4ced7bc577 100644 --- a/modules/budgets/config/locales/crowdin/pt-PT.yml +++ b/modules/budgets/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_budgets_engine: name: 'Orçamentos' activerecord: diff --git a/modules/calendar/config/locales/crowdin/hu.yml b/modules/calendar/config/locales/crowdin/hu.yml index e63bc3b355ab..b6e857068d17 100644 --- a/modules/calendar/config/locales/crowdin/hu.yml +++ b/modules/calendar/config/locales/crowdin/hu.yml @@ -4,9 +4,9 @@ hu: name: "OpenProject Calendar" description: "Provides calendar views." label_calendar: "Naptár" - label_calendar_plural: "Naptárak" - label_new_calendar: "Új naptár" - permission_view_calendar: "Naptárak megtekintése" - permission_manage_calendars: "Naptárak kezelése" + label_calendar_plural: "Naptár" + label_new_calendar: "Új esemény" + permission_view_calendar: "Naptár bejegyzések megtekintése" + permission_manage_calendars: "Naptár kezelés" permission_share_calendars: "Feliratkozás az iCalendars-ra" project_module_calendar_view: "Naptárak" diff --git a/modules/calendar/config/locales/crowdin/js-hu.yml b/modules/calendar/config/locales/crowdin/js-hu.yml index 4970b7e3381a..73cf2edc1572 100644 --- a/modules/calendar/config/locales/crowdin/js-hu.yml +++ b/modules/calendar/config/locales/crowdin/js-hu.yml @@ -4,5 +4,5 @@ hu: calendar: create_new: 'Új naptár létrehozása' title: 'Naptár' - too_many: 'Összesen %{count} munkacsomag van, de csak %{max} jeleníthető meg.' + too_many: 'Összesen %{százalék_szám} munkacsomag van, de csak %{maximum} jeleníthető meg.' unsaved_title: 'Névtelen naptár' diff --git a/modules/calendar/config/locales/crowdin/js-pt-BR.yml b/modules/calendar/config/locales/crowdin/js-pt-BR.yml index 5b6274d63a34..ad51db24e136 100644 --- a/modules/calendar/config/locales/crowdin/js-pt-BR.yml +++ b/modules/calendar/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: calendar: create_new: 'Criar novo calendário' diff --git a/modules/calendar/config/locales/crowdin/js-pt-PT.yml b/modules/calendar/config/locales/crowdin/js-pt-PT.yml index a6e21759e651..84b80223c2fa 100644 --- a/modules/calendar/config/locales/crowdin/js-pt-PT.yml +++ b/modules/calendar/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: calendar: create_new: 'Criar novo calendário' diff --git a/modules/calendar/config/locales/crowdin/pt-BR.yml b/modules/calendar/config/locales/crowdin/pt-BR.yml index ffc1a019908a..e434cc6516a4 100644 --- a/modules/calendar/config/locales/crowdin/pt-BR.yml +++ b/modules/calendar/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_calendar: name: "Calendário OpenProject" description: "Fornece visualizações do calendário." diff --git a/modules/calendar/config/locales/crowdin/pt-PT.yml b/modules/calendar/config/locales/crowdin/pt-PT.yml index 8806bbabe1cf..2ce4bfac061f 100644 --- a/modules/calendar/config/locales/crowdin/pt-PT.yml +++ b/modules/calendar/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_calendar: name: "Calendário OpenProject" description: "Fornece visualizações do calendário." diff --git a/modules/costs/config/locales/crowdin/hu.yml b/modules/costs/config/locales/crowdin/hu.yml index 72ac4c3d60be..7801700ffd6b 100644 --- a/modules/costs/config/locales/crowdin/hu.yml +++ b/modules/costs/config/locales/crowdin/hu.yml @@ -34,7 +34,7 @@ hu: unit: "Egység neve" unit_plural: "Többes számú egység neve" work_package: - costs_by_type: "Elköltött egység" + costs_by_type: "Elhasznált egységek" labor_costs: "Munkaerő költségek" material_costs: "Egység költségek" overall_costs: "Összes költség" @@ -104,7 +104,7 @@ hu: label_work_package_filter_add: "Munkacsomag szűrő hozzáadása" label_kind: "Típus" label_less_or_equal: "<=" - label_log_costs: "Költségek naplózása" + label_log_costs: "Naplózott költségek" label_no: "Nem" label_option_plural: "Beállítások" label_overall_costs: "Összes költség" diff --git a/modules/costs/config/locales/crowdin/js-pt-BR.yml b/modules/costs/config/locales/crowdin/js-pt-BR.yml index 546b2d01fba3..b082a46b6d16 100644 --- a/modules/costs/config/locales/crowdin/js-pt-BR.yml +++ b/modules/costs/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: work_packages: property_groups: diff --git a/modules/costs/config/locales/crowdin/js-pt-PT.yml b/modules/costs/config/locales/crowdin/js-pt-PT.yml index 3294144067d6..8498784808e7 100644 --- a/modules/costs/config/locales/crowdin/js-pt-PT.yml +++ b/modules/costs/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: work_packages: property_groups: diff --git a/modules/costs/config/locales/crowdin/pt-BR.yml b/modules/costs/config/locales/crowdin/pt-BR.yml index fe5298744da6..b4ec08e3065f 100644 --- a/modules/costs/config/locales/crowdin/pt-BR.yml +++ b/modules/costs/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_costs: name: "Tempo e custos" description: "Este módulo acrescenta recursos para planejar e monitorar os custos dos projetos." diff --git a/modules/costs/config/locales/crowdin/pt-PT.yml b/modules/costs/config/locales/crowdin/pt-PT.yml index c4d939811e42..e3e3eb6fa74f 100644 --- a/modules/costs/config/locales/crowdin/pt-PT.yml +++ b/modules/costs/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_costs: name: "Tempo e custos" description: "Este módulo acrescenta funcionalidades para planear e acompanhar os custos dos projetos." diff --git a/modules/costs/config/locales/crowdin/zh-TW.yml b/modules/costs/config/locales/crowdin/zh-TW.yml index b9842091a8e6..b22e03c7c0ba 100644 --- a/modules/costs/config/locales/crowdin/zh-TW.yml +++ b/modules/costs/config/locales/crowdin/zh-TW.yml @@ -32,7 +32,7 @@ zh-TW: spent_on: "日期" cost_type: unit: "單位名稱" - unit_plural: "複數單位名稱" + unit_plural: "單位名稱(複數)" work_package: costs_by_type: "支出單位" labor_costs: "工資" @@ -97,7 +97,7 @@ zh-TW: label_generic_user: "一般使用者" label_greater_or_equal: ">=" label_group_by: "分組依據" - label_group_by_add: "新增依「群組」" + label_group_by_add: "增加群組欄位" label_hourly_rate: "小時費率" label_include_deleted: "包含已刪除的" label_work_package_filter_add: "新增工作項目篩選條件" diff --git a/modules/dashboards/config/locales/crowdin/js-pt-BR.yml b/modules/dashboards/config/locales/crowdin/js-pt-BR.yml index 4ab0b6b573fe..9aafff8e6417 100644 --- a/modules/dashboards/config/locales/crowdin/js-pt-BR.yml +++ b/modules/dashboards/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: dashboards: label: 'Painel' diff --git a/modules/dashboards/config/locales/crowdin/js-pt-PT.yml b/modules/dashboards/config/locales/crowdin/js-pt-PT.yml index f390c59ac102..862ac47440ef 100644 --- a/modules/dashboards/config/locales/crowdin/js-pt-PT.yml +++ b/modules/dashboards/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: dashboards: label: 'Painel de Controlo' diff --git a/modules/dashboards/config/locales/crowdin/pt-BR.yml b/modules/dashboards/config/locales/crowdin/pt-BR.yml index 21ba5bc596c0..d55391e6ca30 100644 --- a/modules/dashboards/config/locales/crowdin/pt-BR.yml +++ b/modules/dashboards/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: dashboards: label: 'Painéis' project_module_dashboards: 'Painéis' diff --git a/modules/dashboards/config/locales/crowdin/pt-PT.yml b/modules/dashboards/config/locales/crowdin/pt-PT.yml index 6f6e5d785e05..e884662919c2 100644 --- a/modules/dashboards/config/locales/crowdin/pt-PT.yml +++ b/modules/dashboards/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: dashboards: label: 'Painéis de controlo' project_module_dashboards: 'Painéis de controle' diff --git a/modules/dashboards/spec/features/project_details_spec.rb b/modules/dashboards/spec/features/project_details_spec.rb index 5854a2a3a9de..828c04e60c89 100644 --- a/modules/dashboards/spec/features/project_details_spec.rb +++ b/modules/dashboards/spec/features/project_details_spec.rb @@ -26,35 +26,15 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -require_relative '../support/pages/dashboard' +require_relative "../support/pages/dashboard" -RSpec.describe 'Project details widget on dashboard', :js do - let!(:version_cf) { create(:version_project_custom_field) } - let!(:bool_cf) { create(:boolean_project_custom_field) } - let!(:user_cf) { create(:user_project_custom_field) } - let!(:int_cf) { create(:integer_project_custom_field) } - let!(:float_cf) { create(:float_project_custom_field) } - let!(:text_cf) { create(:text_project_custom_field) } - let!(:string_cf) { create(:string_project_custom_field) } - let!(:date_cf) { create(:date_project_custom_field) } - - let(:system_version) { create(:version, sharing: 'system') } +RSpec.describe "Project details widget on dashboard", :js do + let(:system_version) { create(:version, sharing: "system") } let!(:project) do - create(:project, members: { other_user => role }).tap do |p| - p.send(int_cf.attribute_setter, 5) - p.send(bool_cf.attribute_setter, true) - p.send(version_cf.attribute_setter, system_version) - p.send(float_cf.attribute_setter, 4.5) - p.send(text_cf.attribute_setter, 'Some **long** text') - p.send(string_cf.attribute_setter, 'Some small text') - p.send(date_cf.attribute_setter, Date.current) - p.send(user_cf.attribute_setter, other_user) - - p.save!(validate: false) - end + create(:project, members: { other_user => role }) end let(:permissions) do @@ -78,13 +58,13 @@ let(:editing_user) do create(:user, member_with_permissions: { project => editing_permissions }, - firstname: 'Cool', - lastname: 'Guy') + firstname: "Cool", + lastname: "Guy") end let(:other_user) do create(:user, - firstname: 'Other', - lastname: 'User') + firstname: "Other", + lastname: "User") end let(:dashboard_page) do @@ -95,23 +75,7 @@ def add_project_details_widget dashboard_page.visit! dashboard_page.add_widget(1, 1, :within, "Project details") - dashboard_page.expect_and_dismiss_toaster message: I18n.t('js.notice_successful_update') - end - - def change_cf_value(cf, old_value, new_value) - # Open description field - cf.activate! - sleep(0.1) - - # Change the value - cf.expect_value(old_value) - cf.set_value new_value - cf.save! unless cf.field_type === 'create-autocompleter' - - # The edit field is toggled and the value saved. - expect(page).to have_content(new_value) - expect(page).to have_selector(cf.selector) - expect(page).to have_no_selector(cf.input_selector) + dashboard_page.expect_and_dismiss_toaster message: I18n.t("js.notice_successful_update") end before do @@ -119,69 +83,69 @@ def change_cf_value(cf, old_value, new_value) add_project_details_widget end - context 'without editing permissions' do + context "without editing permissions" do let(:current_user) { read_only_user } - it 'can add the widget, but not edit the custom fields' do + it "displays the deprecated message" do # As the user lacks the manage_public_queries and save_queries permission, no other widget is present - details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") within(details_widget.area) do - # Expect values - expect(page) - .to have_content("#{int_cf.name}\n5") - expect(page) - .to have_content("#{bool_cf.name}\nyes") - expect(page) - .to have_content("#{version_cf.name}\n#{system_version.name}") - expect(page) - .to have_content("#{float_cf.name}\n4.5") expect(page) - .to have_content("#{text_cf.name}\nSome long text") - expect(page) - .to have_content("#{string_cf.name}\nSome small text") - expect(page) - .to have_content("#{date_cf.name}\n#{Date.today.strftime('%m/%d/%Y')}") - expect(page) - .to have_content("#{user_cf.name}\n#{other_user.name.split.map(&:first).join}\n#{other_user.name}") - - # The fields are not editable - field = EditField.new dashboard_page, bool_cf.attribute_name(:camel_case) - field.expect_read_only - field.activate! expect_open: false + .to have_content("Project details have now moved to a column on the right edge of this page.") + expect(page).to have_content( + <<~TEXT.strip + Starting with version 13.5, project attributes can be grouped \ + in sections and enabled and disabled at a project level. + TEXT + ) + expect(page).to have_content( + <<~TEXT.strip + This widget can now be removed or replaced. \ + It will be deleted in subsequent versions. + TEXT + ) end end end - context 'with editing permissions' do + context "with editing permissions" do let(:current_user) { editing_user } - it 'can edit the custom fields' do - int_field = EditField.new dashboard_page, int_cf.attribute_name(:camel_case) - change_cf_value int_field, "5", "3" - - string_field = EditField.new dashboard_page, string_cf.attribute_name(:camel_case) - change_cf_value string_field, 'Some small text', 'Some new text' - - text_field = TextEditorField.new dashboard_page, text_cf.attribute_name(:camel_case) - change_cf_value text_field, 'Some long text', 'Some very long text' + it "displays the deprecated message" do + # As the user lacks the manage_public_queries and save_queries permission, no other widget is present + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") - user_field = SelectField.new dashboard_page, user_cf.attribute_name(:camel_case) - change_cf_value user_field, other_user.name, editing_user.name + within(details_widget.area) do + expect(page) + .to have_content("Project details have now moved to a column on the right edge of this page.") + expect(page).to have_content( + <<~TEXT.strip + Starting with version 13.5, project attributes can be grouped \ + in sections and enabled and disabled at a project level. + TEXT + ) + expect(page).to have_content( + <<~TEXT.strip + This widget can now be removed or replaced. \ + It will be deleted in subsequent versions. + TEXT + ) + end end end - context 'when project has Activity module enabled' do + context "when project has Activity module enabled" do let(:current_user) { read_only_user } it 'has a "Project activity" entry in More menu linking to the project activity page' do - details_widget = Components::Grids::GridArea.new('.grid--area.-widgeted:nth-of-type(1)') + details_widget = Components::Grids::GridArea.new(".grid--area.-widgeted:nth-of-type(1)") - details_widget.expect_menu_item('Project details activity') + details_widget.expect_menu_item("Project details activity") - details_widget.click_menu_item('Project details activity') + details_widget.click_menu_item("Project details activity") expect(page).to have_current_path(project_activity_index_path(project), ignore_query: true) - expect(page).to have_checked_field(id: 'event_types_project_attributes') + expect(page).to have_checked_field(id: "event_types_project_attributes") end end end diff --git a/modules/documents/config/locales/crowdin/pt-BR.yml b/modules/documents/config/locales/crowdin/pt-BR.yml index e702bd7f9a7d..d3f4f66d2c16 100644 --- a/modules/documents/config/locales/crowdin/pt-BR.yml +++ b/modules/documents/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_documents: name: "Documentos do OpenProject" description: "Um plugin OpenProject para permitir a criação de documentos em projetos." diff --git a/modules/documents/config/locales/crowdin/pt-PT.yml b/modules/documents/config/locales/crowdin/pt-PT.yml index 964ed01ea63a..22cb9da2f10e 100644 --- a/modules/documents/config/locales/crowdin/pt-PT.yml +++ b/modules/documents/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_documents: name: "Documentos do OpenProject" description: "Um plugin OpenProject para permitir a criação de documentos em projetos." diff --git a/modules/gantt/config/locales/crowdin/js-pt-BR.yml b/modules/gantt/config/locales/crowdin/js-pt-BR.yml index 0040575842b8..a4802317bbb2 100644 --- a/modules/gantt/config/locales/crowdin/js-pt-BR.yml +++ b/modules/gantt/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: queries: all_open: 'Tudo aberto' diff --git a/modules/gantt/config/locales/crowdin/js-pt-PT.yml b/modules/gantt/config/locales/crowdin/js-pt-PT.yml index d477cf988cc4..bfd6b4a1e0c4 100644 --- a/modules/gantt/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gantt/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: queries: all_open: 'Tudo aberto' diff --git a/modules/gantt/config/locales/crowdin/pt-BR.yml b/modules/gantt/config/locales/crowdin/pt-BR.yml index 30806f322d02..4b2fa1b786dc 100644 --- a/modules/gantt/config/locales/crowdin/pt-BR.yml +++ b/modules/gantt/config/locales/crowdin/pt-BR.yml @@ -1,3 +1,3 @@ #English strings go here -pt: +pt-BR: project_module_gantt: "Gráficos de Gantt" diff --git a/modules/gantt/config/locales/crowdin/pt-PT.yml b/modules/gantt/config/locales/crowdin/pt-PT.yml index 30806f322d02..73fb705fcc5a 100644 --- a/modules/gantt/config/locales/crowdin/pt-PT.yml +++ b/modules/gantt/config/locales/crowdin/pt-PT.yml @@ -1,3 +1,3 @@ #English strings go here -pt: +pt-PT: project_module_gantt: "Gráficos de Gantt" diff --git a/modules/github_integration/config/locales/crowdin/js-pt-BR.yml b/modules/github_integration/config/locales/crowdin/js-pt-BR.yml index cc5634c62aaa..07bc9f351d5f 100644 --- a/modules/github_integration/config/locales/crowdin/js-pt-BR.yml +++ b/modules/github_integration/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: github_integration: work_packages: diff --git a/modules/github_integration/config/locales/crowdin/js-pt-PT.yml b/modules/github_integration/config/locales/crowdin/js-pt-PT.yml index e20fbd308026..c9bf44aa52a1 100644 --- a/modules/github_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/github_integration/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: github_integration: work_packages: diff --git a/modules/github_integration/config/locales/crowdin/pt-BR.yml b/modules/github_integration/config/locales/crowdin/pt-BR.yml index f793bb60845f..a623297c3791 100644 --- a/modules/github_integration/config/locales/crowdin/pt-BR.yml +++ b/modules/github_integration/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" diff --git a/modules/github_integration/config/locales/crowdin/pt-PT.yml b/modules/github_integration/config/locales/crowdin/pt-PT.yml index d1e32ea7bc86..319a17c6e6a0 100644 --- a/modules/github_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/github_integration/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_github_integration: name: "Integração do OpenProject GitHub" description: "Integra o OpenProject e o GitHub para um melhor fluxo de trabalho" diff --git a/modules/gitlab_integration/config/locales/crowdin/cs.yml b/modules/gitlab_integration/config/locales/crowdin/cs.yml index becd05dc9d7a..e98796a3fe4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/cs.yml +++ b/modules/gitlab_integration/config/locales/crowdin/cs.yml @@ -40,7 +40,7 @@ cs: merge_request_closed_comment: > **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR sloučeno:** Požadavek na sloučení %{mr_number} [%{mr_title}](%{mr_url}) pro [%{repository}](%{repository_url}) byl sloučen [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > diff --git a/modules/gitlab_integration/config/locales/crowdin/hu.yml b/modules/gitlab_integration/config/locales/crowdin/hu.yml index 257b45b34344..8bcb794353e5 100644 --- a/modules/gitlab_integration/config/locales/crowdin/hu.yml +++ b/modules/gitlab_integration/config/locales/crowdin/hu.yml @@ -27,41 +27,41 @@ hu: gitlab_issue: attributes: labels: - invalid_schema: "must be an array of hashes with keys: color, title" + invalid_schema: "egy hash tömbnek kell lennie a következő kulcsokkal: color, title" gitlab_merge_request: attributes: labels: - invalid_schema: "must be an array of hashes with keys: color, title" + invalid_schema: "egy hash tömbnek kell lennie a következő kulcsokkal: color, title" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "GitLab tartalom megjelenítése" gitlab_integration: merge_request_opened_comment: > - **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **MR megnyitva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) megnyitva [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_closed_comment: > - **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **MR lezárva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) lezárva [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR egyesítve:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) egyesítve [%{gitlab_user}](%{gitlab_user_url}) által. merge_request_reopened_comment: > - **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **MR újranyitva:** Egyesítési kérelem (merge request) %{mr_number} [%{mr_title}](%{mr_url}) a következőhöz: [%{repository}](%{repository_url}) újranyitva [%{gitlab_user}](%{gitlab_user_url}) által. note_commit_referenced_comment: > - **Referenced in Commit:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in a Commit Note [%{commit_id}](%{commit_url}) on [%{repository}](%{repository_url}): %{commit_note} + **Hivatkozva a következő commitban:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) [%{commit_id}](%{commit_url}) commitban a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{commit_note} note_mr_referenced_comment: > - **Referenced in MR:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Hivatkozva a következő egyesítési kérelemben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{mr_number}%{mr_title}%{mr_url} MR-ben a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{mr_note} note_mr_commented_comment: > - **Commented in MR:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Hozzászólva a következő egyesítési kérelemben:** [%{gitlab_user}](%{gitlab_user_url}) hozzászólt ehhez a WP-hez a(z) %{mr_number}[%{mr_title}]%{mr_url} MR-ben a(z) [%{repository}](%{repository_url}) repositoryban, az üzenet: %{mr_note} note_issue_referenced_comment: > - **Referenced in Issue:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Hivatkozva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{issue_number}[%{issue_title}]%{issue_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{issue_note} note_issue_commented_comment: > - **Commented in Issue:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Hozzászólva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hozzászólt ehhez a WP-hez a(z) %{issue_number}[%{issue_title}]%{issue_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{issue_note} note_snippet_referenced_comment: > - **Referenced in Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) on [%{repository}](%{repository_url}): %{snippet_note} + **Hivatkozva a következőben:** [%{gitlab_user}](%{gitlab_user_url}) hivatkozott erre a WP-re a(z) %{snippet_number}[%{snippet_title}]%{snippet_url} a(z) [%{repository}](%{repository_url}) repositoryban: %{snippet_note} issue_opened_referenced_comment: > - **Issue Opened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés megnyitva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) megnyitva [%{gitlab_user}](%{gitlab_user_url}) által. issue_closed_referenced_comment: > - **Issue Closed:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés lezárva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) lezárva [%{gitlab_user}](%{gitlab_user_url}) által. issue_reopened_referenced_comment: > - **Issue Reopened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **Kérdés újranyitva:** %{issue_number}[%{issue_title}]%{issue_url} a következőhöz: [%{repository}](%{repository_url}) újranyitva [%{gitlab_user}](%{gitlab_user_url}) által. push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Frissítés az MR-ben:** [%{gitlab_user}](%{gitlab_user_url}) frissítette a következőkkel: [%{commit_number}](%{commit_url}) a(z) [%{repository}](%{repository_url}) ekkor: %{commit_timestamp}, üzenet: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Frissítés az MR-ben:** [%{gitlab_user}](%{gitlab_user_url}) frissítette a következőkkel: [%{commit_number}](%{commit_url}) a(z) [%{repository}](%{repository_url}) ekkor: %{commit_timestamp}, üzenet: %{commit_note} diff --git a/modules/gitlab_integration/config/locales/crowdin/js-hu.yml b/modules/gitlab_integration/config/locales/crowdin/js-hu.yml index a254c559dbab..de7ad84d6a4b 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-hu.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-hu.yml @@ -26,25 +26,25 @@ hu: work_packages: tab_name: "GitLab" tab_header_issue: - title: "Issues" + title: "Kérdések" tab_header_mr: - title: "Merge requests" + title: "Egyesítési kérelmek (merge requests)" create_mr: - label: Create MR - description: Create a Merge Request + label: MR létrehozása + description: Egyesítési kérelem (merge request) létrehozása copy_menu: - label: Git snippets - description: Copy git snippets to clipboard + label: Git snippetek + description: "Git snippetek másolása a vágólapra\n" git_actions: - branch_name: Branch name - commit_message: Commit message - cmd: Create branch with empty commit - title: Quick snippets for Git - copy_success: '✅ Copied!' - copy_error: '❌ Copy failed!' + branch_name: Branch neve + commit_message: Commit üzenet + cmd: Branch létrehozása üres committal + title: Gyors snippetek a Githez + copy_success: '✅ Másolva!' + copy_error: '❌ Másolás sikertelen!' tab_issue: - empty: 'There are no issues linked yet. Link an existing issue by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the issue title/description or create a new issue.' + empty: 'Még nincsenek kapcsolódó kérdések. Hivatkozzon egy meglévő kérdésre a kérdés címében/leírásában található OP#%{wp_id} (vagy PP#%{wp_id} a privát hivatkozások esetében) kód használatával, vagy hozzon létre egy új kérdést.' tab_mrs: - empty: 'There are no merge requests linked yet. Link an existing MR by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the MR title/description or create a new MR.' - gitlab_pipelines: Pipelines - updated_on: Updated on + empty: 'Még nincsenek hozzákapcsolt egyesítési kérelmek (merge requests). Linkelj egy meglévő MR-t az OP#%{wp_id} (vagy a PP#%{wp_id} a privát linkekhez) kód használatával az MR címében/leírásában, vagy hozz létre egy új MR-t.' + gitlab_pipelines: Pipeline-ok + updated_on: Frissítve ekkor diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml index 2f95b55b1fd1..4c30f98f039c 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-BR.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-BR: js: gitlab_integration: work_packages: diff --git a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml index 028bbc84e0a9..f90aa30e9a4a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/js-pt-PT.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-PT: js: gitlab_integration: work_packages: @@ -33,18 +33,18 @@ pt: label: Criar MR description: Crie Pedido de Fusão copy_menu: - label: Git snippets + label: Fragmentos de código Git description: Copiar pedaços de código git para área de transferência git_actions: branch_name: Nome do ramo - commit_message: Commit message + commit_message: Mensagem de confirmação cmd: Criar ramificação com a confirmação vazia - title: Quick snippets for Git + title: Fragmentos de código rápido para Git copy_success: '✅ Copiado!' copy_error: '❌ A cópia falhou!' tab_issue: - empty: 'There are no issues linked yet. Link an existing issue by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the issue title/description or create a new issue.' + empty: 'Ainda não existem problemas associados. Associe um problema existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do problema ou crie um problema novo.' tab_mrs: - empty: 'There are no merge requests linked yet. Link an existing MR by using the code OP#%{wp_id} (or PP#%{wp_id} for private links) in the MR title/description or create a new MR.' + empty: 'Ainda não existem pedidos de fusão associados. Associe um MR existente utilizando o código OP#%{wp_id} (ou PP#%{wp_id} para ligações privadas) no título/descrição do MR ou crie um novo MR.' gitlab_pipelines: Pipelines updated_on: Atualizado em diff --git a/modules/gitlab_integration/config/locales/crowdin/lt.yml b/modules/gitlab_integration/config/locales/crowdin/lt.yml index f5550ea73e12..0c7ba744fb4e 100644 --- a/modules/gitlab_integration/config/locales/crowdin/lt.yml +++ b/modules/gitlab_integration/config/locales/crowdin/lt.yml @@ -33,7 +33,7 @@ lt: labels: invalid_schema: "turi būti masyvas raktų: spalva, antraštė" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "Rodyti GitLab turinį" gitlab_integration: merge_request_opened_comment: > **MR Atidarytas:** Suliejimo prašymą %{mr_number} [%{mr_title}](%{mr_url}) skirtą [%{repository}](%{repository_url}) atidarė [%{gitlab_user}](%{gitlab_user_url}). diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml b/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml index 9178e4b1df36..992338e9171a 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-BR.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-BR: activerecord: errors: models: diff --git a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml index 548286d56923..8cfdaaaa5baf 100644 --- a/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml +++ b/modules/gitlab_integration/config/locales/crowdin/pt-PT.yml @@ -20,7 +20,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See docs/COPYRIGHT.rdoc for more details. #++ -pt: +pt-PT: activerecord: errors: models: @@ -33,35 +33,35 @@ pt: labels: invalid_schema: "deve ser um conjunto de hashes com as chaves: cor, título" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "Mostrar conteúdo GitLab" gitlab_integration: merge_request_opened_comment: > - **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **MR aberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). merge_request_closed_comment: > - **MR Closed:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **MR fechado:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). merge_request_merged_comment: > - **MR Merged:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been merged by [%{gitlab_user}](%{gitlab_user_url}). + **MR fundido:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi fundido por [%{gitlab_user}](%{gitlab_user_url}). merge_request_reopened_comment: > - **MR Reopened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **MR reaberto:** Pedido de fusão %{mr_number} [%{mr_title}](%{mr_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). note_commit_referenced_comment: > - **Referenced in Commit:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in a Commit Note [%{commit_id}](%{commit_url}) on [%{repository}](%{repository_url}): %{commit_note} + **Referenciado em Confirmação:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP numa Nota de Confirmação [%{commit_id}](%{commit_url}) em [%{repository}](%{repository_url}): %{commit_note} note_mr_referenced_comment: > - **Referenced in MR:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Referenciado em MR:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_mr_commented_comment: > - **Commented in MR:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Merge Request %{mr_number} [%{mr_title}](%{mr_url}) on [%{repository}](%{repository_url}): %{mr_note} + **Comentado em RM:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Pedido de Fusão %{mr_number} [%{mr_title}](%{mr_url}) em [%{repository}](%{repository_url}): %{mr_note} note_issue_referenced_comment: > - **Referenced in Issue:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Referenciado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_issue_commented_comment: > - **Commented in Issue:** [%{gitlab_user}](%{gitlab_user_url}) commented this WP in Issue %{issue_number} [%{issue_title}](%{issue_url}) on [%{repository}](%{repository_url}): %{issue_note} + **Comentado no Problema:** [%{gitlab_user}](%{gitlab_user_url}) comentou este WP no Problema %{issue_number} [%{issue_title}](%{issue_url}) em [%{repository}](%{repository_url}): %{issue_note} note_snippet_referenced_comment: > - **Referenced in Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenced this WP in Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) on [%{repository}](%{repository_url}): %{snippet_note} + **Referenciado no Snippet:** [%{gitlab_user}](%{gitlab_user_url}) referenciou este WP no Snippet %{snippet_number} [%{snippet_title}](%{snippet_url}) em [%{repository}](%{repository_url}): %{snippet_note} issue_opened_referenced_comment: > - **Issue Opened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Aberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi aberto por [%{gitlab_user}](%{gitlab_user_url}). issue_closed_referenced_comment: > - **Issue Closed:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been closed by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Fechado:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi fechado por [%{gitlab_user}](%{gitlab_user_url}). issue_reopened_referenced_comment: > - **Issue Reopened:** Issue %{issue_number} [%{issue_title}](%{issue_url}) for [%{repository}](%{repository_url}) has been reopened by [%{gitlab_user}](%{gitlab_user_url}). + **Problema Reaberto:** Problema %{issue_number} [%{issue_title}](%{issue_url}) para [%{repository}](%{repository_url}) foi reaberto por [%{gitlab_user}](%{gitlab_user_url}). push_single_commit_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} push_multiple_commits_comment: > - **Pushed in MR:** [%{gitlab_user}](%{gitlab_user_url}) pushed multiple commits [%{commit_number}](%{commit_url}) to [%{repository}](%{repository_url}) at %{commit_timestamp}: %{commit_note} + **Enviado no MR:** [%{gitlab_user}](%{gitlab_user_url}) enviou vários compromissos [%{commit_number}](%{commit_url}) para [%{repository}](%{repository_url}) em %{commit_timestamp}: %{commit_note} diff --git a/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml b/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml index 01490cacdcc4..40a9833d98af 100644 --- a/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml +++ b/modules/gitlab_integration/config/locales/crowdin/zh-TW.yml @@ -33,7 +33,7 @@ zh-TW: labels: invalid_schema: "must be an array of hashes with keys: color, title" project_module_gitlab: "GitLab" - permission_show_gitlab_content: "Show GitLab content" + permission_show_gitlab_content: "顯示 GitLab內容" gitlab_integration: merge_request_opened_comment: > **MR Opened:** Merge request %{mr_number} [%{mr_title}](%{mr_url}) for [%{repository}](%{repository_url}) has been opened by [%{gitlab_user}](%{gitlab_user_url}). diff --git a/modules/grids/config/locales/crowdin/js-pt-BR.yml b/modules/grids/config/locales/crowdin/js-pt-BR.yml index 589f704ef934..c39352b80807 100644 --- a/modules/grids/config/locales/crowdin/js-pt-BR.yml +++ b/modules/grids/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: grid: add_widget: 'Adicionar widget' diff --git a/modules/grids/config/locales/crowdin/js-pt-PT.yml b/modules/grids/config/locales/crowdin/js-pt-PT.yml index 924418f14a34..470c6a6212a3 100644 --- a/modules/grids/config/locales/crowdin/js-pt-PT.yml +++ b/modules/grids/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: grid: add_widget: 'Adicionar widget' @@ -8,7 +8,7 @@ pt: text: "Alguns widgets, como o widget gráfico do pacote de trabalho, estão disponíveis apenas na edição Enterprise." link: 'Edição Enterprise.' widgets: - missing_permission: "You don't have the necessary permissions to view this widget." + missing_permission: "Não tem as permissões necessárias para visualizar este widget." custom_text: title: 'Texto personalizado' documents: diff --git a/modules/grids/config/locales/crowdin/pt-BR.yml b/modules/grids/config/locales/crowdin/pt-BR.yml index 1f8442bd164d..94418b933836 100644 --- a/modules/grids/config/locales/crowdin/pt-BR.yml +++ b/modules/grids/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: grids: label_widget_in_grid: "Widget contido na Grade %{grid_name}" activerecord: diff --git a/modules/grids/config/locales/crowdin/pt-PT.yml b/modules/grids/config/locales/crowdin/pt-PT.yml index 34c68b02bce0..dbc6415e633a 100644 --- a/modules/grids/config/locales/crowdin/pt-PT.yml +++ b/modules/grids/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: grids: label_widget_in_grid: "Widget contido na Grelha %{grid_name}" activerecord: diff --git a/modules/job_status/config/locales/crowdin/js-pt-BR.yml b/modules/job_status/config/locales/crowdin/js-pt-BR.yml index fb2cf29d3368..f69fcd5a0e39 100644 --- a/modules/job_status/config/locales/crowdin/js-pt-BR.yml +++ b/modules/job_status/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: job_status: download_starts: 'O download deve iniciar automaticamente.' diff --git a/modules/job_status/config/locales/crowdin/js-pt-PT.yml b/modules/job_status/config/locales/crowdin/js-pt-PT.yml index ac1e013d8119..1dbc20237719 100644 --- a/modules/job_status/config/locales/crowdin/js-pt-PT.yml +++ b/modules/job_status/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: job_status: download_starts: 'O download deve iniciar automaticamente.' diff --git a/modules/job_status/config/locales/crowdin/pt-BR.yml b/modules/job_status/config/locales/crowdin/pt-BR.yml index ec08d1fbbb60..45c91159ffe6 100644 --- a/modules/job_status/config/locales/crowdin/pt-BR.yml +++ b/modules/job_status/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_job_status: name: "Situação do trabalho OpenProject" description: "Listagem e situação dos trabalhos em segundo plano." diff --git a/modules/job_status/config/locales/crowdin/pt-PT.yml b/modules/job_status/config/locales/crowdin/pt-PT.yml index 01b7b950b87b..bec78ecb81c7 100644 --- a/modules/job_status/config/locales/crowdin/pt-PT.yml +++ b/modules/job_status/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_job_status: name: "Estado do trabalho OpenProject" description: "Listagem e estado dos trabalhos em segundo plano." diff --git a/modules/ldap_groups/config/locales/crowdin/ms.yml b/modules/ldap_groups/config/locales/crowdin/ms.yml index 0d13fb571b4c..e24a0ebb4176 100644 --- a/modules/ldap_groups/config/locales/crowdin/ms.yml +++ b/modules/ldap_groups/config/locales/crowdin/ms.yml @@ -51,8 +51,8 @@ ms: destroy: title: 'Keluarkan kumpulan yang diselaraskan %{name}' confirmation: "Jika anda teruskan, kumpulan yang diselaraskan %{name} dan semua pengguna %{users_count} yang diselaraskan melalui itu akan dikeluarkan." - info: "Note: The OpenProject group itself and members added outside this LDAP synchronization will not be removed." - verification: "Enter the group's name %{name} to verify the deletion." + info: "Perhatian: Kumpulan OpenProject itu sendiri dan ahli yang ditambah di luar penjanaan LDAP tidak akan dikeluarkan." + verification: "Masukkan nama kumpulan %{name} untuk mengesahkan pembuangan." help_text_html: | This module allows you to set up a synchronization between LDAP and OpenProject groups. It depends on LDAP groups need to use the groupOfNames / memberOf attribute set to be working with OpenProject. diff --git a/modules/ldap_groups/config/locales/crowdin/pt-BR.yml b/modules/ldap_groups/config/locales/crowdin/pt-BR.yml index 520680581282..5674ce36a1e4 100644 --- a/modules/ldap_groups/config/locales/crowdin/pt-BR.yml +++ b/modules/ldap_groups/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_ldap_groups: name: "Grupos LDAP do OpenProject" description: "Sincronização de associações de grupos LDAP." diff --git a/modules/ldap_groups/config/locales/crowdin/pt-PT.yml b/modules/ldap_groups/config/locales/crowdin/pt-PT.yml index e78e0ac38495..3bf9b12f09ed 100644 --- a/modules/ldap_groups/config/locales/crowdin/pt-PT.yml +++ b/modules/ldap_groups/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_ldap_groups: name: "Grupos LDAP do OpenProject" description: "Sincronização de associações de grupos LDAP." diff --git a/modules/ldap_groups/lib/tasks/ldap_groups.rake b/modules/ldap_groups/lib/tasks/ldap_groups.rake index ff24331508a8..e23f4deb538c 100644 --- a/modules/ldap_groups/lib/tasks/ldap_groups.rake +++ b/modules/ldap_groups/lib/tasks/ldap_groups.rake @@ -128,6 +128,7 @@ namespace :ldap_groups do uid=aa729,ou=people,dc=example,dc=com (Password: smada) uid=bb459,ou=people,dc=example,dc=com (Password: niwdlab) uid=cc414,ou=people,dc=example,dc=com (Password: retneprac) + uid=bölle,ou=people,dc=example,dc=com (Password: bólle) -------------------------------------------------------- diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb index ccb33ead7598..deac9f8fe520 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb @@ -70,7 +70,7 @@ ml: 2, test_selector: 'op-meeting-agenda-actions') edit_action_item(menu) if @meeting_agenda_item.editable? - add_note_action_item(menu) if @meeting_agenda_item.editable? + add_note_action_item(menu) if @meeting_agenda_item.editable? && @meeting_agenda_item.notes.blank? move_actions(menu) delete_action_item(menu) end diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb index 2955f10eb7f3..430e91945115 100644 --- a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb +++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.html.erb @@ -5,11 +5,14 @@ title: @title, size: @size )) do |dialog| - dialog.with_show_button(**merged_button_attributes) do |button| - button.with_leading_visual_icon(icon: @button_icon) if @button_icon - @button_text + if @button_text + dialog.with_show_button(**merged_text_button_attributes) do |button| + button.with_leading_visual_icon(icon: @button_icon) if @button_icon + @button_text + end + else + dialog.with_show_button(**merged_icon_button_attributes) end - content_tag("turbo-frame", id: "#{@id}-frame", loading: :lazy, diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb index 6a8ef3ce3766..93a51f8815b8 100644 --- a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb +++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb @@ -32,7 +32,8 @@ class AsyncDialogComponent < ApplicationComponent include ApplicationHelper include ::OpPrimer::ComponentHelpers - def initialize(id:, src:, title:, button_icon: nil, button_text: nil, button_attributes: {}, size: :auto) + def initialize(id:, src:, title:, button_icon: nil, button_icon_label: nil, button_text: nil, button_attributes: {}, + size: :auto) super @id = id @@ -40,6 +41,7 @@ def initialize(id:, src:, title:, button_icon: nil, button_text: nil, button_att @title = title @size = size @button_icon = button_icon + @button_icon_label = button_icon_label @button_text = button_text @button_attributes = button_attributes end @@ -53,7 +55,7 @@ def stimulus_attributes } end - def merged_button_attributes + def merged_text_button_attributes stimuls_action_ref = 'click->op-turbo-op-primer-async-dialog#reinitFrame' @button_attributes[:data] = {} if @button_attributes[:data].nil? @@ -61,6 +63,12 @@ def merged_button_attributes @button_attributes end + + def merged_icon_button_attributes + merged_text_button_attributes.merge( + icon: @button_icon, 'aria-label': @button_icon_label + ) + end end end end diff --git a/modules/meeting/config/locales/crowdin/js-ms.yml b/modules/meeting/config/locales/crowdin/js-ms.yml index a633717befab..d209653a73bb 100644 --- a/modules/meeting/config/locales/crowdin/js-ms.yml +++ b/modules/meeting/config/locales/crowdin/js-ms.yml @@ -21,4 +21,4 @@ #++ ms: js: - label_meetings: 'Meetings' + label_meetings: 'Mesyuarat' diff --git a/modules/meeting/config/locales/crowdin/js-pt-BR.yml b/modules/meeting/config/locales/crowdin/js-pt-BR.yml index 5f850f4538e2..47c62b8943fd 100644 --- a/modules/meeting/config/locales/crowdin/js-pt-BR.yml +++ b/modules/meeting/config/locales/crowdin/js-pt-BR.yml @@ -19,6 +19,6 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: label_meetings: 'Reuniões' diff --git a/modules/meeting/config/locales/crowdin/js-pt-PT.yml b/modules/meeting/config/locales/crowdin/js-pt-PT.yml index 5f850f4538e2..16cdac936349 100644 --- a/modules/meeting/config/locales/crowdin/js-pt-PT.yml +++ b/modules/meeting/config/locales/crowdin/js-pt-PT.yml @@ -19,6 +19,6 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: label_meetings: 'Reuniões' diff --git a/modules/meeting/config/locales/crowdin/ms.seeders.yml b/modules/meeting/config/locales/crowdin/ms.seeders.yml index 595a75234889..ffdf184cb36f 100644 --- a/modules/meeting/config/locales/crowdin/ms.seeders.yml +++ b/modules/meeting/config/locales/crowdin/ms.seeders.yml @@ -9,21 +9,21 @@ ms: demo-project: meetings: item_0: - title: Weekly + title: Mingguan meeting_agenda_items: item_0: - title: Good news + title: Berita baik item_1: - title: Updates from development team + title: Kemas kini dari pasukan pembangunan item_2: - title: Updates from product team + title: Kemas kini dari pasukan produk item_3: - title: Updates from marketing team + title: Kemas kini dari pasukan pemasaran item_4: - title: Updates from sales team + title: Kemas kini dari pasukan jualan item_5: - title: Review of quarterly goals + title: Penilaian matlamat suku tahunan item_6: - title: Core values feedback + title: Maklum balas nilai teras item_7: - title: General topics + title: Topik umum diff --git a/modules/meeting/config/locales/crowdin/ms.yml b/modules/meeting/config/locales/crowdin/ms.yml index a6f52fc90ea7..9ad233d4322b 100644 --- a/modules/meeting/config/locales/crowdin/ms.yml +++ b/modules/meeting/config/locales/crowdin/ms.yml @@ -28,156 +28,156 @@ ms: activerecord: attributes: meeting: - type: "Meeting type" - location: "Location" - duration: "Duration" - notes: "Notes" - participants: "Participants" + type: "Jenis mesyuarat" + location: "Lokasi" + duration: "Tempoh" + notes: "Nota" + participants: "Peserta" participant: - other: "%{count} Participants" - participants_attended: "Attendees" - participants_invited: "Invitees" - project: "Project" - start_date: "Date" - start_time: "Time" - start_time_hour: "Starting time" + other: "1 Peserta" + participants_attended: "Peserta" + participants_invited: "Jemputan" + project: "Projek" + start_date: "Tarikh" + start_time: "Masa" + start_time_hour: "Masa mula" meeting_agenda_items: - title: "Title" - author: "Responsible" - duration_in_minutes: "Duration (min)" - description: "Notes" + title: "Tajuk" + author: "Bertanggungjawab" + duration_in_minutes: "Tempoh (minit)" + description: "Nota" errors: messages: - invalid_time_format: "is not a valid time. Required format: HH:MM" + invalid_time_format: "bukan masa yang sah. Format yang diperlukan: JJ:MM" models: - structured_meeting: "Meeting (dynamic)" - meeting_agenda_item: "Agenda item" + structured_meeting: "Mesyuarat (dinamik)" + meeting_agenda_item: "Item agenda" meeting_agenda: "Agenda" - meeting_minutes: "Minutes" + meeting_minutes: "Minit mesyuarat" activity: filter: - meeting: "Meetings" - description_attended: "attended" - description_invite: "invited" + meeting: "Mesyuarat" + description_attended: "dihadiri" + description_invite: "dijemput" events: - meeting: Meeting edited - meeting_agenda: Meeting agenda edited - meeting_agenda_closed: Meeting agenda closed - meeting_agenda_opened: Meeting agenda opened - meeting_minutes: Meeting minutes edited - meeting_minutes_created: Meeting minutes created - error_notification_with_errors: "Failed to send notification. The following recipients could not be notified: %{recipients}" - label_meeting: "Meeting" - label_meeting_plural: "Meetings" - label_meeting_new: "New Meeting" - label_meeting_edit: "Edit Meeting" + meeting: Mesyuarat telah diedit + meeting_agenda: Agenda mesyuarat yang diedit + meeting_agenda_closed: Agenda mesyuarat ditutup + meeting_agenda_opened: Agenda mesyuarat dibuka + meeting_minutes: Minit mesyuarat diedit + meeting_minutes_created: Minit mesyuarat dicipta + error_notification_with_errors: "Gagal untuk hantar pemberitahuan. Penerima berikut tidak dapat diberitahu: %{recipients}" + label_meeting: "Mesyuarat" + label_meeting_plural: "Mesyuarat" + label_meeting_new: "Mesyuarat Baharu" + label_meeting_edit: "Edit mesyuarat" label_meeting_agenda: "Agenda" - label_meeting_minutes: "Minutes" - label_meeting_close: "Close" - label_meeting_open: "Open" - label_meeting_agenda_close: "Close the agenda to begin the Minutes" - label_meeting_date_time: "Date/Time" - label_meeting_diff: "Diff" - label_upcoming_meetings: "Upcoming meetings" - label_past_meetings: "Past meetings" - label_upcoming_meetings_short: "Upcoming" - label_past_meetings_short: "Past" - label_involvement: "Involvement" - label_upcoming_invitations: "Upcoming invitations" - label_past_invitations: "Past invitations" - label_attendee: "Attendee" - label_author: "Creator" - label_notify: "Send for review" - label_icalendar: "Send iCalendar" - label_icalendar_download: "Download iCalendar event" + label_meeting_minutes: "Minit" + label_meeting_close: "Tutup" + label_meeting_open: "Buka" + label_meeting_agenda_close: "Tutup agenda untuk mulakan minit" + label_meeting_date_time: "Tarikh/Masa" + label_meeting_diff: "Beza" + label_upcoming_meetings: "Mesyuarat akan datang" + label_past_meetings: "Mesyuarat lalu" + label_upcoming_meetings_short: "Akan Datang" + label_past_meetings_short: "Lalu" + label_involvement: "Penglibatan" + label_upcoming_invitations: "Jemputan akan datang" + label_past_invitations: "Jemputan lalu" + label_attendee: "Peserta" + label_author: "Pencipta" + label_notify: "Hantar untuk semakan" + label_icalendar: "Hantar iCalendar\n" + label_icalendar_download: "Muat turun acara dalam iCalendar" label_version: "Versi" - label_time_zone: "Time zone" - label_start_date: "Start date" + label_time_zone: "Zon waktu" + label_start_date: "Tarikh mula" meeting: copy: - title: "Copy meeting %{title}" - agenda: "Copy agenda" - agenda_text: "Copy the agenda of the old meeting" + title: "Salin mesyuarat %{title}" + agenda: "Salin agenda" + agenda_text: "Salin agenda mesyuarat lama" email: - open_meeting_link: "Open meeting" + open_meeting_link: "Buka mesyuarat" invited: - summary: "%{actor} has sent you an invitation for the meeting %{title}" + summary: "%{actor} menghantar anda jemputan untuk mesyuarat %{title}" rescheduled: header: "Meeting %{title} has been rescheduled" - summary: "Meeting %{title} has been rescheduled by %{actor}" - body: "The meeting %{title} has been rescheduled by %{actor}." - old_date_time: "Old date/time" - new_date_time: "New date/time" - label_mail_all_participants: "Send email to all participants" + summary: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + body: "Mesyuarat %{title} telah dijadual semula oleh %{actor}" + old_date_time: "Tarikh/masa lama" + new_date_time: "Tarikh/masa baru" + label_mail_all_participants: "Hantar emel ke semua peserta" types: - classic: 'Classic' - classic_text: 'Organize your meeting in a formattable text agenda and protocol.' - structured: 'Dynamic' - structured_text: 'Organize your meeting as a list of agenda items, optionally linking them to a work package.' - structured_text_copy: 'Copying a meeting will currently not copy the associated meeting agenda items, just the details' - copied: "Copied from Meeting #%{id}" - notice_successful_notification: "Notification sent successfully" - notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. - permission_create_meetings: "Create meetings" - permission_edit_meetings: "Edit meetings" - permission_delete_meetings: "Delete meetings" - permission_view_meetings: "View meetings" - permission_create_meeting_agendas: "Create meeting agendas" - permission_create_meeting_agendas_explanation: "Allows editing the Classic Meeting's agenda content." - permission_manage_agendas: "Manage agendas" - permission_manage_agendas_explanation: "Allows managing the Dynamic Meeting's agenda items." - permission_close_meeting_agendas: "Close agendas" - permission_send_meeting_agendas_notification: "Send review notification for agendas" - permission_create_meeting_minutes: "Manage minutes" - permission_send_meeting_minutes_notification: "Send review notification for minutes" - permission_meetings_send_invite: "Invite users to meetings" - permission_send_meeting_agendas_icalendar: "Send meeting agenda as calendar entry" - project_module_meetings: "Meetings" - text_duration_in_hours: "Duration in hours" - text_in_hours: "in hours" - text_meeting_agenda_for_meeting: 'agenda for the meeting "%{meeting}"' - text_meeting_closing_are_you_sure: "Are you sure you want to close the meeting agenda?" - text_meeting_agenda_open_are_you_sure: "This will overwrite all changes in the minutes! Do you want to continue?" - text_meeting_minutes_for_meeting: 'minutes for the meeting "%{meeting}"' - text_notificiation_invited: "This mail contains an ics entry for the meeting below:" - text_meeting_empty_heading: "Your meeting is empty" - text_meeting_empty_description_1: "Start by adding agenda items below. Each item can be as simple as just a title, but you can also add additional details like duration and notes." - text_meeting_empty_description_2: "You can also add references to existing work packages. When you do, related notes will automatically be visible in the work package's \"Meetings\" tab." - label_meeting_empty_action: "Add agenda item" - label_meeting_actions: "Meeting actions" - label_meeting_edit_title: "Edit meeting title" - label_meeting_delete: "Delete meeting" - label_meeting_created_by: "Created by" - label_meeting_last_updated: "Last updated" - label_agenda_item_undisclosed_wp: "Work package #%{id} not visible" - label_agenda_item_deleted_wp: "Deleted work package reference" - label_agenda_item_actions: "Agenda items actions" - label_agenda_item_move_to_top: "Move to top" - label_agenda_item_move_to_bottom: "Move to bottom" - label_agenda_item_move_up: "Move up" - label_agenda_item_move_down: "Move down" - label_agenda_item_add_notes: "Add notes" - label_meeting_details: "Meeting details" - label_meeting_details_edit: "Edit meeting details" - label_meeting_state_open: "Open" - label_meeting_state_closed: "Closed" - label_meeting_reopen_action: "Reopen meeting" - label_meeting_close_action: "Close meeting" - text_meeting_open_description: "This meeting is open. You can add/remove agenda items and edit them as you please. After the meeting is over, close it to lock it." - text_meeting_closed_description: "This meeting is closed. You cannot add/remove agenda items anymore." - label_meeting_manage_participants: "Manage participants" - label_meeting_no_participants: "No participants" - label_meeting_show_hide_participants: "Show/hide %{count} more" - label_meeting_show_all_participants: "Show all" - label_meeting_add_participants: "Add participants" - text_meeting_not_editable_anymore: "This meeting is not editable anymore." - text_meeting_not_present_anymore: "This meeting was deleted. Please select another meeting." - label_add_work_package_to_meeting_dialog_title: "Add work package to meeting" - label_add_work_package_to_meeting_dialog_button: "Add to meeting" + classic: 'Klasik' + classic_text: 'Susun mesyuarat anda dalam agenda teks boleh format dan protokol.' + structured: 'Dinamik' + structured_text: 'Susun mesyuarat anda sebagai senarai butiran agenda, secara pilihan menghubungnya kepada pakej kerja.' + structured_text_copy: 'Menyalin mesyuarat pada masa ini tidak akan menyalin item agenda mesyuarat yang berkaitan, hanya butiran sahaja' + copied: "Salin dari mesyuarat #%{id}" + notice_successful_notification: "Pemberitahuan berjaya dihantar" + notice_timezone_missing: Tiada zon waktu yang ditetapkan dan %{zone} adalah andaian.Untuk pilih zon waktu anda, sila klik sini. + permission_create_meetings: "Cipta mesyuarat" + permission_edit_meetings: "Edit mesyuarat" + permission_delete_meetings: "Hapuskan Mesyuarat" + permission_view_meetings: "Lihat Mesyuarat" + permission_create_meeting_agendas: "Cipta agenda mesyuarat" + permission_create_meeting_agendas_explanation: "Benarkan kandungan agenda Mesyuarat Klasik untuk diedit" + permission_manage_agendas: "Urus agenda" + permission_manage_agendas_explanation: "Benarkan pengurusan item agenda Dinamik Mesyuarat." + permission_close_meeting_agendas: "Tutup agenda" + permission_send_meeting_agendas_notification: "Hantar pemberitahuan semakan untuk agenda" + permission_create_meeting_minutes: "Urus minit mesyuarat" + permission_send_meeting_minutes_notification: "Hantar pemberitahuan semakan untuk minit mesyuarat" + permission_meetings_send_invite: "Jemput pengguna ke mesyuarat" + permission_send_meeting_agendas_icalendar: "Hantar agenda mesyuarat sebagai kemasukan kalendar" + project_module_meetings: "Mesyuarat" + text_duration_in_hours: "Jangka masa dalam jam" + text_in_hours: "dalam jam" + text_meeting_agenda_for_meeting: 'agenda untuk mesyuarat "%{meeting}"' + text_meeting_closing_are_you_sure: "Adakah anda pasti anda ingin menutup agenda mesyuarat?" + text_meeting_agenda_open_are_you_sure: "Ini akan menggantikan semua perubahan dalam minit mesyuarat! Adakah anda ingin teruskan?" + text_meeting_minutes_for_meeting: 'minit untuk mesyuarat "%{meeting}"' + text_notificiation_invited: "Mel ini mengandungi kemasukan ics untuk mesyuarat dibawah:" + text_meeting_empty_heading: "Mesyuarat anda kosong" + text_meeting_empty_description_1: "Mula dengan menambah item agenda dibawah. Setiap item boleh jadi seringkas tajuk, tapi anda juga boleh tambah butiran tambahan seperti jangka masa dan nota." + text_meeting_empty_description_2: "Anda juga boleh menambah rujukan ke pakej kerja yang sedia ada. Apabila anda lakukan, nota berkaitan secara automatik akan boleh dilihat dalam tab \"Mesyuarat\" pakej kerja." + label_meeting_empty_action: "Tambah item agenda" + label_meeting_actions: "Tindakan mesyuarat" + label_meeting_edit_title: "Edit tajuk mesyuarat" + label_meeting_delete: "Hapuskan mesyuarat" + label_meeting_created_by: "Dicipta oleh" + label_meeting_last_updated: "Kemas kini terakhir" + label_agenda_item_undisclosed_wp: "Pakej kerja #%{id} tidak kelihatan" + label_agenda_item_deleted_wp: "Hapuskan rujukan pakej kerja" + label_agenda_item_actions: "Tindakan item agenda" + label_agenda_item_move_to_top: "Alih ke paling atas" + label_agenda_item_move_to_bottom: "Alih ke paling bawah" + label_agenda_item_move_up: "Alihkan ke atas" + label_agenda_item_move_down: "Gerak ke bawah" + label_agenda_item_add_notes: "Tambah nota" + label_meeting_details: "Butiran mesyuarat" + label_meeting_details_edit: "Edit butiran mesyuarat" + label_meeting_state_open: "Buka" + label_meeting_state_closed: "Ditutup" + label_meeting_reopen_action: "Buka semula mesyuarat" + label_meeting_close_action: "Tutup mesyuarat" + text_meeting_open_description: "Mesyuarat ini terbuka. Anda boleh tambah/keluarkan item agenda dan edit mereka sesuka hati. Setelah mesyuarat berakhir, tutup mesyuarat untuk kunci." + text_meeting_closed_description: "Mesyuarat ini ditutup. Anda tidak boleh tambah/keluarkan item agenda lagi." + label_meeting_manage_participants: "Urus peserta" + label_meeting_no_participants: "Tiada peserta" + label_meeting_show_hide_participants: "Tunjuk/hilangkan %{count} lagi" + label_meeting_show_all_participants: "Tunjukkan semua" + label_meeting_add_participants: "Tambah peserta" + text_meeting_not_editable_anymore: "Mesyuarat ini tidak boleh diedit lagi." + text_meeting_not_present_anymore: "Mesyuarat ini telah dihapuskan. Sila pilih mesyuarat lain." + label_add_work_package_to_meeting_dialog_title: "Tambah pakej kerja ke mesyuarat" + label_add_work_package_to_meeting_dialog_button: "Tambah ke mesyuarat" label_meeting_selection_caption: "It's only possible to add this work package to upcoming or ongoing open meetings." - text_add_work_package_to_meeting_description: "A work package can be added to one or multiple meetings for discussion. Any notes concerning it are also visible here." - text_agenda_item_no_notes: "No notes provided" - text_agenda_item_not_editable_anymore: "This agenda item is not editable anymore." - text_work_package_has_no_upcoming_meeting_agenda_items: "This work package is not scheduled in an upcoming meeting agenda yet." - text_work_package_add_to_meeting_hint: "Use the \"Add to meeting\" button to add this work package to an upcoming meeting." - text_work_package_has_no_past_meeting_agenda_items: "This work package was not mentioned in a past meeting." + text_add_work_package_to_meeting_description: "Pakej kerja boleh ditambah kepada satu atau beberapa mesyuarat untuk perbincangan. Sebarang nota berkaitan juga kelihatan di sini." + text_agenda_item_no_notes: "Tiada nota disediakan" + text_agenda_item_not_editable_anymore: "Item agenda tidak boleh diedit lagi." + text_work_package_has_no_upcoming_meeting_agenda_items: "Pakej kerja ini belum dijadualkan dalam mana-mana agenda mesyuarat akan datang lagi." + text_work_package_add_to_meeting_hint: "Guna butang \"Tambah ke mesyuarat\" untuk tambah pakej kerja ini ke mesyuarat akan datang." + text_work_package_has_no_past_meeting_agenda_items: "Pakej kerja ini masih belum diutarakan dalam mesyuarat yang lalu." diff --git a/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml b/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml index 8049e00f3b77..2d1eb4c6d26e 100644 --- a/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml +++ b/modules/meeting/config/locales/crowdin/pt-BR.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-BR: seeds: standard: projects: diff --git a/modules/meeting/config/locales/crowdin/pt-BR.yml b/modules/meeting/config/locales/crowdin/pt-BR.yml index 5e6db0db004f..c20cde6f42ac 100644 --- a/modules/meeting/config/locales/crowdin/pt-BR.yml +++ b/modules/meeting/config/locales/crowdin/pt-BR.yml @@ -20,7 +20,7 @@ #See COPYRIGHT and LICENSE files for more details. #++ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_meeting: name: "Reunião do OpenProject" description: >- diff --git a/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml b/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml index 80e5be5ebe2f..4f63928bdae6 100644 --- a/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml +++ b/modules/meeting/config/locales/crowdin/pt-PT.seeders.yml @@ -2,7 +2,7 @@ #Please do not edit directly. #This file is part of the sources sent to crowdin for translation. --- -pt: +pt-PT: seeds: standard: projects: diff --git a/modules/meeting/config/locales/crowdin/pt-PT.yml b/modules/meeting/config/locales/crowdin/pt-PT.yml index 87e1555225bf..5431a9b95d5e 100644 --- a/modules/meeting/config/locales/crowdin/pt-PT.yml +++ b/modules/meeting/config/locales/crowdin/pt-PT.yml @@ -20,7 +20,7 @@ #See COPYRIGHT and LICENSE files for more details. #++ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_meeting: name: "Reunião do OpenProject" description: >- diff --git a/modules/meeting/spec/features/meetings_attachments_spec.rb b/modules/meeting/spec/features/meetings_attachments_spec.rb index e628b662d8e9..629b5198470a 100644 --- a/modules/meeting/spec/features/meetings_attachments_spec.rb +++ b/modules/meeting/spec/features/meetings_attachments_spec.rb @@ -1,7 +1,7 @@ -require 'spec_helper' -require 'features/page_objects/notification' +require "spec_helper" +require "features/page_objects/notification" -RSpec.describe 'Add an attachment to a meeting (agenda)', :js, with_cuprite: false do +RSpec.describe "Add an attachment to a meeting (agenda)", :js, with_cuprite: false do let(:role) do create(:project_role, permissions: %i[view_meetings edit_meetings create_meeting_agendas]) end @@ -22,7 +22,7 @@ end let(:attachments) { Components::Attachments.new } - let(:image_fixture) { UploadedFile.load_from('spec/fixtures/files/image.png') } + let(:image_fixture) { UploadedFile.load_from("spec/fixtures/files/image.png") } let(:editor) { Components::WysiwygEditor.new } let(:attachments_list) { Components::AttachmentsList.new } @@ -36,27 +36,27 @@ end end - describe 'wysiwyg editor' do - context 'if on an existing page' do - it 'can upload an image via drag & drop' do - find('.ck-content') + describe "wysiwyg editor" do + context "if on an existing page" do + it "can upload an image via drag & drop" do + find(".ck-content") - editor.expect_button 'Upload image from computer' + editor.expect_button "Upload image from computer" - editor.drag_attachment image_fixture.path, 'Some image caption' + editor.drag_attachment image_fixture.path, "Some image caption" click_on "Save" - content = find_test_selector('op-meeting--meeting_agenda') + content = find_test_selector("op-meeting--meeting_agenda") - expect(content).to have_css('img') - expect(content).to have_content('Some image caption') + expect(content).to have_css("img") + expect(content).to have_content("Some image caption") end end end - describe 'attachment dropzone' do - it 'can upload an image via attaching and drag & drop' do + describe "attachment dropzone" do + it "can upload an image via attaching and drag & drop" do editor.wait_until_loaded attachments_list.wait_until_visible @@ -65,14 +65,14 @@ editor.attachments_list.expect_empty attachments.attach_file_on_input(image_fixture.path) editor.wait_until_upload_progress_toaster_cleared - editor.attachments_list.expect_attached('image.png') + editor.attachments_list.expect_attached("image.png") ## # and via drag & drop editor.attachments_list.drag_enter editor.attachments_list.drop(image_fixture) editor.wait_until_upload_progress_toaster_cleared - editor.attachments_list.expect_attached('image.png', count: 2) + editor.attachments_list.expect_attached("image.png", count: 2) end end end diff --git a/modules/my_page/config/locales/crowdin/js-ms.yml b/modules/my_page/config/locales/crowdin/js-ms.yml index 80596bfff1f9..d831c4788ca9 100644 --- a/modules/my_page/config/locales/crowdin/js-ms.yml +++ b/modules/my_page/config/locales/crowdin/js-ms.yml @@ -1,4 +1,4 @@ ms: js: my_page: - label: "My page" + label: "Halaman saya" diff --git a/modules/my_page/config/locales/crowdin/js-pt-BR.yml b/modules/my_page/config/locales/crowdin/js-pt-BR.yml index 859e96f039bf..03810b223f34 100644 --- a/modules/my_page/config/locales/crowdin/js-pt-BR.yml +++ b/modules/my_page/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: my_page: label: "Minha página" diff --git a/modules/my_page/config/locales/crowdin/js-pt-PT.yml b/modules/my_page/config/locales/crowdin/js-pt-PT.yml index d9dc89e0571b..787cc615dbb6 100644 --- a/modules/my_page/config/locales/crowdin/js-pt-PT.yml +++ b/modules/my_page/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: my_page: label: "A minha página" diff --git a/modules/openid_connect/config/locales/crowdin/ms.yml b/modules/openid_connect/config/locales/crowdin/ms.yml index b9415982cf55..54b0a4a5da91 100644 --- a/modules/openid_connect/config/locales/crowdin/ms.yml +++ b/modules/openid_connect/config/locales/crowdin/ms.yml @@ -3,15 +3,15 @@ ms: name: "OpenProject OpenID Connect" description: "Adds OmniAuth OpenID Connect strategy providers to Openproject." logout_warning: > - You have been logged out. The contents of any form you submit may be lost. Please [log in]. + Anda telah log keluar. Apa-apa bentuk kandungan yang anda hantar mungkin hilang. Sila [log masuk]. activemodel: attributes: openid_connect/provider: - name: Name - display_name: Display name + name: Nama + display_name: Nama paparan identifier: Identifier - secret: Secret - scope: Scope + secret: "Sulit\n" + scope: Skop limit_self_registration: Limit self registration openid_connect: menu_title: OpenID providers diff --git a/modules/openid_connect/config/locales/crowdin/pt-BR.yml b/modules/openid_connect/config/locales/crowdin/pt-BR.yml index 41d80a844de5..74764dad5681 100644 --- a/modules/openid_connect/config/locales/crowdin/pt-BR.yml +++ b/modules/openid_connect/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_openid_connect: name: "Conectar OpenProject OpenID" description: "Adiciona provedores de estratégia OmniAuth OpenID Connect ao Openproject." diff --git a/modules/openid_connect/config/locales/crowdin/pt-PT.yml b/modules/openid_connect/config/locales/crowdin/pt-PT.yml index e64f1d0d91f2..8ac260196e32 100644 --- a/modules/openid_connect/config/locales/crowdin/pt-PT.yml +++ b/modules/openid_connect/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_openid_connect: name: "OpenProject OpenID Connect" description: "Adiciona fornecedores da estratégia OmniAuth OpenID Connect ao Openproject." diff --git a/modules/overviews/app/components/_index.sass b/modules/overviews/app/components/_index.sass new file mode 100644 index 000000000000..fc1fdcf84e08 --- /dev/null +++ b/modules/overviews/app/components/_index.sass @@ -0,0 +1 @@ +@import "./project_custom_fields/sections/project_custom_fields/show_component.sass" diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb new file mode 100644 index 000000000000..a492eff70b5d --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.html.erb @@ -0,0 +1,42 @@ +<%= + content_tag("turbo-frame", id: "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}-frame") do + component_wrapper(data: { qa_selector: 'async-dialog-content' }) do + primer_form_with( + model: @project, + method: :put, + url: update_project_custom_values_path(project_id: @project.id, section_id: @project_custom_field_section.id), + ) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new(my: 3, classes: "Overlay-body_autocomplete_height")) do + render(Projects::CustomFields::Form.new( + f, + project: @project, + custom_field_section: @project_custom_field_section, + wrapper_id: "#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" + )) + end + collection.with_component(Primer::Alpha::Dialog::Footer.new) do + component_collection do |footer_collection| + footer_collection.with_component(Primer::ButtonComponent.new( + data: { + 'close-dialog-id': "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" + } + )) do + t("button_cancel") + end + footer_collection.with_component(Primer::ButtonComponent.new( + scheme: :primary, + type: :submit, + data: { + qa_selector: 'save-project-attributes-button' + } + )) do + t("button_save") + end + end + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb new file mode 100644 index 000000000000..9e2d79744951 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/edit_dialog_component.rb @@ -0,0 +1,49 @@ +#-- 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 ProjectCustomFields + module Sections + class EditDialogComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + def initialize(project:, + project_custom_field_section:) + super + + @project = project + @project_custom_field_section = project_custom_field_section + end + + def wrapper_uniq_by + @project_custom_field_section.id + end + end + end +end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb new file mode 100644 index 000000000000..94c5f51a0063 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.html.erb @@ -0,0 +1,18 @@ +<%= + flex_layout(align_items: :flex_start, justify_content: :space_between, classes: 'op-project-custom-field-container', data: { + qa_selector: "project-custom-field-#{@project_custom_field.id}" + }) do |custom_field_value_container| + # temporarily using inline styles in order to align the content as desired + custom_field_value_container.with_row(mb: 1) do + render(Primer::Beta::Text.new(font_weight: :bold)) { @project_custom_field.name } + end + + custom_field_value_container.with_row do + if not_set? + render(Primer::Beta::Text.new()) { t('placeholders.default') } + else + render_value + end + end + end +%> diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb new file mode 100644 index 000000000000..c8f131378114 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.rb @@ -0,0 +1,120 @@ +#-- 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 ProjectCustomFields + module Sections + module ProjectCustomFields + class ShowComponent < ApplicationComponent + include ApplicationHelper + include CustomFieldsHelper + include OpPrimer::ComponentHelpers + + def initialize(project_custom_field:, project_custom_field_values:) + super + + @project_custom_field = project_custom_field + @project_custom_field_values = project_custom_field_values + end + + private + + def not_set? + @project_custom_field_values.empty? || @project_custom_field_values.all? { |cf_value| cf_value.value.blank? } + end + + def render_value + case @project_custom_field.field_format + when "text" + render_rich_text + when "user" + render_user + else + render(Primer::Beta::Text.new) do + @project_custom_field_values&.map do |cf_value| + format_value(cf_value.value, @project_custom_field) + end&.join(", ") + end + end + end + + def render_rich_text + truncation_length = 100 + + if @project_custom_field_values.first&.value&.length.to_i > truncation_length + render_truncated_preview_and_dialog_for_rich_text_value(truncation_length) + else + render(Primer::Beta::Text.new) do + format_value(@project_custom_field_values.first&.value, @project_custom_field) + end + end + end + + def render_truncated_preview_and_dialog_for_rich_text_value(truncation_length) + flex_layout do |rich_text_preview_container| + rich_text_preview_container.with_row do + render(Primer::Beta::Text.new(classes: 'project-custom-fields-rich-text-preview')) do + format_value( + @project_custom_field_values.first&.value&.truncate(truncation_length), + @project_custom_field + ) + end + render_dialog + end + end + end + + def render_dialog + render(Primer::Alpha::Dialog.new(size: :medium_portrait, title: @project_custom_field.name)) do |dialog| + dialog.with_show_button(scheme: :link) { t(:label_expand) } + # TODO: remove inline style + dialog.with_body(style: "max-height: 500px;") do + format_value(@project_custom_field_values.first&.value, @project_custom_field) + end + end + end + + def render_user + if @project_custom_field.multi_value? + flex_layout do |avatar_container| + @project_custom_field_values&.each do |cf_value| + avatar_container.with_row do + render_avatar(cf_value.typed_value) + end + end + end + else + render_avatar(@project_custom_field_values&.first&.typed_value) + end + end + + def render_avatar(user) + render(Users::AvatarComponent.new(user:, size: :mini)) + end + end + end + end +end diff --git a/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass new file mode 100644 index 000000000000..77fb285f9188 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/project_custom_fields/show_component.sass @@ -0,0 +1,3 @@ +.project-custom-fields-rich-text-preview + :last-child + display: inline diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb new file mode 100644 index 000000000000..b42340b6e217 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/show_component.html.erb @@ -0,0 +1,38 @@ +<%= + component_wrapper do + flex_layout(border: :bottom, pb: 2, classes: 'op-project-custom-field-section-container', data: { + qa_selector: "project-custom-field-section-#{@project_custom_field_section.id}" + }) do |details_container| + details_container.with_row(mb: 2) do + flex_layout(align_items: :center, justify_content: :space_between) do |heading| + heading.with_column(flex: 1) do + render(Primer::Beta::Heading.new(tag: :h4)) { @project_custom_field_section.name } + end + + heading.with_column do + render(OpTurbo::OpPrimer::AsyncDialogComponent.new( + id: "edit-project-custom-fields-dialog-#{@project_custom_field_section.id}", + src: project_custom_field_section_dialog_path(project_id: @project.id, section_id: @project_custom_field_section.id), + size: :medium_portrait, + title: @project_custom_field_section.name, + button_icon: :pencil, + button_icon_label: t(:label_edit), + button_attributes: { scheme: :invisible, data: { + qa_selector: "project-custom-field-section-edit-button" + } } + )) + end if allowed_to_edit? + end + end + + @project_custom_fields.each do |project_custom_field| + details_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ProjectCustomFields::ShowComponent.new( + project_custom_field: project_custom_field, + project_custom_field_values: get_eager_loaded_project_custom_field_values_for(project_custom_field.id) + )) + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_custom_fields/sections/show_component.rb b/modules/overviews/app/components/project_custom_fields/sections/show_component.rb new file mode 100644 index 000000000000..bce4f3bf995a --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sections/show_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 ProjectCustomFields + module Sections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:, project_custom_field_section:, project_custom_fields:) + super + + @project = project + @project_custom_field_section = project_custom_field_section + @project_custom_fields = project_custom_fields + + eager_load_project_custom_field_values + end + + private + + def allowed_to_edit? + User.current.allowed_in_project?(:edit_project, @project) + end + + def eager_load_project_custom_field_values + # TODO: move to service + @eager_loaded_project_custom_field_values = CustomValue + .includes(custom_field: :custom_options) + .where( + custom_field_id: @project_custom_fields.pluck(:id), + customized_id: @project.id + ) + .to_a + end + + def get_eager_loaded_project_custom_field_values_for(custom_field_id) + @eager_loaded_project_custom_field_values.select { |pcfv| pcfv.custom_field_id == custom_field_id } + end + end + end +end diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb new file mode 100644 index 000000000000..6f8f746c66ec --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.html.erb @@ -0,0 +1,17 @@ +<%= + component_wrapper do + if available_project_custom_fields_grouped_by_section.any? + flex_layout(data: { qa_selector: "project-custom-fields-sidebar-async-content" }) do |sections_container| + available_project_custom_fields_grouped_by_section.each do |project_custom_field_section, project_custom_fields| + sections_container.with_row(mb: 3) do + render(ProjectCustomFields::Sections::ShowComponent.new( + project: @project, + project_custom_field_section: , + project_custom_fields: project_custom_fields + )) + end + end + end + end + end +%> diff --git a/modules/overviews/app/components/project_custom_fields/sidebar_component.rb b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb new file mode 100644 index 000000000000..73350be6ecd2 --- /dev/null +++ b/modules/overviews/app/components/project_custom_fields/sidebar_component.rb @@ -0,0 +1,47 @@ +#-- 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 ProjectCustomFields + class SidebarComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(project:) + super + + @project = project + end + + private + def available_project_custom_fields_grouped_by_section + @available_project_custom_fields_grouped_by_section ||= + @project.available_custom_fields.group_by(&:project_custom_field_section) + end + end +end diff --git a/modules/overviews/app/controllers/overviews/overviews_controller.rb b/modules/overviews/app/controllers/overviews/overviews_controller.rb index 09afdaf29573..386b86988a5d 100644 --- a/modules/overviews/app/controllers/overviews/overviews_controller.rb +++ b/modules/overviews/app/controllers/overviews/overviews_controller.rb @@ -1,14 +1,75 @@ module ::Overviews class OverviewsController < ::Grids::BaseInProjectController + include OpTurbo::ComponentStream + + before_action :authorize before_action :jump_to_project_menu_item menu_item :overview + def project_custom_fields_sidebar + render :project_custom_fields_sidebar, layout: false + end + + def project_custom_field_section_dialog + render( + ProjectCustomFields::Sections::EditDialogComponent.new( + project: @project, + project_custom_field_section: find_project_custom_field_section + ), + layout: false + ) + end + + def update_project_custom_values + section = find_project_custom_field_section + + service_call = ::Projects::UpdateService + .new( + user: current_user, + model: @project + ) + .call( + permitted_params.project.merge( + _limit_custom_fields_validation_to_section_id: section.id + ) + ) + + if service_call.success? + update_sidebar_component + else + handle_errors(service_call.result, section) + end + + respond_to_with_turbo_streams(status: service_call.success? ? :ok : :unprocessable_entity) + end + def jump_to_project_menu_item if params[:jump] # try to redirect to the requested menu item redirect_to_project_menu_item(@project, params[:jump]) && return end end + + private + + def find_project_custom_field_section + ProjectCustomFieldSection.find(params[:section_id]) + end + + def handle_errors(project_with_errors, section) + update_via_turbo_stream( + component: ProjectCustomFields::Sections::EditDialogComponent.new( + project: project_with_errors, + project_custom_field_section: section + ) + ) + end + + def update_sidebar_component + update_via_turbo_stream( + component: ProjectCustomFields::SidebarComponent.new(project: @project) + ) + end end end diff --git a/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb new file mode 100644 index 000000000000..33deb6558c3c --- /dev/null +++ b/modules/overviews/app/views/overviews/overviews/project_custom_fields_sidebar.html.erb @@ -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. + +++#%> +<%= content_tag("turbo-frame", id: "project-custom-fields-sidebar") do %> + <%= render(ProjectCustomFields::SidebarComponent.new(project: @project)) %> +<% end %> \ No newline at end of file diff --git a/modules/overviews/config/locales/crowdin/js-ms.yml b/modules/overviews/config/locales/crowdin/js-ms.yml index ce56c542e74a..351a2df46c34 100644 --- a/modules/overviews/config/locales/crowdin/js-ms.yml +++ b/modules/overviews/config/locales/crowdin/js-ms.yml @@ -1,4 +1,4 @@ ms: js: overviews: - label: 'Overview' + label: 'Ringkasan' diff --git a/modules/overviews/config/locales/crowdin/js-pt-BR.yml b/modules/overviews/config/locales/crowdin/js-pt-BR.yml index 436bc25f1a36..7f83bd2a2caa 100644 --- a/modules/overviews/config/locales/crowdin/js-pt-BR.yml +++ b/modules/overviews/config/locales/crowdin/js-pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: js: overviews: label: 'Visão geral' diff --git a/modules/overviews/config/locales/crowdin/js-pt-PT.yml b/modules/overviews/config/locales/crowdin/js-pt-PT.yml index 0b2501692c3f..bf7cabe59466 100644 --- a/modules/overviews/config/locales/crowdin/js-pt-PT.yml +++ b/modules/overviews/config/locales/crowdin/js-pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: js: overviews: label: 'Sinopse' diff --git a/modules/overviews/config/locales/crowdin/ms.yml b/modules/overviews/config/locales/crowdin/ms.yml index 7ce6b82e59b0..0017c72bc4cb 100644 --- a/modules/overviews/config/locales/crowdin/ms.yml +++ b/modules/overviews/config/locales/crowdin/ms.yml @@ -1,4 +1,4 @@ ms: overviews: - label: 'Overview' - permission_manage_overview: 'Manage overview page' + label: 'Ringkasan' + permission_manage_overview: 'Urus halaman ringkasan' diff --git a/modules/overviews/config/locales/crowdin/pt-BR.yml b/modules/overviews/config/locales/crowdin/pt-BR.yml index d52a98c9f0e4..62a5e4574b57 100644 --- a/modules/overviews/config/locales/crowdin/pt-BR.yml +++ b/modules/overviews/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: overviews: label: 'Visão geral' permission_manage_overview: 'Gerenciar página de visão geral' diff --git a/modules/overviews/config/locales/crowdin/pt-PT.yml b/modules/overviews/config/locales/crowdin/pt-PT.yml index bb1318a226f0..d25abafa09e9 100644 --- a/modules/overviews/config/locales/crowdin/pt-PT.yml +++ b/modules/overviews/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: overviews: label: 'Visão geral' permission_manage_overview: 'Gerir página de visão geral' diff --git a/modules/overviews/config/routes.rb b/modules/overviews/config/routes.rb index ca6a4e5f15b6..755569db7e39 100644 --- a/modules/overviews/config/routes.rb +++ b/modules/overviews/config/routes.rb @@ -1,6 +1,11 @@ Rails.application.routes.draw do - get 'projects/:project_id', - to: "overviews/overviews#show", - as: :project_overview, - constraints: { format: :html, project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+") } + constraints(project_id: Regexp.new("(?!(#{Project::RESERVED_IDENTIFIERS.join('|')})$)(\\w|-)+"), format: :html) do + get 'projects/:project_id', + to: "overviews/overviews#show", + as: :project_overview + get 'projects/:project_id/project_custom_fields_sidebar', to: "overviews/overviews#project_custom_fields_sidebar", as: :project_custom_fields_sidebar + get 'projects/:project_id/project_custom_field_section_dialog/:section_id', to: "overviews/overviews#project_custom_field_section_dialog", + as: :project_custom_field_section_dialog + put 'projects/:project_id/update_project_custom_values/:section_id', to: "overviews/overviews#update_project_custom_values", as: :update_project_custom_values + end end diff --git a/modules/overviews/lib/overviews/engine.rb b/modules/overviews/lib/overviews/engine.rb index c764d05c2fc3..c738f08b13a9 100644 --- a/modules/overviews/lib/overviews/engine.rb +++ b/modules/overviews/lib/overviews/engine.rb @@ -46,16 +46,29 @@ class Engine < ::Rails::Engine Rails.application.reloader.to_prepare do OpenProject::AccessControl.permission(:view_project) .controller_actions - .push('overviews/overviews/show') + .push( + 'overviews/overviews/show', + 'overviews/overviews/project_custom_fields_sidebar' + ) + + OpenProject::AccessControl.permission(:edit_project) + .controller_actions + .push( + 'overviews/overviews/project_custom_field_section_dialog', + 'overviews/overviews/update_project_custom_values' + ) OpenProject::AccessControl.permission(:view_work_packages) - .controller_actions - .push('overviews/overviews/show') + .controller_actions + .push('overviews/overviews/show') OpenProject::AccessControl.map do |ac_map| ac_map.project_module nil do |map| map.permission :manage_overview, - { 'overviews/overviews': ['show'] }, + { 'overviews/overviews': + [ + 'show' + ] }, permissible_on: :project, require: :member end diff --git a/modules/recaptcha/config/locales/crowdin/ms.yml b/modules/recaptcha/config/locales/crowdin/ms.yml index 6b72f03df7bb..f633df7b0ed1 100644 --- a/modules/recaptcha/config/locales/crowdin/ms.yml +++ b/modules/recaptcha/config/locales/crowdin/ms.yml @@ -5,18 +5,18 @@ ms: description: "This module provides recaptcha checks during login." recaptcha: label_recaptcha: "reCAPTCHA" - button_please_wait: 'Please wait ...' - verify_account: "Verify your account" - error_captcha: "Your account could not be verified. Please contact an administrator." + button_please_wait: 'Sila tunggu ...' + verify_account: "Sahkan akaun anda" + error_captcha: "Akaun anda tidak dapat disahkan. Sila hubungi pengurus." settings: website_key: 'Website key' - response_limit: 'Response limit for HCaptcha' + response_limit: 'Had tindak balas untuk Hcaptcha' response_limit_text: 'The maximum number of characters to treat the HCaptcha response as valid.' website_key_text: 'Enter the website key you created on the reCAPTCHA admin console for this domain.' secret_key: 'Secret key' secret_key_text: 'Enter the secret key you created on the reCAPTCHA admin console.' - type: 'Use reCAPTCHA' - type_disabled: 'Disable reCAPTCHA' + type: 'Gunakan reCAPTCHA' + type_disabled: 'Nyahaktifkan reCAPTCHA' type_v2: 'reCAPTCHA v2' type_v3: 'reCAPTCHA v3' type_hcaptcha: 'HCaptcha' diff --git a/modules/recaptcha/config/locales/crowdin/pt-BR.yml b/modules/recaptcha/config/locales/crowdin/pt-BR.yml index 83fcf63093cb..16dffd5b6778 100644 --- a/modules/recaptcha/config/locales/crowdin/pt-BR.yml +++ b/modules/recaptcha/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_recaptcha: name: "ReCaptcha do OpenProject" description: "Este módulo fornece verificações recaptcha durante o início de sessão." diff --git a/modules/recaptcha/config/locales/crowdin/pt-PT.yml b/modules/recaptcha/config/locales/crowdin/pt-PT.yml index 9344c9bdf49e..7982b12d7a42 100644 --- a/modules/recaptcha/config/locales/crowdin/pt-PT.yml +++ b/modules/recaptcha/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_recaptcha: name: "ReCaptcha do OpenProject" description: "Este módulo fornece verificações recaptcha durante o início de sessão." diff --git a/modules/reporting/config/locales/crowdin/js-ms.yml b/modules/reporting/config/locales/crowdin/js-ms.yml index d11c0e7c5a91..cb941b73531b 100644 --- a/modules/reporting/config/locales/crowdin/js-ms.yml +++ b/modules/reporting/config/locales/crowdin/js-ms.yml @@ -22,5 +22,5 @@ ms: js: reporting_engine: - label_remove: "Delete" - label_response_error: "There was an error handling the query." + label_remove: "Hapus" + label_response_error: "Terdapat ralat semasa mengendalikan pertanyaan." diff --git a/modules/reporting/config/locales/crowdin/js-pt-BR.yml b/modules/reporting/config/locales/crowdin/js-pt-BR.yml index 30c0350863d9..c94fd8e3d4f0 100644 --- a/modules/reporting/config/locales/crowdin/js-pt-BR.yml +++ b/modules/reporting/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: reporting_engine: label_remove: "Excluir" diff --git a/modules/reporting/config/locales/crowdin/js-pt-PT.yml b/modules/reporting/config/locales/crowdin/js-pt-PT.yml index 58373c5bb89b..dbe5aa4f6c33 100644 --- a/modules/reporting/config/locales/crowdin/js-pt-PT.yml +++ b/modules/reporting/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: reporting_engine: label_remove: "Eliminar" diff --git a/modules/reporting/config/locales/crowdin/ms.yml b/modules/reporting/config/locales/crowdin/ms.yml index f4d731246204..72942a3c31f6 100644 --- a/modules/reporting/config/locales/crowdin/ms.yml +++ b/modules/reporting/config/locales/crowdin/ms.yml @@ -23,48 +23,48 @@ ms: plugin_openproject_reporting: name: "OpenProject Reporting" description: "This plugin allows creating custom cost reports with filtering and grouping created by the OpenProject Time and costs plugin." - button_save_as: "Save report as..." - comments: "Comment" - cost_reports_title: "Time and costs" - label_cost_report: "Cost report" - label_cost_report_plural: "Cost reports" - description_drill_down: "Show details" - description_filter_selection: "Selection" + button_save_as: "Simpan laporan sebagai..." + comments: "Komen" + cost_reports_title: "Masa dan kos" + label_cost_report: "Laporan kos" + label_cost_report_plural: "Laporan kos" + description_drill_down: "Papar butiran" + description_filter_selection: "Pilihan" description_multi_select: "Show multiselect" description_remove_filter: "Remove filter" information_restricted_depending_on_permission: "Depending on your permissions this page might contain restricted information." - label_click_to_edit: "Click to edit." - label_closed: "closed" - label_columns: "Columns" + label_click_to_edit: "Klik untuk edit." + label_closed: "ditutup" + label_columns: "Lajur" label_cost_entry_attributes: "Cost entry attributes" - label_days_ago: "during the last days" - label_entry: "Cost entry" + label_days_ago: "pada hari-hari terakhir" + label_entry: "Kemasukan kos" label_filter_text: "Filter text" label_filter_value: "Value" label_filters: "Filter" label_greater: ">" - label_is_not_project_with_subprojects: "is not (includes subprojects)" - label_is_project_with_subprojects: "is (includes subprojects)" - label_work_package_attributes: "Work package attributes" + label_is_not_project_with_subprojects: "adalah tidak (termasuk subprojek)" + label_is_project_with_subprojects: "adalah (termasuk subprojek)" + label_work_package_attributes: "Atribut pakej kerja" label_less: "<" - label_logged_by_reporting: "Logged by" - label_money: "Cash value" - label_month_reporting: "Month (Spent)" - label_new_report: "New cost report" - label_open: "open" - label_operator: "Operator" - label_private_report_plural: "Private cost reports" - label_progress_bar_explanation: "Generating report..." - label_public_report_plural: "Public cost reports" - label_really_delete_question: "Are you sure you want to delete this report?" - label_rows: "Rows" - label_saving: "Saving ..." - label_spent_on_reporting: "Date (Spent)" - label_sum: "Sum" - label_units: "Units" - label_week_reporting: "Week (Spent)" - label_year_reporting: "Year (Spent)" - label_count: "Count" + label_logged_by_reporting: "Dilog oleh" + label_money: "Nilai tunai" + label_month_reporting: "Bulan (Dihabiskan)" + label_new_report: "Laporan kos baru" + label_open: "buka" + label_operator: "Pengendali" + label_private_report_plural: "Laporan kos peribadi" + label_progress_bar_explanation: "Menjana laporan..." + label_public_report_plural: "Laporan kos umum" + label_really_delete_question: "Adakah anda pasti anda ingin menghapuskan laporan ini?" + label_rows: "Baris" + label_saving: "Menyimpan ..." + label_spent_on_reporting: "Tarikh (Dihabiskan)" + label_sum: "Jumlah" + label_units: "Unit" + label_week_reporting: "Minggu (Dihabiskan)" + label_year_reporting: "Tahun (Dihabiskan)" + label_count: "Hitung" label_filter: "Filter" label_filter_add: "Add Filter" label_filter_plural: "Filters" @@ -72,23 +72,23 @@ ms: label_group_by_add: "Add Group-by Attribute" label_inactive: "«inactive»" label_no: "Tidak" - label_none: "(no data)" - label_no_reports: "There are no cost reports yet." - label_report: "Report" + label_none: "(tiada data)" + label_no_reports: "Masih tiada laporan kos." + label_report: "Laporan" label_yes: "Ya" - load_query_question: "Report will have %{size} table cells and may take some time to render. Do you still want to try rendering it?" - permission_save_cost_reports: "Save public cost reports" - permission_save_private_cost_reports: "Save private cost reports" - project_module_reporting_module: "Cost reports" - text_costs_are_rounded_note: "Displayed values are rounded. All calculations are based on the non-rounded values." + load_query_question: "Laporan akan ada %{size} kotak jadual dan mungkin memerlukan sedikit masa untuk dihasilkan. Adakah anda masih mahu cuba untuk menghasilkan?" + permission_save_cost_reports: "Simpan laporan kos umum" + permission_save_private_cost_reports: "Simpan laporan kos peribadi" + project_module_reporting_module: "Laporan kos" + text_costs_are_rounded_note: "Nilai yang dipaparkan adalah dibundarkan. Semua kiraan adalah berdasarkan nilai yang tidak dibundarkan." toggle_multiselect: "activate/deactivate multiselect" - units: "Units" - validation_failure_date: "is not a valid date" - validation_failure_integer: "is not a valid integer" + units: "Unit" + validation_failure_date: "bukan tarikh yang sah" + validation_failure_integer: "bukan integer yang sah" export: cost_reports: title: "Your Cost Reports XLS export" reporting: group_by: - selected_columns: "Selected columns" - selected_rows: "Selected rows" + selected_columns: "Lajur terpilih" + selected_rows: "Baris terpilih" diff --git a/modules/reporting/config/locales/crowdin/pt-BR.yml b/modules/reporting/config/locales/crowdin/pt-BR.yml index 5f77c6611e28..fe16440fb12c 100644 --- a/modules/reporting/config/locales/crowdin/pt-BR.yml +++ b/modules/reporting/config/locales/crowdin/pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: plugin_openproject_reporting: name: "Relatórios do OpenProject" description: "Este plugin permite a criação de relatórios de custos personalizados com filtragem e agrupamento criados pelo plugin OpenProject Time e custos." diff --git a/modules/reporting/config/locales/crowdin/pt-PT.yml b/modules/reporting/config/locales/crowdin/pt-PT.yml index d5d5b525186c..d16aea0d54ef 100644 --- a/modules/reporting/config/locales/crowdin/pt-PT.yml +++ b/modules/reporting/config/locales/crowdin/pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: plugin_openproject_reporting: name: "Relatórios do OpenProject" description: "Este plugin permite criar relatórios de custos personalizados com filtragem e agrupamento criados pelo plugin OpenProject Time e custos." diff --git a/modules/reporting/lib/widget/filters/multi_choice.rb b/modules/reporting/lib/widget/filters/multi_choice.rb index fab1ed00456c..31a4080adbe0 100644 --- a/modules/reporting/lib/widget/filters/multi_choice.rb +++ b/modules/reporting/lib/widget/filters/multi_choice.rb @@ -29,19 +29,19 @@ class Widget::Filters::MultiChoice < Widget::Filters::Base def render filterName = filter_class.underscore_name - result = content_tag :div, id: "#{filterName}_arg_1", class: 'advanced-filters--filter-value' do + result = content_tag :div, id: "#{filterName}_arg_1", class: "advanced-filters--filter-value" do choices = filter_class.available_values.each_with_index.map do |(label, value), i| opts = { - type: 'radio', + type: "radio", name: "values[#{filterName}][]", id: "#{filterName}_radio_option_#{i}", value: } - opts[:checked] = 'checked' if filter.values == [value].flatten + opts[:checked] = "checked" if filter.values == [value].flatten radio_button = tag :input, opts content_tag :label, radio_button + translate(label), for: "#{filterName}_radio_option_#{i}", - 'data-filter-name': filter_class.underscore_name, + "data-filter-name": filter_class.underscore_name, class: "#{filterName}_radio_option filter_radio_option" end content_tag :div, choices.join.html_safe, diff --git a/modules/reporting/lib/widget/filters/multi_values.rb b/modules/reporting/lib/widget/filters/multi_values.rb index 1d11f22d658e..e4364e4016a5 100644 --- a/modules/reporting/lib/widget/filters/multi_values.rb +++ b/modules/reporting/lib/widget/filters/multi_values.rb @@ -28,37 +28,37 @@ class Widget::Filters::MultiValues < Widget::Filters::Base def render - write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: 'advanced-filters--filter-value') do - select_options = { 'data-remote-url': url_for(action: 'available_values'), - 'data-initially-selected': JSON::dump(Array(filter.values).flatten), - style: 'vertical-align: top;', # FIXME: Do CSS + write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: "advanced-filters--filter-value") do + select_options = { "data-remote-url": url_for(action: "available_values"), + "data-initially-selected": JSON::dump(Array(filter.values).flatten), + style: "vertical-align: top;", # FIXME: Do CSS name: "values[#{filter_class.underscore_name}][]", - 'data-loading': @options[:lazy] ? 'ajax' : '', + "data-loading": @options[:lazy] ? "ajax" : "", id: "#{filter_class.underscore_name}_arg_1_val", - class: 'form--select filter-value', - 'data-filter-name': filter_class.underscore_name } - box_content = ''.html_safe + class: "form--select filter-value", + "data-filter-name": filter_class.underscore_name } + box_content = "".html_safe label = label_tag "#{filter_class.underscore_name}_arg_1_val", - h(filter_class.label) + ' ' + I18n.t(:label_filter_value), - class: 'hidden-for-sighted' + h(filter_class.label) + " " + I18n.t(:label_filter_value), + class: "hidden-for-sighted" box = content_tag :select, select_options, id: "#{filter_class.underscore_name}_select_1" do render_widget Widget::Filters::Option, filter, to: box_content unless @options[:lazy] end plus = content_tag :a, - href: '#', - class: 'form-label filter_multi-select -transparent', - 'data-filter-name': filter_class.underscore_name, + href: "#", + class: "form-label filter_multi-select -transparent", + "data-filter-name": filter_class.underscore_name, title: I18n.t(:description_multi_select) do content_tag :span, - '', - class: 'icon-context icon-button icon-add icon4', + "", + class: "icon-context icon-button icon-add icon4", title: I18n.t(:label_enable_multi_select) do - content_tag :span, I18n.t(:label_enable_multi_select), class: 'hidden-for-sighted' + content_tag :span, I18n.t(:label_enable_multi_select), class: "hidden-for-sighted" end end - content_tag(:span, class: 'inline-label') do + content_tag(:span, class: "inline-label") do label + box + plus end end) diff --git a/modules/reporting/lib/widget/filters/project.rb b/modules/reporting/lib/widget/filters/project.rb index 496e2e84cbda..6b827053a49d 100644 --- a/modules/reporting/lib/widget/filters/project.rb +++ b/modules/reporting/lib/widget/filters/project.rb @@ -30,22 +30,22 @@ class Widget::Filters::Project < Widget::Filters::Base include AngularHelper def render - write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: 'advanced-filters--filter-value') do + write(content_tag(:div, id: "#{filter_class.underscore_name}_arg_1", class: "advanced-filters--filter-value") do label = html_label selected_values = map_filter_values - box = angular_component_tag 'opce-project-autocompleter', + box = angular_component_tag "opce-project-autocompleter", inputs: { filters: [], - InputName: "values[#{filter_class.underscore_name}][]", + InputName: "values[#{filter_class.underscore_name}]", multiple: true, model: selected_values.filter { |item| !item.nil? } }, id: "#{filter_class.underscore_name}_select_1", - class: 'filter-value' + class: "filter-value" - content_tag(:span, class: 'inline-label') do + content_tag(:span, class: "inline-label") do label + box end end) @@ -56,13 +56,13 @@ def render def html_label label_tag "#{filter_class.underscore_name}_arg_1_val", "#{h(filter_class.label)} #{I18n.t(:label_filter_value)}", - class: 'hidden-for-sighted' + class: "hidden-for-sighted" end def map_filter_values # In case the filter values are all written in a single string (e.g. ["12, 33"]) if filter.values.length === 1 && filter.values[0].instance_of?(String) - filter.values = filter.values[0].split(',') + filter.values = filter.values[0].split(",") end filter.values.each.map do |id| diff --git a/modules/storages/app/common/storages/peripherals/nextcloud.rb b/modules/storages/app/common/storages/peripherals/nextcloud.rb index 0d5a40cc18b1..d59f4d003d3c 100644 --- a/modules/storages/app/common/storages/peripherals/nextcloud.rb +++ b/modules/storages/app/common/storages/peripherals/nextcloud.rb @@ -32,6 +32,7 @@ module Storages module Peripherals Nextcloud = Dry::Container::Namespace.new('nextcloud') do namespace('queries') do + register(:auth_check, StorageInteraction::Nextcloud::AuthCheckQuery) register(:download_link, StorageInteraction::Nextcloud::DownloadLinkQuery) register(:file_ids, StorageInteraction::Nextcloud::FileIdsQuery) register(:file_info, StorageInteraction::Nextcloud::FileInfoQuery) diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb index a5442092376c..78d22ce075a8 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/configuration_interface.rb @@ -32,25 +32,14 @@ module Storages module Peripherals module OAuthConfigurations class ConfigurationInterface - def authorization_state_check(_) = raise ::Storages::Errors::SubclassResponsibility - def scope = raise ::Storages::Errors::SubclassResponsibility def basic_rack_oauth_client = raise ::Storages::Errors::SubclassResponsibility def to_httpx_oauth_config = raise ::Storages::Errors::SubclassResponsibility - private - - def authorization_check_wrapper - case yield - in { status: 200..299 } - :success - in { status: 401 | 403 } - :refresh_needed - else - :error - end + def authorization_uri(state: nil) + basic_rack_oauth_client.authorization_uri(scope:, state:) end end end diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb index 33570cad6b5d..eb6c47c6db8d 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/nextcloud_configuration.rb @@ -44,19 +44,6 @@ def initialize(storage) # rubocop:enable Lint/MissingSuper - def authorization_state_check(token) - authorization_check_wrapper do - OpenProject.httpx.get( - Util.join_uri_path(@uri, "/ocs/v1.php/cloud/user"), - headers: { - "Authorization" => "Bearer #{token}", - "OCS-APIRequest" => "true", - "Accept" => "application/json" - } - ) - end - end - def extract_origin_user_id(rack_access_token) rack_access_token.raw_attributes[:user_id] end diff --git a/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb b/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb index c66ea8c86a2e..9b9568d601f7 100644 --- a/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb +++ b/modules/storages/app/common/storages/peripherals/oauth_configurations/one_drive_configuration.rb @@ -46,15 +46,6 @@ def initialize(storage) # rubocop:enable Lint/MissingSuper - def authorization_state_check(access_token) - authorization_check_wrapper do - OpenProject.httpx.get( - Util.join_uri_path(@uri, "/v1.0/me"), - headers: { "Authorization" => "Bearer #{access_token}", "Accept" => "application/json" } - ) - end - end - def extract_origin_user_id(rack_access_token) OpenProject.httpx.get( Util.join_uri_path(@uri, "/v1.0/me"), diff --git a/modules/storages/app/common/storages/peripherals/one_drive.rb b/modules/storages/app/common/storages/peripherals/one_drive.rb index 399df0b46c39..5ec1cc2975a6 100644 --- a/modules/storages/app/common/storages/peripherals/one_drive.rb +++ b/modules/storages/app/common/storages/peripherals/one_drive.rb @@ -32,6 +32,7 @@ module Storages module Peripherals OneDrive = Dry::Container::Namespace.new('one_drive') do namespace('queries') do + register(:auth_check, StorageInteraction::OneDrive::AuthCheckQuery) register(:download_link, StorageInteraction::OneDrive::DownloadLinkQuery) register(:files, StorageInteraction::OneDrive::FilesQuery) register(:file_info, StorageInteraction::OneDrive::FileInfoQuery) diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb index 908ea6a6eec0..f4807cb61c22 100644 --- a/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/authentication.rb @@ -59,7 +59,7 @@ def self.authorization_state(storage:, user:) .resolve("#{storage.short_provider_type}.queries.auth_check") .call(storage:, auth_strategy:) .match( - on_success: ->(result) { result }, + on_success: ->(*) { :connected }, on_failure: ->(error) { error.code == :unauthorized ? :failed_authorization : :error } ) end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb new file mode 100644 index 000000000000..a2d33060540a --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/nextcloud/auth_check_query.rb @@ -0,0 +1,71 @@ +# 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 Storages + module Peripherals + module StorageInteraction + module Nextcloud + class AuthCheckQuery + Auth = ::Storages::Peripherals::StorageInteraction::Authentication + + using ServiceResultRefinements + + def self.call(storage:, auth_strategy:) + new(storage).call(auth_strategy:) + end + + def initialize(storage) + @storage = storage + end + + def call(auth_strategy:) + Auth[auth_strategy].call(storage: @storage, http_options: Util.ocs_api_request) do |http| + handle_response http.get(Util.join_uri_path(@storage.uri, "/ocs/v1.php/cloud/user")) + end + end + + private + + def handle_response(response) + case response + in { status: 200..299 } + ServiceResult.success + in { status: 401 } + ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) + else + data = ::Storages::StorageErrorData.new(source: self.class, payload: response) + ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) + end + end + end + end + end + end +end diff --git a/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb new file mode 100644 index 000000000000..dec0178eb7bc --- /dev/null +++ b/modules/storages/app/common/storages/peripherals/storage_interaction/one_drive/auth_check_query.rb @@ -0,0 +1,71 @@ +# 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 Storages + module Peripherals + module StorageInteraction + module OneDrive + class AuthCheckQuery + Auth = ::Storages::Peripherals::StorageInteraction::Authentication + + using ServiceResultRefinements + + def self.call(storage:, auth_strategy:) + new(storage).call(auth_strategy:) + end + + def initialize(storage) + @storage = storage + end + + def call(auth_strategy:) + Auth[auth_strategy].call(storage: @storage) do |http| + handle_response http.get(Util.join_uri_path(@storage.uri, "/v1.0/me")) + end + end + + private + + def handle_response(response) + case response + in { status: 200..299 } + ServiceResult.success + in { status: 401 } + ServiceResult.failure(result: :unauthorized, errors: ::Storages::StorageError.new(code: :unauthorized)) + else + data = ::Storages::StorageErrorData.new(source: self.class, payload: response) + ServiceResult.failure(result: :error, errors: ::Storages::StorageError.new(code: :error, data:)) + end + end + end + end + end + end +end diff --git a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb index 60dbbfe08419..9f97c9e249a6 100644 --- a/modules/storages/app/controllers/storages/admin/project_storages_controller.rb +++ b/modules/storages/app/controllers/storages/admin/project_storages_controller.rb @@ -60,6 +60,7 @@ def index # Show a HTML page with a form in order to create a new ProjectStorage # Called by: When a user clicks on the "+New" button in Project -> Settings -> File Storages + # rubocop:disable Metrics/AbcSize def new @available_storages = available_storages project_folder_mode = Storages::ProjectStorage.project_folder_modes.values.find do |mode| @@ -76,6 +77,8 @@ def new render template: "/storages/project_settings/new" end + # rubocop:enable Metrics/AbcSize + # Create a new ProjectStorage object. # Called by: The new page above with form-data from that form. def create @@ -95,12 +98,11 @@ def create def oauth_access_grant # rubocop:disable Metrics/AbcSize @project_storage = @object - connection_manager = OAuthClients::ConnectionManager.new( - user: current_user, - configuration: @project_storage.storage.oauth_configuration - ) + storage = @project_storage.storage + auth_state = ::Storages::Peripherals::StorageInteraction::Authentication + .authorization_state(storage:, user: current_user) - if connection_manager.authorization_state_connected? + if auth_state == :connected redirect_to(project_settings_project_storages_path) else nonce = SecureRandom.uuid @@ -110,7 +112,7 @@ def oauth_access_grant # rubocop:disable Metrics/AbcSize expires: 1.hour } session[:oauth_callback_flash_modal] = oauth_access_grant_nudge_modal(authorized: true) - redirect_to(connection_manager.get_authorization_uri(state: nonce)) + redirect_to(storage.oauth_configuration.authorization_uri(state: nonce)) end end diff --git a/modules/storages/app/helpers/storage_login_helper.rb b/modules/storages/app/helpers/storage_login_helper.rb index 7d0c5a0a785c..db5f1e27b7f0 100644 --- a/modules/storages/app/helpers/storage_login_helper.rb +++ b/modules/storages/app/helpers/storage_login_helper.rb @@ -32,14 +32,11 @@ module StorageLoginHelper def storage_login_input(storage) return {} if storage&.oauth_client.blank? - connection_manager = ::OAuthClients::ConnectionManager - .new(user: current_user, configuration: storage.oauth_configuration) - { storageId: storage.id, storageType: API::V3::Storages::STORAGE_TYPE_URN_MAP[storage.provider_type], authorizationLink: { - href: connection_manager.get_authorization_uri + href: storage.oauth_configuration.authorization_uri } } end diff --git a/modules/storages/config/locales/crowdin/cs.yml b/modules/storages/config/locales/crowdin/cs.yml index efe2768f6411..5a466dc9f299 100644 --- a/modules/storages/config/locales/crowdin/cs.yml +++ b/modules/storages/config/locales/crowdin/cs.yml @@ -63,7 +63,7 @@ cs: one_drive: Povolit OpenProject přístup k Azure datům pomocí OAuth pro připojení OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Dokončete nastavení správným přesměrováním URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? + confirm_replace_oauth_application: Tato akce obnoví aktuální OAuth přihlašovací údaje. Po potvrzení budete muset znovu zadat přihlašovací údaje u poskytovatele úložiště a všichni vzdálení uživatelé budou muset znovu autorizovat proti OpenProject Jste si jisti, že chcete pokračovat? confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? delete_warning: input_delete_confirmation: Zadejte název úložiště souboru %{file_storage} pro potvrzení odstranění. diff --git a/modules/storages/config/locales/crowdin/js-pt-BR.yml b/modules/storages/config/locales/crowdin/js-pt-BR.yml index 9a28a2b6299d..3b18fc043427 100644 --- a/modules/storages/config/locales/crowdin/js-pt-BR.yml +++ b/modules/storages/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: storages: link_files_in_storage: "Vincular arquivos em %{storageType}" diff --git a/modules/storages/config/locales/crowdin/js-pt-PT.yml b/modules/storages/config/locales/crowdin/js-pt-PT.yml index ca2915605e49..3afdd9bc6d84 100644 --- a/modules/storages/config/locales/crowdin/js-pt-PT.yml +++ b/modules/storages/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: storages: link_files_in_storage: "Vincular ficheiros em %{storageType}" diff --git a/modules/storages/config/locales/crowdin/ms.yml b/modules/storages/config/locales/crowdin/ms.yml index d59102a58689..28265d562136 100644 --- a/modules/storages/config/locales/crowdin/ms.yml +++ b/modules/storages/config/locales/crowdin/ms.yml @@ -6,101 +6,101 @@ ms: storages/storage: creator: Pencipta drive: Drive ID - host: Host + host: Hos name: Nama provider_type: Provider type tenant: Directory (tenant) ID errors: messages: - not_linked_to_project: is not linked to project. + not_linked_to_project: tidak terpaut dengan projek. models: storages/file_link: attributes: origin_id: - only_numeric_or_uuid: can only be numeric or uuid. + only_numeric_or_uuid: boleh menjadi dalam format numerik atau uuid. storages/project_storage: attributes: project_folder_mode: - mode_unavailable: is not available for this storage. + mode_unavailable: tidak tersedia untuk penyimpanan ini. storages/storage: attributes: host: authorization_header_missing: is not fully set up. The Nextcloud instance does not receive the "Authorization" header, which is necessary for a Bearer token based authorization of API requests. Please double check your HTTP server configuration. - cannot_be_connected_to: can not be connected to. - minimal_nextcloud_version_unmet: does not meet minimal version requirements (must be Nextcloud 23 or higher) - not_nextcloud_server: is not a Nextcloud server - op_application_not_installed: appears to not have the app "OpenProject integration" installed. Please install it first and then try again. + cannot_be_connected_to: tidak boleh disambungkan. + minimal_nextcloud_version_unmet: tidak memenuhi keperluan versi minimum (mesti Nextcloud 23 atau lebih tinggi) + not_nextcloud_server: adalah bukan server Nextcloud + op_application_not_installed: kelihatan tidak mempunyai aplikasi "Integrasi OpenProject" dipasang. Sila pasang terlebih dahulu dan cuba sekali lagi. password: - invalid_password: is not valid. - unknown_error: could not be validated. Please check your storage connection and try again. + invalid_password: tidak sah. + unknown_error: tidak dapat disahkan. Sila periksa sambungan storan anda dan cuba lagi. models: file_link: Fail - storages/storage: Storage + storages/storage: Storan api_v3: errors: - too_many_elements_created_at_once: Too many elements created at once. Expected %{max} at most, got %{actual}. + too_many_elements_created_at_once: Terlalu banyak unsur dicipta dalam satu masa. Dijangka %{max} paling banyak, mendapat %{actual}. permission_create_files: Cipta fail permission_delete_files: Padam fail - permission_manage_file_links: Manage file links - permission_manage_storages_in_project: Manage file storages in project + permission_manage_file_links: Urus pautan fail + permission_manage_storages_in_project: Urus fail storan dalam projek permission_read_files: Baca fail permission_share_files: Kongsi fail - permission_view_file_links: View file links + permission_view_file_links: Papar pautan fail permission_write_files: Write files - project_module_storages: File storages + project_module_storages: Storan fail storages: buttons: - complete_without_setup: Complete without it - done_complete_setup: Done, complete setup - done_continue: Done, continue - replace_oauth_application: Replace OpenProject OAuth - replace_oauth_client: Replace %{provider_type} OAuth + complete_without_setup: Lengkap tanpanya + done_complete_setup: Selesai, penyediaan lengkap + done_continue: Selesai, teruskan + replace_oauth_application: Ganti OpenProject OAuth + replace_oauth_client: Ganti %{provider_type} OAuth save_and_continue: Simpan dan teruskan - select_folder: Select folder + select_folder: Pilih folder configuration_checks: oauth_client_incomplete: - nextcloud: Allow OpenProject to access Nextcloud data using OAuth. - one_drive: Allow OpenProject to access Azure data using OAuth to connect OneDrive/Sharepoint. + nextcloud: Benarkan OpenProject untuk mengakses data Nextcloud menggunakan OAuth. + one_drive: Benarkan OpenProject untuk mengakses data Azure menggunakan OAuth untuk menyambung OneDrive/Sharepoint. redirect_uri_incomplete: - one_drive: Complete the setup with the correct URI redirection. + one_drive: Lengkapkan setup dengan pengalihan URI yang betul. confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? delete_warning: - input_delete_confirmation: Enter the file storage name %{file_storage} to confirm deletion. - irreversible_notice: Deleting a file storage is an irreversible action. - project_storage: 'Are you sure you want to delete %{file_storage} from this project? To confirm this action please introduce the storage name in the field below, this will:' - project_storage_delete_result_1: Remove all links from work packages of this project to files and folders of that storage. - project_storage_delete_result_2: In case this storage has an automatically managed project folder, this and its files will be deleted forever. - storage: 'Are you sure you want to delete %{file_storage}? To confirm this action please introduce the storage name in the field below, this will:' - storage_delete_result_1: Remove all storage setups for all projects using this storage. - storage_delete_result_2: Remove all links from work packages of all projects to files and folders of that storage. - storage_delete_result_3: In case this storage has automatically managed project folders, those and their contained files will be deleted forever. - error_invalid_provider_type: Please select a valid storage provider. + input_delete_confirmation: Masukkan nama fail penyimpanan %{file_storage} untuk mengesahkan pembuangan. + irreversible_notice: Mengapus fail penyimpanan adalah tindakan yang tidak dapat dipulihkan. + project_storage: 'Adakah anda pasti anda ingin menghapuskan %{file_storage} dari projek ini? Untuk mengesahkan tindakan ini sila perkenalkan nama penyimpanan di dalam medan di bawah, ini akan:' + project_storage_delete_result_1: Padam semua pautan dari pakej kerja projek ini ke fail dan folder penyimpanan tersebut. + project_storage_delete_result_2: Sekiranya penyimpanan ini mempunyai folder projek yang dikendalikan secara automatik, folder ini beserta failnya akan dipadamkan selamanya. + storage: 'Adakah anda pasti anda ingin menghapuskan %{file_storage}? Untuk mengesahkan tindakan ini sila perkenalkan nama penyimpanan di dalam medan di bawah, ini akan:' + storage_delete_result_1: Padam semua setup penyimpanan untuk semua projek yang menggunakan penyimpanan ini. + storage_delete_result_2: Padam semua pautan dari pakej kerja semua projek ke fail dan folder penyimpanan tersebut. + storage_delete_result_3: Sekiranya penyimpanan ini mempunyai folder projek yang dikendalikan secara automatik, folder tersebut beserta fail dalam kandungannya akan dipadamkan selamanya. + error_invalid_provider_type: Sila pilih pembekal penyimpanan yang sah. file_storage_view: - automatically_managed_folders: Automatically managed folders - general_information: General information + automatically_managed_folders: Folder yang dikendalikan secara automatik + general_information: Maklumat umum nextcloud_oauth: Nextcloud OAuth oauth_applications: OAuth applications one_drive_oauth: Azure OAuth openproject_oauth: OpenProject OAuth - project_folders: Project folders + project_folders: Folder projek redirect_uri: Redirect URI storage_provider: Storage provider health: - checked: Last checked %{datetime} - label_error: Error - label_healthy: Healthy - label_pending: Pending - since: since %{datetime} + checked: Terakhir diperiksa %{datetime} + label_error: Ralat + label_healthy: Sihat + label_pending: Dalam proses + since: sejak %{datetime} subtitle: Automatic managed project folders - title: Health status + title: Status kesihatan help_texts: - project_folder: The project folder is the default folder for file uploads for this project. Users can nevertheless still upload files to other locations. + project_folder: Folder projek adalah folder default untuk muat naik fail bagi projek ini. Pengguna masih boleh muat naik fail ke lokasi lain. instructions: all_available_storages_already_added: All available storages are already added to the project. automatic_folder: This will automatically create a root folder for this project and manage the access permissions for each project member. - copy_from: Copy this value from - empty_project_folder_validation: Selecting a folder is mandatory to proceed. + copy_from: Salin nilai ini dari + empty_project_folder_validation: Memilih folder adalah wajib untuk meneruskan. existing_manual_folder: You can designate an existing folder as the root folder for this project. The permissions are however not automatically managed, the administrator needs to manually ensure relevant users have access. The selected folder can be used by multiple projects. host: Please add the host address of your storage including the https://. It should not be longer than 255 characters. managed_project_folders_application_password_caption: 'Enable automatic managed folders by copying this value from: %{provider_type_link}.' @@ -128,15 +128,15 @@ ms: oauth_configuration: Copy these values from the desired application in the %{application_link_text}. provider_configuration: Please make sure you have administration privileges in the %{application_link_text} or contact your Microsoft administrator before doing the setup. In the portal, you also need to register an Azure application or use an existing one for authentication. tenant_id: Please copy the Directory (tenant) ID from the desired application and App registrations in the %{application_link_text}. - tenant_id_placeholder: Name or UUID + tenant_id_placeholder: Nama atau UUID setting_up_additional_storages: For setting up additional file storages, please visit setting_up_additional_storages_non_admin: Administrators can set up additional file storages in Administration / File Storages. setting_up_storages: For setting up file storages, please visit setting_up_storages_non_admin: Administrators can set up file storages in Administration / File Storages. type: 'Please make sure you have administration privileges in your Nextcloud instance and have the following application installed before doing the setup:' type_link_text: "“Integration OpenProject”" - label_active: Active - label_add_new_storage: Add new storage + label_active: Aktif + label_add_new_storage: Tambah storan baru label_automatic_folder: New folder with automatically managed permissions label_completed: Completed label_creation_time: Creation time @@ -151,28 +151,28 @@ ms: label_inactive: Inactive label_incomplete: Incomplete label_managed_project_folders: - application_password: Application password + application_password: Kata laluan aplikasi automatically_managed_folders: Automatically managed folders - label_name: Name + label_name: Nama label_new_file_storage: New %{provider} storage label_new_storage: New storage - label_no_selected_folder: No selected folder + label_no_selected_folder: Tiada folder yang dipilih label_no_specific_folder: No specific folder - label_oauth_client_id: OAuth Client ID - label_openproject_oauth_application_id: OpenProject OAuth Client ID + label_oauth_client_id: OAuth ID Pelanggan + label_openproject_oauth_application_id: OpenProject OAuth ID Pelanggan label_openproject_oauth_application_secret: OpenProject OAuth Client Secret - label_project_folder: Project folder - label_provider: Provider - label_redirect_uri: Redirect URI + label_project_folder: Folder projek + label_provider: Pembekal + label_redirect_uri: Ubah hala URI label_show_storage_redirect_uri: Show redirect URI label_status: Status label_storage: Storage label_uri: URI member_connection_status: - connected: Connected + connected: Disambungkan connected_no_permissions: User role has no storages permissions - not_connected: Not connected. The user should login to the storage via the following %{link}. - members_no_results: No members to display. + not_connected: Tidak bersambung. Pengguna perlu log masuk ke dalam storan melalui %{link} berikut. + members_no_results: Tiada ahli untuk dipaparkan. no_results: No storages set up yet. notice_successful_storage_connection: |- Storage connected successfully! Remember to activate the module and the specific storage in the project settings diff --git a/modules/storages/config/locales/crowdin/pt-BR.yml b/modules/storages/config/locales/crowdin/pt-BR.yml index a9758828e8ae..04de4e7e4ae8 100644 --- a/modules/storages/config/locales/crowdin/pt-BR.yml +++ b/modules/storages/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: activerecord: attributes: storages/file_link: diff --git a/modules/storages/config/locales/crowdin/pt-PT.yml b/modules/storages/config/locales/crowdin/pt-PT.yml index 585507858b36..02ad74fc47f9 100644 --- a/modules/storages/config/locales/crowdin/pt-PT.yml +++ b/modules/storages/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: activerecord: attributes: storages/file_link: @@ -53,8 +53,8 @@ pt: complete_without_setup: Concluir sem isso done_complete_setup: Concluído, terminar configuração done_continue: Concluído, continuar - replace_oauth_application: Replace OpenProject OAuth - replace_oauth_client: Replace %{provider_type} OAuth + replace_oauth_application: Substituir OpenProject OAuth + replace_oauth_client: Substituir OAuth %{provider_type} save_and_continue: Guardar e continuar select_folder: Selecionar pasta configuration_checks: @@ -63,8 +63,8 @@ pt: one_drive: Permitir que o OpenProject aceda aos dados do Azure utilizando o OAuth para ligar o OneDrive/Sharepoint. redirect_uri_incomplete: one_drive: Conclua a configuração com o redirecionamento correto da URI. - confirm_replace_oauth_application: This action will reset the current OAuth credentials. After confirming you will have to reenter the credentials at the storage provider and all remote users will have to authorize against OpenProject again. Are you sure you want to proceed? - confirm_replace_oauth_client: This action will reset the current OAuth credentials. After confirming you will have to enter new credentials from the storage provider and all users will have to authorize against %{provider_type} again. Are you sure you want to proceed? + confirm_replace_oauth_application: Esta ação irá repor as credenciais OAuth atuais. Após a confirmação, terá de voltar a introduzir as credenciais no fornecedor de armazenamento e todos os utilizadores remotos terão de autorizar novamente o OpenProject. Tem a certeza de que pretende continuar? + confirm_replace_oauth_client: Esta ação irá repor as credenciais OAuth atuais. Depois de confirmar, terá de introduzir novas credenciais do fornecedor de armazenamento e todos os utilizadores terão de autorizar novamente em %{provider_type}. Tem a certeza de que pretende continuar? delete_warning: input_delete_confirmation: Introduza o nome do ficheiro de armazenamento %{file_storage} para confirmar a eliminação. irreversible_notice: A eliminação de um ficheiro de armazenamento é uma ação irreversível. diff --git a/modules/storages/config/locales/crowdin/zh-TW.yml b/modules/storages/config/locales/crowdin/zh-TW.yml index 35a959d2f6ea..a9f90aaf36b5 100644 --- a/modules/storages/config/locales/crowdin/zh-TW.yml +++ b/modules/storages/config/locales/crowdin/zh-TW.yml @@ -197,7 +197,7 @@ zh-TW: page_titles: file_storages: delete: 刪除儲存區 - subtitle: Add an external file storage in order to upload, link and manage files in work packages. + subtitle: 新增外部檔案存儲空間,以便上傳、連結和管理工作項目中的文件。 managed_project_folders: one_drive_information: |- To enable automatically managed project folders in OneDrive/SharePoint, additional configuration is needed on @@ -231,8 +231,8 @@ zh-TW: name: OneDrive/SharePoint name_placeholder: 例如 OneDrive storage_list_blank_slate: - description: Add a storage to see them here. - heading: You don't have any storages yet. + description: 新增的儲存空間將在此顯示 + heading: 目前沒有任何儲存空間 upsale: description: |- Integrate your OneDrive/SharePoint as a file storage with OpenProject. Upload files and link them directly to diff --git a/modules/storages/lib/api/v3/storages/storage_representer.rb b/modules/storages/lib/api/v3/storages/storage_representer.rb index 405a5f163238..24c95803d08b 100644 --- a/modules/storages/lib/api/v3/storages/storage_representer.rb +++ b/modules/storages/lib/api/v3/storages/storage_representer.rb @@ -74,16 +74,6 @@ def link_without_resource(name, getter:, setter:) extend ClassMethods - def initialize(model, current_user:, embed_links: nil) - if model.oauth_configuration.present? - # Do not instantiate a connection manager, if representer is used for parsing - @connection_manager = - ::OAuthClients::ConnectionManager.new(user: current_user, configuration: model.oauth_configuration) - end - - super - end - property :id property :name @@ -120,10 +110,10 @@ def initialize(model, current_user:, embed_links: nil) getter: ->(*) { type = STORAGE_TYPE_URN_MAP[represented.provider_type] || represented.provider_type - { href: type, title: 'Nextcloud' } + { href: type, title: "Nextcloud" } }, setter: ->(fragment:, **) { - href = fragment['href'] + href = fragment["href"] break if href.blank? represented.provider_type = STORAGE_TYPE_MAP[href] || href @@ -132,9 +122,9 @@ def initialize(model, current_user:, embed_links: nil) link_without_resource :origin, getter: ->(*) { { href: represented.host } }, setter: ->(fragment:, **) { - break if fragment['href'].blank? + break if fragment["href"].blank? - represented.host = fragment['href'].gsub(/\/+$/, '') + represented.host = fragment["href"].gsub(/\/+$/, "") } links :prepareUpload do @@ -145,8 +135,8 @@ def initialize(model, current_user:, embed_links: nil) title: "Upload file", payload: { projectId: project_id, - fileName: '{fileName}', - parent: '{parent}' + fileName: "{fileName}", + parent: "{parent}" }, templated: true } @@ -158,7 +148,8 @@ def initialize(model, current_user:, embed_links: nil) end link :authorizationState do - urn = case authorization_state + auth_state = authorization_state + urn = case auth_state when :connected URN_CONNECTION_CONNECTED when :failed_authorization @@ -166,7 +157,7 @@ def initialize(model, current_user:, embed_links: nil) else URN_CONNECTION_ERROR end - title = I18n.t(:"oauth_client.urn_connection_status.#{authorization_state}") + title = I18n.t(:"oauth_client.urn_connection_status.#{auth_state}") { href: urn, title: } end @@ -174,7 +165,7 @@ def initialize(model, current_user:, embed_links: nil) link :authorize do next unless authorization_state == :failed_authorization - { href: @connection_manager.get_authorization_uri, title: 'Authorize' } + { href: represented.oauth_configuration.authorization_uri, title: "Authorize" } end link :projectStorages do @@ -213,7 +204,7 @@ def initialize(model, current_user:, embed_links: nil) } def _type - 'Storage' + "Storage" end private @@ -231,7 +222,8 @@ def storage_projects_ids(storage) end def authorization_state - @authorization_state ||= @connection_manager.authorization_state + ::Storages::Peripherals::StorageInteraction::Authentication.authorization_state(storage: represented, + user: current_user) end end end diff --git a/modules/storages/spec/factories/storage_factory.rb b/modules/storages/spec/factories/storage_factory.rb index 963a91a3e146..bcce415cdc61 100644 --- a/modules/storages/spec/factories/storage_factory.rb +++ b/modules/storages/spec/factories/storage_factory.rb @@ -29,7 +29,7 @@ #++ FactoryBot.define do - factory :storage, class: 'Storages::Storage' do + factory :storage, class: "Storages::Storage" do sequence(:name) { |n| "Storage #{n}" } creator factory: :user @@ -38,11 +38,11 @@ end trait :as_generic do - provider_type { 'Storages::Storage' } + provider_type { "Storages::Storage" } end trait :as_generic do - provider_type { 'Storages::Storage' } + provider_type { "Storages::Storage" } end trait :as_not_automatically_managed do @@ -50,28 +50,28 @@ end trait :as_healthy do - health_status { 'healthy' } + health_status { "healthy" } health_reason { nil } health_changed_at { Time.now.utc } health_checked_at { Time.now.utc } end trait :as_unhealthy do - health_status { 'unhealthy' } - health_reason { 'error_code | description' } + health_status { "unhealthy" } + health_reason { "error_code | description" } health_changed_at { Time.now.utc } health_checked_at { Time.now.utc } end trait :as_unhealthy_long_reason do - health_status { 'unhealthy' } - health_reason { 'unauthorized | Outbound request not authorized | #' } + health_status { "unhealthy" } + health_reason { "unauthorized | Outbound request not authorized | #" } health_changed_at { Time.now.utc } health_checked_at { Time.now.utc } end trait :as_pending do - health_status { 'pending' } + health_status { "pending" } health_reason { nil } health_changed_at { Time.now.utc } health_checked_at { Time.now.utc } @@ -80,14 +80,14 @@ factory :nextcloud_storage, parent: :storage, - class: '::Storages::NextcloudStorage' do + class: "::Storages::NextcloudStorage" do provider_type { Storages::Storage::PROVIDER_TYPE_NEXTCLOUD } sequence(:host) { |n| "https://host#{n}.example.com" } trait :as_automatically_managed do automatically_managed { true } - username { 'OpenProject' } - password { 'Password123' } + username { "OpenProject" } + password { "Password123" } end end @@ -105,8 +105,8 @@ oauth_client_token_user { association :user } end - name { 'Nextcloud Local' } - host { 'https://nextcloud.local' } + name { "Nextcloud Local" } + host { "https://nextcloud.local" } initialize_with do Storages::NextcloudStorage.create_or_find_by(attributes.except(:oauth_client, :oauth_application)) @@ -114,27 +114,27 @@ after(:create) do |storage, evaluator| create(:oauth_client, - client_id: ENV.fetch('NEXTCLOUD_LOCAL_OAUTH_CLIENT_ID', 'MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_ID'), - client_secret: ENV.fetch('NEXTCLOUD_LOCAL_OAUTH_CLIENT_SECRET', 'MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_SECRET'), + client_id: ENV.fetch("NEXTCLOUD_LOCAL_OAUTH_CLIENT_ID", "MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_ID"), + client_secret: ENV.fetch("NEXTCLOUD_LOCAL_OAUTH_CLIENT_SECRET", "MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_SECRET"), integration: storage) create(:oauth_application, - uid: ENV.fetch('NEXTCLOUD_LOCAL_OPENPROJECT_UID', 'MISSING_NEXTCLOUD_LOCAL_OPENPROJECT_UID'), - secret: ENV.fetch('NEXTCLOUD_LOCAL_OPENPROJECT_SECRET', 'MISSING_NEXTCLOUD_LOCAL_OPENPROJECT_SECRET'), - redirect_uri: ENV.fetch('NEXTCLOUD_LOCAL_OPENPROJECT_REDIRECT_URI', + uid: ENV.fetch("NEXTCLOUD_LOCAL_OPENPROJECT_UID", "MISSING_NEXTCLOUD_LOCAL_OPENPROJECT_UID"), + secret: ENV.fetch("NEXTCLOUD_LOCAL_OPENPROJECT_SECRET", "MISSING_NEXTCLOUD_LOCAL_OPENPROJECT_SECRET"), + redirect_uri: ENV.fetch("NEXTCLOUD_LOCAL_OPENPROJECT_REDIRECT_URI", "https://nextcloud.local/index.php/apps/integration_openproject/oauth-redirect"), - scopes: 'api_v3', + scopes: "api_v3", integration: storage) create(:oauth_client_token, oauth_client: storage.oauth_client, user: evaluator.oauth_client_token_user, - access_token: ENV.fetch('NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN', - 'MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN'), - refresh_token: ENV.fetch('NEXTCLOUD_LOCAL_OAUTH_CLIENT_REFRESH_TOKEN', - 'MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_REFRESH_TOKEN'), - token_type: 'bearer', - origin_user_id: 'admin') + access_token: ENV.fetch("NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN", + "MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_ACCESS_TOKEN"), + refresh_token: ENV.fetch("NEXTCLOUD_LOCAL_OAUTH_CLIENT_REFRESH_TOKEN", + "MISSING_NEXTCLOUD_LOCAL_OAUTH_CLIENT_REFRESH_TOKEN"), + token_type: "bearer", + origin_user_id: "admin") end end @@ -151,7 +151,7 @@ factory :one_drive_storage, parent: :storage, - class: '::Storages::OneDriveStorage' do + class: "::Storages::OneDriveStorage" do host { nil } tenant_id { SecureRandom.uuid } drive_id { SecureRandom.uuid } @@ -167,26 +167,26 @@ oauth_client_token_user { association :user } end - name { 'Sharepoint VCR drive' } - tenant_id { ENV.fetch('ONE_DRIVE_TEST_TENANT_ID', '4d44bf36-9b56-45c0-8807-bbf386dd047f') } - drive_id { ENV.fetch('ONE_DRIVE_TEST_DRIVE_ID', 'b!dmVLG22QlE2PSW0AqVB7UOhZ8n7tjkVGkgqLNnuw2OBb-brzKzZAR4DYT1k9KPXs') } + name { "Sharepoint VCR drive" } + tenant_id { ENV.fetch("ONE_DRIVE_TEST_TENANT_ID", "4d44bf36-9b56-45c0-8807-bbf386dd047f") } + drive_id { ENV.fetch("ONE_DRIVE_TEST_DRIVE_ID", "b!dmVLG22QlE2PSW0AqVB7UOhZ8n7tjkVGkgqLNnuw2OBb-brzKzZAR4DYT1k9KPXs") } after(:create) do |storage, evaluator| create(:oauth_client, - client_id: ENV.fetch('ONE_DRIVE_TEST_OAUTH_CLIENT_ID', 'MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_ID'), - client_secret: ENV.fetch('ONE_DRIVE_TEST_OAUTH_CLIENT_SECRET', - 'MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_SECRET'), + client_id: ENV.fetch("ONE_DRIVE_TEST_OAUTH_CLIENT_ID", "MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_ID"), + client_secret: ENV.fetch("ONE_DRIVE_TEST_OAUTH_CLIENT_SECRET", + "MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_SECRET"), integration: storage) create(:oauth_client_token, oauth_client: storage.oauth_client, user: evaluator.oauth_client_token_user, - access_token: ENV.fetch('ONE_DRIVE_TEST_OAUTH_CLIENT_ACCESS_TOKEN', - 'MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_ACCESS_TOKEN'), - refresh_token: ENV.fetch('ONE_DRIVE_TEST_OAUTH_CLIENT_REFRESH_TOKEN', - 'MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_REFRESH_TOKEN'), - token_type: 'bearer', - origin_user_id: '33db2c84-275d-46af-afb0-c26eb786b194') + access_token: ENV.fetch("ONE_DRIVE_TEST_OAUTH_CLIENT_ACCESS_TOKEN", + "MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_ACCESS_TOKEN"), + refresh_token: ENV.fetch("ONE_DRIVE_TEST_OAUTH_CLIENT_REFRESH_TOKEN", + "MISSING_ONE_DRIVE_TEST_OAUTH_CLIENT_REFRESH_TOKEN"), + token_type: "bearer", + origin_user_id: "33db2c84-275d-46af-afb0-c26eb786b194") end end end diff --git a/modules/storages/spec/features/create_file_links_spec.rb b/modules/storages/spec/features/create_file_links_spec.rb index e08534c17fe6..2b87c0286ed7 100644 --- a/modules/storages/spec/features/create_file_links_spec.rb +++ b/modules/storages/spec/features/create_file_links_spec.rb @@ -61,15 +61,11 @@ before do allow(Storages::FileLinkSyncService).to receive(:new).and_return(sync_service) - stub_request(:get, "#{storage.host}/ocs/v1.php/cloud/user") - .with( - headers: { - "Authorization" => "Bearer #{oauth_client_token.access_token}", - "Ocs-Apirequest" => "true", - "Accept" => "application/json" - } - ) - .to_return(status: 200, body: "", headers: {}) + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.success } + ) + stub_request(:propfind, "#{storage.host}/remote.php/dav/files/#{oauth_client_token.origin_user_id}/") .to_return(status: 207, body: root_xml_response, headers: {}) stub_request(:propfind, "#{storage.host}/remote.php/dav/files/#{oauth_client_token.origin_user_id}/Folder1") diff --git a/modules/storages/spec/features/show_file_links_spec.rb b/modules/storages/spec/features/show_file_links_spec.rb index 3cad19dd1f93..d96689525f09 100644 --- a/modules/storages/spec/features/show_file_links_spec.rb +++ b/modules/storages/spec/features/show_file_links_spec.rb @@ -37,25 +37,21 @@ let(:current_user) { create(:user, member_with_permissions: { project => permissions }) } let(:work_package) { create(:work_package, project:, description: "Initial description") } - let(:oauth_application) { create(:oauth_application) } - let(:storage) { create(:nextcloud_storage, name: "My storage", oauth_application:) } + let(:storage) { create(:nextcloud_storage_configured, name: "My storage") } let(:oauth_client) { create(:oauth_client, integration: storage) } let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user: current_user) } let(:project_storage) { create(:project_storage, project:, storage:) } let(:file_link) { create(:file_link, container: work_package, storage:, origin_id: "42", origin_name: "logo.png") } let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:sync_service) { instance_double(Storages::FileLinkSyncService) } + let(:authorization_state) { ServiceResult.success } before do - allow(OAuthClients::ConnectionManager) - .to receive(:new) - .and_return(connection_manager) - allow(connection_manager) - .to receive_messages(refresh_token: ServiceResult.success(result: oauth_client_token), - get_access_token: ServiceResult.success(result: oauth_client_token), - authorization_state: :connected) + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { authorization_state } + ) # Mock FileLinkSyncService as if Nextcloud would respond with origin_status=nil allow(Storages::FileLinkSyncService) @@ -98,10 +94,7 @@ end context "if user is not authorized in Nextcloud" do - before do - allow(connection_manager).to receive_messages(authorization_state: :failed_authorization, - get_authorization_uri: "https://example.com/authorize") - end + let(:authorization_state) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } it "must show storage information box with login button" do within_test_selector("op-tab-content--tab-section", text: "MY STORAGE", wait: 25) do @@ -113,9 +106,7 @@ end context "if an error occurred while authorizing to Nextcloud" do - before do - allow(connection_manager).to receive(:authorization_state).and_return(:error) - end + let(:authorization_state) { ServiceResult.failure(errors: Storages::StorageError.new(code: :error)) } it "must show storage information box" do within_test_selector("op-tab-content--tab-section", text: "MY STORAGE", wait: 25) do diff --git a/modules/storages/spec/features/storages/admin/edit_storage_spec.rb b/modules/storages/spec/features/storages/admin/edit_storage_spec.rb index bd50ffc47659..bbe471e671a9 100644 --- a/modules/storages/spec/features/storages/admin/edit_storage_spec.rb +++ b/modules/storages/spec/features/storages/admin/edit_storage_spec.rb @@ -28,43 +28,43 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" require_module_spec_helper -RSpec.describe 'Admin Edit File storage', +RSpec.describe "Admin Edit File storage", :js, :storage_server_helpers do - shared_let(:admin) { create(:admin, preferences: { time_zone: 'Etc/UTC' }) } + shared_let(:admin) { create(:admin, preferences: { time_zone: "Etc/UTC" }) } current_user { admin } - it 'renders a danger zone for deletion' do + it "renders a danger zone for deletion" do storage = create(:nextcloud_storage, name: "Foo Nextcloud") visit edit_admin_settings_storage_path(storage) - within_test_selector('page-header-actions') do - click_on 'Delete' + within_test_selector("page-header-actions") do + click_on "Delete" end expect(page).to have_text("DELETE FILE STORAGE") expect(page).to have_current_path("#{confirm_destroy_admin_settings_storage_path(storage)}?utf8=%E2%9C%93") - storage_delete_button = page.find_button('Delete', disabled: true) + storage_delete_button = page.find_button("Delete", disabled: true) - fill_in('delete_confirmation', with: 'Foo Nextcloud') + fill_in("delete_confirmation", with: "Foo Nextcloud") expect(storage_delete_button).not_to be_disabled storage_delete_button.click expect(page).to have_no_text("Foo Nextcloud") - expect(page).to have_text('Successful deletion.') + expect(page).to have_text("Successful deletion.") expect(page).to have_current_path(admin_settings_storages_path) end - context 'with Nextcloud Storage' do - let(:storage) { create(:nextcloud_storage, :as_automatically_managed, name: 'Cloud Storage') } + context "with Nextcloud Storage" do + let(:storage) { create(:nextcloud_storage, :as_automatically_managed, name: "Cloud Storage") } let(:oauth_application) { create(:oauth_application, integration: storage) } let(:oauth_client) { create(:oauth_client, integration: storage) } - let(:secret) { 'awesome_secret' } + let(:secret) { "awesome_secret" } before do allow(Doorkeeper::OAuth::Helpers::UniqueToken).to receive(:generate).and_return(secret) @@ -72,127 +72,127 @@ oauth_client end - it 'renders an edit view', :webmock do + it "renders an edit view", :webmock do visit edit_admin_settings_storage_path(storage) expect(page).to be_axe_clean - .within('#content') + .within("#content") # NB: Heading order is pending app wide update. See https://community.openproject.org/projects/openproject/work_packages/48513 - .skipping('heading-order') + .skipping("heading-order") - expect(page).to have_test_selector('storage-new-page-header--title', text: "Cloud Storage (Nextcloud)") + expect(page).to have_test_selector("storage-new-page-header--title", text: "Cloud Storage (Nextcloud)") - aggregate_failures 'Storage edit view' do + aggregate_failures "Storage edit view" do # General information - expect(page).to have_test_selector('storage-provider-label', text: 'Storage provider') - expect(page).to have_test_selector('label-host_name_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-description', text: "Nextcloud - #{storage.name} - #{storage.host}") + expect(page).to have_test_selector("storage-provider-label", text: "Storage provider") + expect(page).to have_test_selector("label-host_name_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-description", text: "Nextcloud - #{storage.name} - #{storage.host}") # OAuth application - expect(page).to have_test_selector('storage-openproject-oauth-label', text: 'OpenProject OAuth') - expect(page).to have_test_selector('label-openproject_oauth_application_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-openproject-oauth-application-description', + expect(page).to have_test_selector("storage-openproject-oauth-label", text: "OpenProject OAuth") + expect(page).to have_test_selector("label-openproject_oauth_application_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-openproject-oauth-application-description", text: "OAuth Client ID: #{oauth_application.uid}") # OAuth client - expect(page).to have_test_selector('storage-oauth-client-label', text: 'Nextcloud OAuth') - expect(page).to have_test_selector('label-storage_oauth_client_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-oauth-client-id-description', + expect(page).to have_test_selector("storage-oauth-client-label", text: "Nextcloud OAuth") + expect(page).to have_test_selector("label-storage_oauth_client_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-oauth-client-id-description", text: "OAuth Client ID: #{oauth_client.client_id}") # Automatically managed project folders - expect(page).to have_test_selector('storage-managed-project-folders-label', - text: 'Automatically managed folders') + expect(page).to have_test_selector("storage-managed-project-folders-label", + text: "Automatically managed folders") - expect(page).to have_test_selector('label-managed-project-folders-status', text: 'Active') - expect(page).to have_test_selector('storage-automatically-managed-project-folders-description', - text: 'Let OpenProject create folders per project automatically.') + expect(page).to have_test_selector("label-managed-project-folders-status", text: "Active") + expect(page).to have_test_selector("storage-automatically-managed-project-folders-description", + text: "Let OpenProject create folders per project automatically.") end - aggregate_failures 'General information' do + aggregate_failures "General information" do # Update a storage - happy path - find_test_selector('storage-edit-host-button').click - within_test_selector('storage-general-info-form') do - fill_in 'Name', with: 'My Nextcloud' - click_on 'Save and continue' + find_test_selector("storage-edit-host-button").click + within_test_selector("storage-general-info-form") do + fill_in "Name", with: "My Nextcloud" + click_on "Save and continue" end - expect(page).to have_test_selector('storage-new-page-header--title', text: 'My Nextcloud (Nextcloud)') - expect(page).to have_test_selector('storage-description', text: "Nextcloud - My Nextcloud - #{storage.host}") + expect(page).to have_test_selector("storage-new-page-header--title", text: "My Nextcloud (Nextcloud)") + expect(page).to have_test_selector("storage-description", text: "Nextcloud - My Nextcloud - #{storage.host}") # Update a storage - unhappy path - find_test_selector('storage-edit-host-button').click - within_test_selector('storage-general-info-form') do - fill_in 'Name', with: nil - fill_in 'Host', with: nil - click_on 'Save and continue' + find_test_selector("storage-edit-host-button").click + within_test_selector("storage-general-info-form") do + fill_in "Name", with: nil + fill_in "Host", with: nil + click_on "Save and continue" expect(page).to have_text("Name can't be blank.") expect(page).to have_text("Host is not a valid URL.") - click_on 'Cancel' + click_on "Cancel" end end - aggregate_failures 'OAuth application' do + aggregate_failures "OAuth application" do accept_confirm do - find_test_selector('storage-replace-openproject-oauth-application-button').click + find_test_selector("storage-replace-openproject-oauth-application-button").click end - within_test_selector('storage-openproject-oauth-application-form') do - warning_section = find_test_selector('storage-openproject_oauth_application_warning') - expect(warning_section).to have_text('The client secret value will not be accessible again after you close ' \ - 'this window. Please copy these values into the Nextcloud ' \ - 'OpenProject Integration settings.') - expect(warning_section).to have_link('Nextcloud OpenProject Integration settings', + within_test_selector("storage-openproject-oauth-application-form") do + warning_section = find_test_selector("storage-openproject_oauth_application_warning") + expect(warning_section).to have_text("The client secret value will not be accessible again after you close " \ + "this window. Please copy these values into the Nextcloud " \ + "OpenProject Integration settings.") + expect(warning_section).to have_link("Nextcloud OpenProject Integration settings", href: "#{storage.host}/settings/admin/openproject") - expect(page).to have_css('#openproject_oauth_application_uid', + expect(page).to have_css("#openproject_oauth_application_uid", value: storage.reload.oauth_application.uid) - expect(page).to have_css('#openproject_oauth_application_secret', + expect(page).to have_css("#openproject_oauth_application_secret", value: secret) - click_on 'Done, continue' + click_on "Done, continue" end end - aggregate_failures 'OAuth Client' do + aggregate_failures "OAuth Client" do accept_confirm do - find_test_selector('storage-edit-oauth-client-button').click + find_test_selector("storage-edit-oauth-client-button").click end - within_test_selector('storage-oauth-client-form') do + within_test_selector("storage-oauth-client-form") do # With null values, form should render inline errors - expect(page).to have_css('#oauth_client_client_id', value: '') - expect(page).to have_css('#oauth_client_client_secret', value: '') - click_on 'Save and continue' + expect(page).to have_css("#oauth_client_client_id", value: "") + expect(page).to have_css("#oauth_client_client_secret", value: "") + click_on "Save and continue" expect(page).to have_text("Client ID can't be blank.") expect(page).to have_text("Client secret can't be blank.") # Happy path - Submit valid values - fill_in 'Nextcloud OAuth Client ID', with: '1234567890' - fill_in 'Nextcloud OAuth Client Secret', with: '0987654321' - expect(find_test_selector('storage-oauth-client-submit-button')).not_to be_disabled - click_on 'Save and continue' + fill_in "Nextcloud OAuth Client ID", with: "1234567890" + fill_in "Nextcloud OAuth Client Secret", with: "0987654321" + expect(find_test_selector("storage-oauth-client-submit-button")).not_to be_disabled + click_on "Save and continue" end - expect(page).to have_test_selector('label-storage_oauth_client_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-oauth-client-id-description', text: "OAuth Client ID: 1234567890") + expect(page).to have_test_selector("label-storage_oauth_client_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-oauth-client-id-description", text: "OAuth Client ID: 1234567890") expect(OAuthClient.where(integration: storage).count).to eq(1) end - aggregate_failures 'Automatically managed project folders' do - find_test_selector('storage-edit-automatically-managed-project-folders-button').click + aggregate_failures "Automatically managed project folders" do + find_test_selector("storage-edit-automatically-managed-project-folders-button").click - within_test_selector('storage-automatically-managed-project-folders-form') do + within_test_selector("storage-automatically-managed-project-folders-form") do automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') - application_password_input = page.find_by_id('storages_nextcloud_storage_password') + application_password_input = page.find_by_id("storages_nextcloud_storage_password") expect(automatically_managed_switch).to be_checked expect(application_password_input.value).to be_empty # Clicking submit with application password empty should show an error - click_on('Done, complete setup') + click_on("Done, complete setup") expect(page).to have_text("Password can't be blank.") # Test the error path for an invalid storage password. @@ -201,9 +201,9 @@ response_code: 401) automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') expect(automatically_managed_switch).to be_checked - fill_in 'Application password', with: "1234567890" + fill_in "Application password", with: "1234567890" # Clicking submit with application password empty should show an error - click_on('Done, complete setup') + click_on("Done, complete setup") expect(page).to have_text("Password is not valid.") # Test the happy path for a valid storage password. @@ -212,135 +212,135 @@ mock_nextcloud_application_credentials_validation(storage.host, password: "1234567890") automatically_managed_switch = page.find('[name="storages_nextcloud_storage[automatic_management_enabled]"]') expect(automatically_managed_switch).to be_checked - fill_in 'Application password', with: "1234567890" - click_on('Done, complete setup') + fill_in "Application password", with: "1234567890" + click_on("Done, complete setup") end - expect(page).to have_test_selector('label-managed-project-folders-status', text: 'Active') + expect(page).to have_test_selector("label-managed-project-folders-status", text: "Active") end end - it 'renders health status information' do + it "renders health status information" do visit edit_admin_settings_storage_path(storage) - expect(page).to have_test_selector('storage-health-label-pending', text: 'Pending') + expect(page).to have_test_selector("storage-health-label-pending", text: "Pending") end end - context 'with Nextcloud Storage and not automatically managed' do - let(:storage) { create(:nextcloud_storage, :as_not_automatically_managed, name: 'Cloud Storage') } + context "with Nextcloud Storage and not automatically managed" do + let(:storage) { create(:nextcloud_storage, :as_not_automatically_managed, name: "Cloud Storage") } - it 'does not render health status information' do + it "does not render health status information" do visit edit_admin_settings_storage_path(storage) - expect(page).not_to have_test_selector('storage-health-label-pending', text: 'Pending') + expect(page).not_to have_test_selector("storage-health-label-pending", text: "Pending") end end - context 'with OneDrive/SharePoint Storage' do - let(:storage) { create(:one_drive_storage, :as_automatically_managed, name: 'Test Drive') } + context "with OneDrive/SharePoint Storage" do + let(:storage) { create(:one_drive_storage, :as_automatically_managed, name: "Test Drive") } let(:oauth_client) { create(:oauth_client, integration: storage) } before { oauth_client } - it 'renders an edit view', :webmock do + it "renders an edit view", :webmock do visit edit_admin_settings_storage_path(storage) expect(page).to be_axe_clean - .within('#content') - .skipping('heading-order') + .within("#content") + .skipping("heading-order") - expect(page).to have_test_selector('storage-new-page-header--title', text: 'Test Drive (OneDrive/SharePoint)') + expect(page).to have_test_selector("storage-new-page-header--title", text: "Test Drive (OneDrive/SharePoint)") - aggregate_failures 'Storage edit view' do + aggregate_failures "Storage edit view" do # General information - expect(page).to have_test_selector('storage-provider-label', text: 'Storage provider') - expect(page).to have_test_selector('label-host_name_configured-storage_tenant_drive_configured-status', - text: 'Completed') - expect(page).to have_test_selector('storage-description', text: 'OneDrive/SharePoint - Test Drive') + expect(page).to have_test_selector("storage-provider-label", text: "Storage provider") + expect(page).to have_test_selector("label-host_name_configured-storage_tenant_drive_configured-status", + text: "Completed") + expect(page).to have_test_selector("storage-description", text: "OneDrive/SharePoint - Test Drive") # OAuth client - expect(page).to have_test_selector('storage-oauth-client-label', text: 'Azure OAuth') - expect(page).to have_test_selector('label-storage_oauth_client_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-oauth-client-id-description', + expect(page).to have_test_selector("storage-oauth-client-label", text: "Azure OAuth") + expect(page).to have_test_selector("label-storage_oauth_client_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-oauth-client-id-description", text: "OAuth Client ID: #{oauth_client.client_id}") end - aggregate_failures 'General information' do + aggregate_failures "General information" do # Update a storage - happy path - find_test_selector('storage-edit-host-button').click - within_test_selector('storage-general-info-form') do - fill_in 'Name', with: 'My OneDrive' - click_on 'Save and continue' + find_test_selector("storage-edit-host-button").click + within_test_selector("storage-general-info-form") do + fill_in "Name", with: "My OneDrive" + click_on "Save and continue" end - expect(page).to have_test_selector('storage-new-page-header--title', text: 'My OneDrive (OneDrive/SharePoint)') - expect(page).to have_test_selector('storage-description', text: 'OneDrive/SharePoint - My OneDrive') + expect(page).to have_test_selector("storage-new-page-header--title", text: "My OneDrive (OneDrive/SharePoint)") + expect(page).to have_test_selector("storage-description", text: "OneDrive/SharePoint - My OneDrive") # Update a storage - unhappy path - find_test_selector('storage-edit-host-button').click - within_test_selector('storage-general-info-form') do - fill_in 'Name', with: nil - fill_in 'Drive ID', with: nil - click_on 'Save and continue' + find_test_selector("storage-edit-host-button").click + within_test_selector("storage-general-info-form") do + fill_in "Name", with: nil + fill_in "Drive ID", with: nil + click_on "Save and continue" expect(page).to have_text("Name can't be blank.") expect(page).to have_text("Drive ID can't be blank.") - click_on 'Cancel' + click_on "Cancel" end end - aggregate_failures 'OAuth Client' do + aggregate_failures "OAuth Client" do accept_confirm do - find_test_selector('storage-edit-oauth-client-button').click + find_test_selector("storage-edit-oauth-client-button").click end - within_test_selector('storage-oauth-client-form') do + within_test_selector("storage-oauth-client-form") do # With null values, form should render inline errors - expect(page).to have_css('#oauth_client_client_id', value: '') - expect(page).to have_css('#oauth_client_client_secret', value: '') - click_on 'Save and continue' + expect(page).to have_css("#oauth_client_client_id", value: "") + expect(page).to have_css("#oauth_client_client_secret", value: "") + click_on "Save and continue" expect(page).to have_text("Client ID can't be blank.") expect(page).to have_text("Client secret can't be blank.") # Happy path - Submit valid values - fill_in 'Azure OAuth Application (client) ID', with: '1234567890' - fill_in 'Azure OAuth Client Secret Value', with: '0987654321' - click_on 'Save and continue' + fill_in "Azure OAuth Application (client) ID", with: "1234567890" + fill_in "Azure OAuth Client Secret Value", with: "0987654321" + click_on "Save and continue" end - aggregate_failures 'Redirect URI' do - expect(page).to have_test_selector('storage-redirect-uri-label') - expect(page).to have_test_selector('storage-show-redirect-uri-button') - expect(page).not_to have_test_selector('storage-oauth-client-redirect-uri') + aggregate_failures "Redirect URI" do + expect(page).to have_test_selector("storage-redirect-uri-label") + expect(page).to have_test_selector("storage-show-redirect-uri-button") + expect(page).not_to have_test_selector("storage-oauth-client-redirect-uri") find('a[data-test-selector="storage-show-redirect-uri-button"]').click - expect(page).to have_test_selector('storage-oauth-client-redirect-uri') - expect(find_test_selector('storage-oauth-client-submit-button')).to be_disabled + expect(page).to have_test_selector("storage-oauth-client-redirect-uri") + expect(find_test_selector("storage-oauth-client-submit-button")).to be_disabled end - expect(page).to have_test_selector('label-storage_oauth_client_configured-status', text: 'Completed') - expect(page).to have_test_selector('storage-oauth-client-id-description', text: "OAuth Client ID: 1234567890") + expect(page).to have_test_selector("label-storage_oauth_client_configured-status", text: "Completed") + expect(page).to have_test_selector("storage-oauth-client-id-description", text: "OAuth Client ID: 1234567890") end end - it 'renders health status information' do + it "renders health status information" do visit edit_admin_settings_storage_path(storage) - expect(page).to have_test_selector('storage-health-label-pending', text: 'Pending') + expect(page).to have_test_selector("storage-health-label-pending", text: "Pending") end end - context 'with OneDrive/SharePoint Storage and not automatically managed' do - let(:storage) { create(:one_drive_storage, :as_not_automatically_managed, name: 'Cloud Storage') } + context "with OneDrive/SharePoint Storage and not automatically managed" do + let(:storage) { create(:one_drive_storage, :as_not_automatically_managed, name: "Cloud Storage") } - it 'does not render health status information' do + it "does not render health status information" do visit edit_admin_settings_storage_path(storage) - expect(page).not_to have_test_selector('storage-health-label-pending', text: 'Pending') + expect(page).not_to have_test_selector("storage-health-label-pending", text: "Pending") end end end diff --git a/modules/storages/spec/features/storages_menu_links_spec.rb b/modules/storages/spec/features/storages_menu_links_spec.rb index 63d4347af018..74eb1d6589e0 100644 --- a/modules/storages/spec/features/storages_menu_links_spec.rb +++ b/modules/storages/spec/features/storages_menu_links_spec.rb @@ -28,10 +28,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" require_module_spec_helper -RSpec.describe 'Storage links in project menu' do +RSpec.describe "Storage links in project menu" do include EnsureConnectionPathHelper shared_let(:project) { create(:project, enabled_module_names: %i[storages]) } @@ -41,11 +41,11 @@ end shared_let(:storage_configured_linked2) { create(:nextcloud_storage_configured, name: "Storage 2") } shared_let(:project_storage2) do - create(:project_storage, project_folder_mode: 'inactive', project:, storage: storage_configured_linked2) + create(:project_storage, project_folder_mode: "inactive", project:, storage: storage_configured_linked2) end shared_let(:storage_configured_linked3) { create(:nextcloud_storage_configured, name: "Storage 3") } shared_let(:project_storage3) do - create(:project_storage, project_folder_mode: 'manual', project:, storage: storage_configured_linked3) + create(:project_storage, project_folder_mode: "manual", project:, storage: storage_configured_linked3) end shared_let(:storage_configured_unlinked) { create(:nextcloud_storage_configured, name: "Storage 4") } shared_let(:storage_unconfigured_linked) { create(:nextcloud_storage, name: "Storage 5") } @@ -57,10 +57,10 @@ visit(project_path(project)) end - context 'if user is an admin but not a member of the project' do + context "if user is an admin but not a member of the project" do let(:user) { create(:admin) } - it 'has no links to enabled storage' do + it "has no links to enabled storage" do visit(project_path(id: project.id)) expect(page).to have_no_link(storage_configured_linked1.name) @@ -71,11 +71,11 @@ end end - context 'if user has permission to' do - context 'read_files and view_file_links' do + context "if user has permission" do + context "to read_files and view_file_links" do let(:permissions) { %i[view_file_links read_files] } - it 'has links to all enabled storages' do + it "has links to all enabled storages" do visit(project_path(id: project.id)) expect(page).to have_link(storage_configured_linked1.name, href: ensure_connection_path(project_storage1)) @@ -85,12 +85,12 @@ expect(page).to have_no_link(storage_unconfigured_linked.name) end - context 'when OP has been installed behind prefix' do - let(:prefix) { '/qwerty' } + context "when OP has been installed behind prefix" do + let(:prefix) { "/qwerty" } before { allow(OpenProject::Configuration).to receive(:rails_relative_url_root).and_return(prefix) } - it 'has all links prefixed' do + it "has all links prefixed" do visit(project_path(id: project.id)) expect(page).to have_link(storage_configured_linked1.name, href: ensure_connection_path(project_storage1)) @@ -102,10 +102,10 @@ end end - context 'read_files' do + context "to read_files" do let(:permissions) { %i[read_files] } - it 'has no links to enabled storages' do + it "has no links to enabled storages" do visit(project_path(id: project.id)) expect(page).to have_no_link(storage_configured_linked1.name) @@ -116,10 +116,10 @@ end end - context 'view_file_links' do + context "to view_file_links" do let(:permissions) { %i[view_file_links] } - it 'has links to enabled storages apart from automatically managed' do + it "has links to enabled storages apart from automatically managed" do visit(project_path(id: project.id)) expect(page).to have_no_link(storage_configured_linked1.name, href: ensure_connection_path(project_storage1)) @@ -131,10 +131,10 @@ end end - context 'if user has no permission to see storage links' do + context "if user has no permission to see storage links" do let(:permissions) { %i[] } - it 'has no links to enabled storages' do + it "has no links to enabled storages" do visit(project_path(id: project.id)) expect(page).to have_no_link(storage_configured_linked1.name) diff --git a/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb b/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb index 65ca341bbce7..2dbda82112f7 100644 --- a/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb +++ b/modules/storages/spec/lib/api/v3/storages/storages_representer_rendering_spec.rb @@ -36,16 +36,16 @@ let(:oauth_client_credentials) { build_stubbed(:oauth_client) } let(:storage) { build_stubbed(:nextcloud_storage, oauth_application:, oauth_client: oauth_client_credentials) } let(:user) { build_stubbed(:user) } + let(:auth_check_result) { ServiceResult.success } let(:representer) { described_class.new(storage, current_user: user, embed_links: true) } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } subject(:generated) { representer.to_json } before do - allow(OAuthClients::ConnectionManager) - .to receive(:new).and_return(connection_manager) - allow(connection_manager) - .to receive_messages(authorization_state: :connected, get_authorization_uri: 'https://example.com/authorize') + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { auth_check_result } + ) end describe '_links' do diff --git a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb index c1d5cf5b3ff4..0a1e11a04b56 100644 --- a/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/file_links/file_links_api_spec.rb @@ -65,7 +65,7 @@ def disable_module(project, modul) shared_let(:oauth_client_token) { create(:oauth_client_token, oauth_client:, user: current_user) } shared_let(:project_storage) { create(:project_storage, project:, storage:) } - let!(:another_project_storage) { nil } # create(:project_storage, project:, storage: another_storage) + let!(:another_project_storage) { nil } let(:file_link) do create(:file_link, creator: current_user, container: work_package, storage:) @@ -79,20 +79,11 @@ def disable_module(project, modul) create(:file_link, creator: current_user, container: work_package, storage: another_storage) end - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:sync_service) { instance_double(Storages::FileLinkSyncService) } subject(:response) { last_response } before do - # Mock ConnectionManager to behave as if connected - allow(OAuthClients::ConnectionManager) - .to receive(:new).and_return(connection_manager) - allow(connection_manager) - .to receive_messages(get_access_token: ServiceResult.success(result: oauth_client_token), authorization_state: :connected, - get_authorization_uri: 'https://example.com/authorize') - - # Mock FileLinkSyncService as if Nextcloud would respond positively allow(Storages::FileLinkSyncService) .to receive(:new).and_return(sync_service) allow(sync_service).to receive(:call) do |file_links| diff --git a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb index f1db282004e3..fccff3ac72d3 100644 --- a/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb +++ b/modules/storages/spec/requests/api/v3/storages/storages_api_spec.rb @@ -26,10 +26,10 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" require_module_spec_helper -RSpec.describe 'API v3 storages resource', :webmock, content_type: :json do +RSpec.describe "API v3 storages resource", :webmock, content_type: :json do include API::V3::Utilities::PathHelper include StorageServerHelpers include UserPermissionsHelper @@ -42,75 +42,72 @@ end shared_let(:user_without_project) { create(:user) } shared_let(:admin) { create(:admin) } - shared_let(:oauth_application) { create(:oauth_application) } - shared_let(:storage) { create(:nextcloud_storage, creator: user_with_permissions, oauth_application:) } + shared_let(:storage) { create(:nextcloud_storage_configured, creator: user_with_permissions) } shared_let(:project_storage) { create(:project_storage, project:, storage:) } - let(:authorize_url) { 'https://example.com/authorize' } - let(:connection_manager) { instance_double(OAuthClients::ConnectionManager) } let(:current_user) { user_with_permissions } + let(:auth_check_result) { ServiceResult.success } subject(:last_response) do get path end before do - allow(connection_manager).to receive_messages(get_authorization_uri: authorize_url, authorization_state: :connected) - allow(OAuthClients::ConnectionManager).to receive(:new).and_return(connection_manager) + Storages::Peripherals::Registry.stub("nextcloud.queries.auth_check", ->(_) { auth_check_result }) login_as current_user end - shared_examples_for 'successful storage response' do |as_admin: false| - include_examples 'successful response' + shared_examples_for "successful storage response" do |as_admin: false| + include_examples "successful response" - describe 'response body' do + describe "response body" do subject { last_response.body } - it { is_expected.to be_json_eql('Storage'.to_json).at_path('_type') } - it { is_expected.to be_json_eql(storage.id.to_json).at_path('id') } + it { is_expected.to be_json_eql("Storage".to_json).at_path("_type") } + it { is_expected.to be_json_eql(storage.id.to_json).at_path("id") } if as_admin - it { is_expected.to have_json_path('_embedded/oauthApplication') } + it { is_expected.to have_json_path("_embedded/oauthApplication") } else - it { is_expected.not_to have_json_path('_embedded/oauthApplication') } + it { is_expected.not_to have_json_path("_embedded/oauthApplication") } end end end - describe 'GET /api/v3/storages' do + describe "GET /api/v3/storages" do let(:path) { api_v3_paths.storages } let!(:another_storage) { create(:nextcloud_storage) } subject(:last_response) { get path } - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'gets the storage collection and returns it' do + describe "gets the storage collection and returns it" do subject { last_response.body } - it_behaves_like 'API V3 collection response', 2, 2, 'Storage', 'Collection' do + it_behaves_like "API V3 collection response", 2, 2, "Storage", "Collection" do let(:elements) { [another_storage, storage] } end end end - context 'as non-admin' do - describe 'gets the storage collection of storages linked to visible projects with correct permissions' do + context "as non-admin" do + describe "gets the storage collection of storages linked to visible projects with correct permissions" do subject { last_response.body } - it_behaves_like 'API V3 collection response', 1, 1, 'Storage', 'Collection' do + it_behaves_like "API V3 collection response", 1, 1, "Storage", "Collection" do let(:elements) { [storage] } end end end end - describe 'POST /api/v3/storages' do + describe "POST /api/v3/storages" do let(:path) { api_v3_paths.storages } - let(:host) { 'https://example.nextcloud.local' } - let(:name) { 'APIStorage' } - let(:type) { 'urn:openproject-org:api:v3:storages:Nextcloud' } + let(:host) { "https://example.nextcloud.local" } + let(:name) { "APIStorage" } + let(:type) { "urn:openproject-org:api:v3:storages:Nextcloud" } let(:params) do { name:, @@ -131,32 +128,32 @@ post path, params.to_json end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'creates a storage and returns it' do + describe "creates a storage and returns it" do subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to have_json_path('_embedded/oauthApplication/clientSecret') } + it { is_expected.to have_json_path("_embedded/oauthApplication/clientSecret") } end - context 'with applicationPassword' do + context "with applicationPassword" do let(:params) do super().merge( - applicationPassword: 'myappsecret' + applicationPassword: "myappsecret" ) end subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('true').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("true").at_path("hasApplicationPassword") } end - context 'with applicationPassword as null' do + context "with applicationPassword as null" do let(:params) do super().merge( applicationPassword: nil @@ -165,168 +162,164 @@ subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('false').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("false").at_path("hasApplicationPassword") } end - context 'if missing a mandatory value' do + context "if missing a mandatory value" do let(:params) do { - name: 'APIStorage', + name: "APIStorage", _links: { - type: { href: 'urn:openproject-org:api:v3:storages:Nextcloud' } + type: { href: "urn:openproject-org:api:v3:storages:Nextcloud" } } } end - it_behaves_like 'constraint violation' do + it_behaves_like "constraint violation" do let(:message) { "Host is not a valid URL" } end end end - context 'as non-admin' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + it_behaves_like "unauthorized access" end end - describe 'GET /api/v3/storages/:storage_id' do + describe "GET /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } - context 'if user belongs to a project using the given storage' do + context "if user belongs to a project using the given storage" do subject { last_response.body } - it_behaves_like 'successful storage response' + it_behaves_like "successful storage response" - context 'if user is missing permission view_file_links' do + context "if user is missing permission view_file_links" do before(:all) { remove_permissions(user_with_permissions, :view_file_links) } after(:all) { add_permissions(user_with_permissions, :view_file_links) } - it_behaves_like 'not found' + it_behaves_like "not found" end - context 'if no storage with that id exists' do + context "if no storage with that id exists" do let(:path) { api_v3_paths.storage(1337) } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'if user has :manage_storages_in_project permission in any project' do + context "if user has :manage_storages_in_project permission in any project" do let(:permissions) { %i(manage_storages_in_project) } - it_behaves_like 'successful storage response' + it_behaves_like "successful storage response" end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'successful storage response', as_admin: true + it_behaves_like "successful storage response", as_admin: true subject { last_response.body } - it { is_expected.not_to have_json_path('_embedded/oauthApplication/clientSecret') } + it { is_expected.not_to have_json_path("_embedded/oauthApplication/clientSecret") } end - context 'when OAuth authorization server is involved' do - shared_examples 'a storage authorization result' do |expected:, has_authorize_link:| + context "when OAuth authorization server is involved" do + shared_examples "a storage authorization result" do |expected:, has_authorize_link:| subject { last_response.body } - before do - allow(connection_manager).to receive(:authorization_state).and_return(authorization_state) - end - it "returns #{expected}" do - expect(subject).to be_json_eql(expected.to_json).at_path('_links/authorizationState/href') + expect(subject).to be_json_eql(expected.to_json).at_path("_links/authorizationState/href") end it "has #{has_authorize_link ? '' : 'no'} authorize link" do if has_authorize_link - expect(subject).to be_json_eql(authorize_url.to_json).at_path('_links/authorize/href') + expect(subject).to have_json_path("_links/authorize/href") else - expect(subject).not_to have_json_path('_links/authorize/href') + expect(subject).not_to have_json_path("_links/authorize/href") end end end - context 'when authorization succeeds and storage is connected' do - let(:authorization_state) { :connected } + context "when authorization succeeds and storage is connected" do + let(:auth_check_result) { ServiceResult.success } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_CONNECTED, has_authorize_link: false end - context 'when authorization fails' do - let(:authorization_state) { :failed_authorization } + context "when authorization fails" do + let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_AUTH_FAILED, has_authorize_link: true end - context 'when authorization fails with an error' do - let(:authorization_state) { :error } + context "when authorization fails with an error" do + let(:auth_check_result) { ServiceResult.failure(errors: Storages::StorageError.new(code: :error)) } - include_examples 'a storage authorization result', + include_examples "a storage authorization result", expected: API::V3::Storages::URN_CONNECTION_ERROR, has_authorize_link: false end end end - describe 'PATCH /api/v3/storages/:storage_id' do + describe "PATCH /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } - let(:name) { 'A new storage name' } + let(:name) { "A new storage name" } let(:params) { { name: } } subject(:last_response) do patch path, params.to_json end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'patches the storage and returns it' do + describe "patches the storage and returns it" do subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql(name.to_json).at_path('name') } + it { is_expected.to be_json_eql(name.to_json).at_path("name") } end - context 'with applicationPassword' do + context "with applicationPassword" do let(:params) do super().merge( - applicationPassword: 'myappsecret' + applicationPassword: "myappsecret" ) end before do - mock_nextcloud_application_credentials_validation(storage.host, password: 'myappsecret') + mock_nextcloud_application_credentials_validation(storage.host, password: "myappsecret") end subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql('true').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("true").at_path("hasApplicationPassword") } end - context 'with applicationPassword as null' do + context "with applicationPassword as null" do let(:params) do super().merge( applicationPassword: nil @@ -335,30 +328,30 @@ subject { last_response.body } - it_behaves_like 'successful response' + it_behaves_like "successful response" - it { is_expected.to be_json_eql('false').at_path('hasApplicationPassword') } + it { is_expected.to be_json_eql("false").at_path("hasApplicationPassword") } end - context 'with invalid applicationPassword' do + context "with invalid applicationPassword" do let(:params) do super().merge( - applicationPassword: '123' + applicationPassword: "123" ) end before do - mock_nextcloud_application_credentials_validation(storage.host, password: '123', response_code: 401) + mock_nextcloud_application_credentials_validation(storage.host, password: "123", response_code: 401) end subject { last_response.body } - it { is_expected.to be_json_eql('Password is not valid.'.to_json).at_path('message') } + it { is_expected.to be_json_eql("Password is not valid.".to_json).at_path("message") } end end end - describe 'DELETE /api/v3/storages/:storage_id' do + describe "DELETE /api/v3/storages/:storage_id" do let(:path) { api_v3_paths.storage(storage.id) } let(:delete_folder_url) do "#{storage.host}/remote.php/dav/files/#{storage.username}/#{project_storage.managed_project_folder_path.chop}/" @@ -375,72 +368,72 @@ deletion_request_stub end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'successful no content response' + it_behaves_like "successful no content response" end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" - it 'does not request project folder deletion' do + it "does not request project folder deletion" do expect(deletion_request_stub).not_to have_been_requested end end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" - it 'does not request project folder deletion' do + it "does not request project folder deletion" do expect(deletion_request_stub).not_to have_been_requested end end end end - describe 'GET /api/v3/storages/:storage_id/open' do + describe "GET /api/v3/storages/:storage_id/open" do let(:path) { api_v3_paths.storage_open(storage.id) } - let(:location) { 'https://deathstar.storage.org/files' } + let(:location) { "https://deathstar.storage.org/files" } before do Storages::Peripherals::Registry.stub( - 'nextcloud.queries.open_storage', + "nextcloud.queries.open_storage", ->(_) { ServiceResult.success(result: location) } ) end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - it_behaves_like 'redirect response' + it_behaves_like "redirect response" end - context 'if user belongs to a project using the given storage' do - it_behaves_like 'redirect response' + context "if user belongs to a project using the given storage" do + it_behaves_like "redirect response" - context 'if user is missing permission view_file_links' do + context "if user is missing permission view_file_links" do before(:all) { remove_permissions(user_with_permissions, :view_file_links) } after(:all) { add_permissions(user_with_permissions, :view_file_links) } - it_behaves_like 'not found' + it_behaves_like "not found" end - context 'if no storage with that id exists' do - let(:path) { api_v3_paths.storage_open('1337') } + context "if no storage with that id exists" do + let(:path) { api_v3_paths.storage_open("1337") } - it_behaves_like 'not found' + it_behaves_like "not found" end end end - describe 'POST /api/v3/storages/:storage_id/oauth_client_credentials' do + describe "POST /api/v3/storages/:storage_id/oauth_client_credentials" do let(:path) { api_v3_paths.storage_oauth_client_credentials(storage.id) } - let(:client_id) { 'myl1ttlecl13ntidii' } - let(:client_secret) { 'th3v3rys3cr3tcl13nts3cr3t' } + let(:client_id) { "myl1ttlecl13ntidii" } + let(:client_secret) { "th3v3rys3cr3tcl13nts3cr3t" } let(:params) do { clientId: client_id, @@ -452,41 +445,41 @@ post path, params.to_json end - context 'as non-admin' do - context 'if user belongs to a project using the given storage' do - it_behaves_like 'unauthorized access' + context "as non-admin" do + context "if user belongs to a project using the given storage" do + it_behaves_like "unauthorized access" end - context 'if user does not belong to a project using the given storage' do + context "if user does not belong to a project using the given storage" do let(:current_user) { user_without_project } - it_behaves_like 'not found' + it_behaves_like "not found" end end - context 'as admin' do + context "as admin" do let(:current_user) { admin } - describe 'creates new oauth client secrets' do + describe "creates new oauth client secrets" do subject { last_response.body } - it_behaves_like 'successful response', 201 + it_behaves_like "successful response", 201 - it { is_expected.to be_json_eql('OAuthClientCredentials'.to_json).at_path('_type') } - it { is_expected.to be_json_eql(client_id.to_json).at_path('clientId') } - it { is_expected.to be_json_eql(true.to_json).at_path('confidential') } - it { is_expected.not_to have_json_path('clientSecret') } + it { is_expected.to be_json_eql("OAuthClientCredentials".to_json).at_path("_type") } + it { is_expected.to be_json_eql(client_id.to_json).at_path("clientId") } + it { is_expected.to be_json_eql(true.to_json).at_path("confidential") } + it { is_expected.not_to have_json_path("clientSecret") } end - context 'if request body is invalid' do + context "if request body is invalid" do let(:params) do { - clientSecret: 'only_an_id' + clientSecret: "only_an_id" } end - it_behaves_like 'constraint violation' do - let(:message) { 'Client ID can\'t be blank.' } + it_behaves_like "constraint violation" do + let(:message) { "Client ID can't be blank." } end end end diff --git a/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb b/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb index 86647c0c511b..41f5f901ede4 100644 --- a/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb +++ b/modules/storages/spec/requests/storages/project_settings/oauth_access_grant_flow_spec.rb @@ -62,7 +62,7 @@ context "when user is logged in" do before { login_as(user) } - context 'when user is not "connected"' do + context "when user is not 'connected'" do let(:nonce) { "57a17c3f-b2ed-446e-9dd8-651ba3aec37d" } let(:redirect_uri) do CGI.escape("#{OpenProject::Application.root_url}/oauth_clients/#{storage.oauth_client.client_id}/callback") @@ -90,19 +90,11 @@ end end - context 'when user is "connected"' do + context "when user is 'connected'" do shared_let(:oauth_client_token) { create(:oauth_client_token, oauth_client: storage.oauth_client, user:) } before do - oauth_client_token - stub_request(:get, "#{storage.host}/ocs/v1.php/cloud/user") - .with( - headers: { - "Accept" => "application/json", - "Authorization" => "Bearer #{oauth_client_token.access_token}", - "Ocs-Apirequest" => "true" - } - ).to_return(status: 200, body: "", headers: {}) + Storages::Peripherals::Registry.stub("nextcloud.queries.auth_check", ->(_) { ServiceResult.success }) end it "redirects to destination_url" do diff --git a/modules/team_planner/config/locales/crowdin/js-pt-BR.yml b/modules/team_planner/config/locales/crowdin/js-pt-BR.yml index 0958589eee05..5185d47ab674 100644 --- a/modules/team_planner/config/locales/crowdin/js-pt-BR.yml +++ b/modules/team_planner/config/locales/crowdin/js-pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: js: team_planner: add_existing: 'Adicionar existente' diff --git a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml index c89ddb8bf191..8bbd64599656 100644 --- a/modules/team_planner/config/locales/crowdin/js-pt-PT.yml +++ b/modules/team_planner/config/locales/crowdin/js-pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: js: team_planner: add_existing: 'Adicionar existente' @@ -12,8 +12,8 @@ pt: remove_assignee: 'Remover responsável' two_weeks: '2 semanas' one_week: '1 semana' - four_weeks: '4-week' - eight_weeks: '8-week' + four_weeks: '4 semanas' + eight_weeks: '8 semanas' work_week: 'Semana de trabalho' today: 'Hoje' drag_here_to_remove: 'Arraste aqui para remover o responsável e as datas de início e término.' diff --git a/modules/team_planner/config/locales/crowdin/pt-BR.yml b/modules/team_planner/config/locales/crowdin/pt-BR.yml index c6f1146ba653..7206c4ba8dfe 100644 --- a/modules/team_planner/config/locales/crowdin/pt-BR.yml +++ b/modules/team_planner/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-BR: plugin_openproject_team_planner: name: "Planejador de equipes OpenProject" description: "Fornece visualizações do planejador de equipes." diff --git a/modules/team_planner/config/locales/crowdin/pt-PT.yml b/modules/team_planner/config/locales/crowdin/pt-PT.yml index e1a02a84bd72..4d1150b1f110 100644 --- a/modules/team_planner/config/locales/crowdin/pt-PT.yml +++ b/modules/team_planner/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here -pt: +pt-PT: plugin_openproject_team_planner: name: "Planeador de equipas OpenProject" description: "Fornece visualizações do planeador de equipas." diff --git a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/enter_backup_code.html.erb b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/enter_backup_code.html.erb index 4406a7d07c03..7398bbbeb4f4 100644 --- a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/enter_backup_code.html.erb +++ b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/enter_backup_code.html.erb @@ -11,8 +11,8 @@ <%= styled_text_field_tag 'backup_code', nil, required: true, autocomplete: 'off', size: 20, maxlength: 20, tabindex: 1, autofocus: true %>
- - + <% end %> -
diff --git a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb index d4298e181254..479c1d1d67db 100644 --- a/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb +++ b/modules/two_factor_authentication/app/views/two_factor_authentication/authentication/request_otp.html.erb @@ -43,20 +43,24 @@ <% end %> - - <% if resend_supported || has_other_devices || has_backup_codes %> -
- + <% end %>
-
+ <% end %> diff --git a/modules/two_factor_authentication/config/locales/crowdin/cs.yml b/modules/two_factor_authentication/config/locales/crowdin/cs.yml index 0c74cb1f2ef9..0d4321082095 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/cs.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/cs.yml @@ -114,11 +114,11 @@ cs: is_default_cannot_delete: "Zařízení je označeno jako výchozí a nemůže být odstraněno z důvodu aktivní bezpečnostní politiky. Před smazáním označte jiné zařízení jako výchozí." not_existing: "Žádné 2FA zařízení nebylo zaregistrováno pro váš účet." 2fa_from_input: Zadejte prosím kód z Vašeho %{device_name} pro ověření Vaší identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_webauthn: Uveďte prosím zařízení WebAuthn %{device_name}. Pokud je založeno na USB, nezapomeňte jej připojit a dotknout se jej. Poté klikněte na tlačítko Přihlásit se. webauthn: title: "WebAuthn" description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + further_steps: Po zvolení jména můžete kliknout na tlačítko Pokračovat. Váš prohlížeč vás vyzve, abyste prezentovali vaše WebAuthn zařízení. Až tak učiníte, jste zařízení zaregistrovali. totp: title: "Použít autentifikátor založený na aplikaci" provisioning_uri: "Poskytování URI" diff --git a/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml b/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml index 7d4f33fa04af..372544c095be 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/js-pt-BR.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-BR: js: two_factor_authentication: errors: diff --git a/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml index dbaec2f1af3a..d51a43480f8f 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/js-pt-PT.yml @@ -19,7 +19,7 @@ #Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. #See COPYRIGHT and LICENSE files for more details. #++ -pt: +pt-PT: js: two_factor_authentication: errors: diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml index c3f99ecebd79..684c982be7ec 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-BR.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-BR: plugin_openproject_two_factor_authentication: name: "Autenticação de dois fatores do OpenProject" description: >- diff --git a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml index ba45743d6f08..0d0e7ba01d25 100644 --- a/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml +++ b/modules/two_factor_authentication/config/locales/crowdin/pt-PT.yml @@ -1,5 +1,5 @@ #English strings go here for Rails i18n -pt: +pt-PT: plugin_openproject_two_factor_authentication: name: "Autenticação de dois fatores do OpenProject" description: >- @@ -76,7 +76,7 @@ pt: button_register_mobile_phone_for_user: "Registre o celular" text_2fa_enabled: "Em cada login, este utilizador será solicitado a inserir um token OTP do seu dispositivo padrão 2FA." text_2fa_disabled: "O utilizador não configurou um dispositivo 2FA através da sua página \"A minha conta\"" - only_sms_allowed: "Only SMS delivery can be set up for other users." + only_sms_allowed: "Apenas o envio de SMS pode ser configurado para outros utilizadores." upsale: title: "Autenticação de dois fatores" description: "Reforce a segurança da sua instância do OpenProject ao oferecer (ou reforçar) a autenticação de dois fatores a todos os membros do projeto." @@ -114,12 +114,12 @@ pt: failed_to_delete: "Falha ao excluir o dispositivo 2FA." is_default_cannot_delete: "O dispositivo está marcado como padrão e não pode ser excluído devido a uma política de segurança ativa. Marque outro dispositivo como padrão antes de excluir." not_existing: "Nenhum dispositivo 2FA foi registrado para sua conta." - 2fa_from_input: Please enter the code from your %{device_name} to verify your identity. - 2fa_from_webauthn: Please provide the WebAuthn device %{device_name}. If it is USB based make sure to plug it in and touch it. Then click the sign in button. + 2fa_from_input: Introduza o código do seu %{device_name} para verificar a sua identidade. + 2fa_from_webauthn: Indique o dispositivo WebAuthn %{device_name}. Se for baseado em USB, certifique-se de que o liga e toca nele. Em seguida, clique no botão de início de sessão. webauthn: title: "WebAuthn" - description: Use Web Authentication to register a FIDO2 device (like a YubiKey) or the secure enclave of your mobile device as a second factor. - further_steps: After you have chosen a name, you can click the Continue button. Your browser will prompt you to present your WebAuthn device. When you have done so, you are done registering the device. + description: Utilize a Autenticação Web para registar um dispositivo FIDO2 (como uma YubiKey) ou o enclave seguro do seu dispositivo móvel como um segundo fator. + further_steps: Depois de ter escolhido um nome, pode clicar no botão Continuar. O seu navegador irá pedir-lhe para apresentar o seu dispositivo WebAuthn. Quando o tiver feito, o registo do dispositivo está concluído. totp: title: "Use o seu autenticador baseado em aplicativos" provisioning_uri: "URI de provisionamento" diff --git a/modules/webhooks/config/locales/crowdin/pt-BR.yml b/modules/webhooks/config/locales/crowdin/pt-BR.yml index 71622189a0f3..0a3c5cba4673 100644 --- a/modules/webhooks/config/locales/crowdin/pt-BR.yml +++ b/modules/webhooks/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_webhooks: name: "Webhooks do OpenProject" description: "Fornece uma API de plug-in para dar suporte aos webhooks do OpenProject para uma melhor integração de terceiros." diff --git a/modules/webhooks/config/locales/crowdin/pt-PT.yml b/modules/webhooks/config/locales/crowdin/pt-PT.yml index 8bdf94e64dc9..1496aff7e003 100644 --- a/modules/webhooks/config/locales/crowdin/pt-PT.yml +++ b/modules/webhooks/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_webhooks: name: "Webhooks do OpenProject" description: "Fornece uma API de plug-in para suportar webhooks do OpenProject para uma melhor integração de terceiros." diff --git a/modules/xls_export/config/locales/crowdin/pt-BR.yml b/modules/xls_export/config/locales/crowdin/pt-BR.yml index c024d4d7edb0..31bcd6d4053b 100644 --- a/modules/xls_export/config/locales/crowdin/pt-BR.yml +++ b/modules/xls_export/config/locales/crowdin/pt-BR.yml @@ -1,4 +1,4 @@ -pt: +pt-BR: plugin_openproject_xls_export: name: "Exportação XLS do OpenProject" description: "Exportar listas de problemas como planilhas Excel (.xls)." diff --git a/modules/xls_export/config/locales/crowdin/pt-PT.yml b/modules/xls_export/config/locales/crowdin/pt-PT.yml index b2691b6ba103..440931f9de6d 100644 --- a/modules/xls_export/config/locales/crowdin/pt-PT.yml +++ b/modules/xls_export/config/locales/crowdin/pt-PT.yml @@ -1,4 +1,4 @@ -pt: +pt-PT: plugin_openproject_xls_export: name: "Exportação XLS do OpenProject" description: "Exportar listas de problemas como folhas de cálculo do Excel (.xls)." diff --git a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb index f62b664a81f5..21a2a620382d 100644 --- a/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb +++ b/modules/xls_export/spec/models/xls_export/project/exporter/xls_integration_spec.rb @@ -1,10 +1,10 @@ -require 'spec_helper' -require 'spreadsheet' -require 'models/projects/exporter/exportable_project_context' +require "spec_helper" +require "spreadsheet" +require "models/projects/exporter/exportable_project_context" RSpec.describe XlsExport::Project::Exporter::XLS do - include_context 'with a project with an arrangement of custom fields' - include_context 'with an instance of the described exporter' + include_context "with a project with an arrangement of custom fields" + include_context "with an instance of the described exporter" let(:sheet) do io = StringIO.new output @@ -14,82 +14,126 @@ let(:header) { sheet.rows.first.compact } # raw values have trailing nil let(:rows) { sheet.rows.drop(1) } - describe 'empty result' do + describe "empty result" do before do allow(instance).to receive(:records).and_return([]) end - it 'returns an empty XLS' do + it "returns an empty XLS" do expect(sheet.rows.count).to eq 1 expect(rows).to be_empty end end - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, - project.name, project.description, 'Off track', 'false'] + project.name, project.description, "Off track", "false"] end - context 'with project description containing html' do + context "with project description containing html" do before do - project.update(description: "This is an

html

description.") + project.update_column(:description, "This is an

html

description.") end - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, project.name, - "This is an html description.", 'Off track', 'false'] + "This is an html description.", "Off track", "false"] end end - context 'with status_explanation enabled' do - let(:query_columns) { %w[name description project_status status_explanation public ] } + context "with status_explanation enabled" do + let(:query_columns) { %w[name description project_status status_explanation public] } - it 'performs a successful export' do + it "performs a successful export" do expect(rows.count).to eq(1) expect(sheet.row(1)).to eq [project.id.to_s, project.identifier, project.name, project.description, - 'Off track', project.status_explanation, 'false'] + "Off track", project.status_explanation, "false"] end end - describe 'custom field columns selected' do - let(:query_columns) { %w[name description project_status public] + custom_fields.map(&:column_name) } - let(:current_user) { build_stubbed(:admin) } - - context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do - it 'renders all those columns' do - cf_names = custom_fields.map(&:name) - expect(header).to eq ['ID', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] - - custom_values = custom_fields.map do |cf| - case cf - when bool_cf - 'true' - when text_cf - project.typed_custom_value_for(cf) - else - project.formatted_custom_value_for(cf) + describe "custom field columns selected" do + let(:query_columns) { %w[name description project_status public] + global_project_custom_fields.map(&:column_name) } + + context "when ee enabled", with_ee: %i[custom_fields_in_projects_list] do + before do + project # re-evaluate project to ensure it is created within the desired user context + end + + context "with admin permission" do + let(:current_user) { build_stubbed(:admin) } + + it "renders all those columns" do + cf_names = global_project_custom_fields.map(&:name) + expect(header).to eq ["ID", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + + expect(header).to include not_used_string_cf.name + expect(header).to include hidden_cf.name + + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + nil + else + project.formatted_custom_value_for(cf) + end end + + expect(sheet.row(1)) + .to eq [project.id.to_s, project.identifier, project.name, project.description, "Off track", "false", + *custom_values] + + # The column for the project-level-disabled custom field is blank + expect(sheet.row(1)[header.index(not_used_string_cf.name)]).to be_nil end + end + + context "without admin permission" do + it "renders all visible globally available project custom fields in the header" do + cf_names = global_project_custom_fields.map(&:name) + + expect(header).to eq ["ID", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + + expect(header).to include not_used_string_cf.name + expect(header).not_to include hidden_cf.name + + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + nil + else + project.formatted_custom_value_for(cf) + end + end - expect(sheet.row(1)) - .to eq [project.id.to_s, project.identifier, project.name, project.description, 'Off track', 'false', *custom_values] + expect(sheet.row(1)) + .to eq [project.id.to_s, project.identifier, project.name, project.description, "Off track", "false", + *custom_values] + end end end - context 'when ee not enabled' do - it 'renders only the default columns' do + context "when ee not enabled" do + it "renders only the default columns" do expect(header).to eq %w[ID Identifier Name Description Status Public] end end end - context 'with no project visible' do + context "with no project visible" do let(:current_user) { User.anonymous } - it 'does not include the project' do + it "does not include the project" do expect(output).not_to include project.identifier expect(rows).to be_empty end diff --git a/script/i18n/fix_crowdin_pt_language_root_key b/script/i18n/fix_crowdin_pt_language_root_key new file mode 100755 index 000000000000..0237262f1427 --- /dev/null +++ b/script/i18n/fix_crowdin_pt_language_root_key @@ -0,0 +1,10 @@ +#!/usr/bin/env bash + +echo "Fixing language root key in pt-BR and pt-PT crowdin files to match the filename" +if [ "$(uname -s)" = "Darwin" ]; then + sed -i '' 's/^pt:/pt-BR:/' config/locales/crowdin/*pt-BR*.yml modules/*/config/locales/crowdin/*pt-BR*.yml + sed -i '' 's/^pt:/pt-PT:/' config/locales/crowdin/*pt-PT*.yml modules/*/config/locales/crowdin/*pt-PT*.yml +else + sed -i 's/^pt:/pt-BR:/' config/locales/crowdin/*pt-BR*.yml modules/*/config/locales/crowdin/*pt-BR*.yml + sed -i 's/^pt:/pt-PT:/' config/locales/crowdin/*pt-PT*.yml modules/*/config/locales/crowdin/*pt-PT*.yml +fi diff --git a/spec/contracts/custom_fields/create_contract_spec.rb b/spec/contracts/custom_fields/create_contract_spec.rb index 3717ea3058a0..5516be552501 100644 --- a/spec/contracts/custom_fields/create_contract_spec.rb +++ b/spec/contracts/custom_fields/create_contract_spec.rb @@ -32,7 +32,7 @@ RSpec.describe CustomFields::CreateContract do include_context "ModelContract shared context" - let(:cf) { build(:project_custom_field) } + let(:cf) { build_stubbed(:project_custom_field) } let(:contract) do described_class.new(cf, current_user, options: {}) end diff --git a/spec/contracts/custom_fields/update_contract_spec.rb b/spec/contracts/custom_fields/update_contract_spec.rb index 66764dfa422f..714019f4c40a 100644 --- a/spec/contracts/custom_fields/update_contract_spec.rb +++ b/spec/contracts/custom_fields/update_contract_spec.rb @@ -32,7 +32,7 @@ RSpec.describe CustomFields::UpdateContract do include_context "ModelContract shared context" - let(:cf) { build(:project_custom_field) } + let(:cf) { build_stubbed(:project_custom_field) } let(:contract) do described_class.new(cf, current_user) end diff --git a/spec/contracts/settings/working_days_params_contract_spec.rb b/spec/contracts/settings/working_days_params_contract_spec.rb index 31f2e46ec8bd..ddd60516a286 100644 --- a/spec/contracts/settings/working_days_params_contract_spec.rb +++ b/spec/contracts/settings/working_days_params_contract_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require 'contracts/shared/model_contract_shared_context' +require "spec_helper" +require "contracts/shared/model_contract_shared_context" RSpec.describe Settings::WorkingDaysParamsContract do - include_context 'ModelContract shared context' + include_context "ModelContract shared context" shared_let(:current_user) { create(:admin) } let(:setting) { Setting } let(:params) { { working_days: [1] } } @@ -38,15 +38,15 @@ described_class.new(setting, current_user, params:) end - it_behaves_like 'contract is valid for active admins and invalid for regular users' + it_behaves_like "contract is valid for active admins and invalid for regular users" - context 'without working days' do + context "without working days" do let(:params) { { working_days: [] } } - include_examples 'contract is invalid', base: :working_days_are_missing + include_examples "contract is invalid", base: :working_days_are_missing end - context 'with an ApplyWorkingDaysChangeJob already existing', + context "with an ApplyWorkingDaysChangeJob already existing", with_good_job: WorkPackages::ApplyWorkingDaysChangeJob do let(:params) { { working_days: [1, 2, 3] } } @@ -58,6 +58,6 @@ previous_working_days: [1, 2, 3, 4]) end - include_examples 'contract is invalid', base: :previous_working_day_changes_unprocessed + include_examples "contract is invalid", base: :previous_working_day_changes_unprocessed end end diff --git a/spec/factories/custom_field_factory.rb b/spec/factories/custom_field_factory.rb index 3ff573672e70..1c4616222b0e 100644 --- a/spec/factories/custom_field_factory.rb +++ b/spec/factories/custom_field_factory.rb @@ -33,24 +33,24 @@ # when using traits. They are not meant to be set externally. _format_name do [ - multi_value ? 'multi-' : nil, + multi_value ? "multi-" : nil, field_format ].compact.join end _type_name { instance.class.name.underscore.humanize(capitalize: false) } end sequence(:name) do |n, _e| - [_format_name, _type_name, n.to_s].join(' ').capitalize + [_format_name, _type_name, n.to_s].join(" ").capitalize end - regexp { '' } + regexp { "" } is_required { false } min_length { false } - default_value { '' } + default_value { "" } max_length { false } editable { true } - possible_values { '' } + possible_values { "" } visible { true } - field_format { 'bool' } + field_format { "bool" } after(:create) do # As the request store keeps track of the created custom fields @@ -58,29 +58,29 @@ end trait :boolean do - _format_name { 'boolean' } - field_format { 'bool' } + _format_name { "boolean" } + field_format { "bool" } end trait :string do - field_format { 'string' } + field_format { "string" } end trait :text do - field_format { 'text' } + field_format { "text" } end trait :integer do - _format_name { 'integer' } - field_format { 'int' } + _format_name { "integer" } + field_format { "int" } end trait :float do - field_format { 'float' } + field_format { "float" } end trait :date do - field_format { 'date' } + field_format { "date" } end trait :list do @@ -88,9 +88,9 @@ default_option { nil } default_options { nil } end - field_format { 'list' } + field_format { "list" } multi_value { false } - possible_values { ['A', 'B', 'C', 'D', 'E', 'F', 'G'] } + possible_values { ["A", "B", "C", "D", "E", "F", "G"] } # update custom options default value from the default_option transient # field for non-multiselect field @@ -140,24 +140,42 @@ end trait :version do - field_format { 'version' } + field_format { "version" } end trait :multi_version do - field_format { 'version' } + field_format { "version" } multi_value { true } end trait :user do - field_format { 'user' } + field_format { "user" } end trait :multi_user do - field_format { 'user' } + field_format { "user" } multi_value { true } end - factory :project_custom_field, class: 'ProjectCustomField' do + factory :project_custom_field, class: "ProjectCustomField" do + project_custom_field_section + + transient do + projects { [] } + end + + # enable the the custom_field for the given projects + after(:create) do |custom_field, evaluator| + projects = Array(evaluator.projects) + next if projects.blank? + + projects.each do |project| + unless project.project_custom_fields.include?(custom_field) + create(:project_custom_field_project_mapping, project:, project_custom_field: custom_field) + end + end + end + factory :boolean_project_custom_field, traits: [:boolean] factory :string_project_custom_field, traits: [:string] factory :text_project_custom_field, traits: [:text] @@ -169,12 +187,12 @@ factory :user_project_custom_field, traits: [:user] end - factory :user_custom_field, class: 'UserCustomField' + factory :user_custom_field, class: "UserCustomField" - factory :group_custom_field, class: 'GroupCustomField' + factory :group_custom_field, class: "GroupCustomField" - factory :wp_custom_field, class: 'WorkPackageCustomField' do - _type_name { 'WP custom field' } + factory :wp_custom_field, class: "WorkPackageCustomField" do + _type_name { "WP custom field" } is_filter { true } transient do @@ -198,16 +216,16 @@ factory :user_wp_custom_field, traits: [:user] end - factory :issue_custom_field, class: 'WorkPackageCustomField' do - _type_name { 'issue custom field' } + factory :issue_custom_field, class: "WorkPackageCustomField" do + _type_name { "issue custom field" } end - factory :time_entry_custom_field, class: 'TimeEntryCustomField' do - field_format { 'text' } + factory :time_entry_custom_field, class: "TimeEntryCustomField" do + field_format { "text" } end - factory :version_custom_field, class: 'VersionCustomField' do - field_format { 'text' } + factory :version_custom_field, class: "VersionCustomField" do + field_format { "text" } end end end diff --git a/spec/factories/custom_field_section.rb b/spec/factories/custom_field_section.rb new file mode 100644 index 000000000000..861b2f049d18 --- /dev/null +++ b/spec/factories/custom_field_section.rb @@ -0,0 +1,37 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :custom_field_section do + sequence(:name) { |n| "Section No. #{n}" } + created_at { Time.zone.now } + updated_at { Time.zone.now } + + factory :project_custom_field_section, class: 'ProjectCustomFieldSection' + end +end diff --git a/spec/factories/project_custom_field_project_mapping.rb b/spec/factories/project_custom_field_project_mapping.rb new file mode 100644 index 000000000000..4564b7668d2c --- /dev/null +++ b/spec/factories/project_custom_field_project_mapping.rb @@ -0,0 +1,34 @@ +#-- 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. +#++ + +FactoryBot.define do + factory :project_custom_field_project_mapping do + project_custom_field + project + end +end diff --git a/spec/features/admin/project_custom_fields/create_spec.rb b/spec/features/admin/project_custom_fields/create_spec.rb new file mode 100644 index 000000000000..d6229369c93f --- /dev/null +++ b/spec/features/admin/project_custom_fields/create_spec.rb @@ -0,0 +1,122 @@ +#-- 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_relative "shared_context" + +RSpec.describe "Create project custom fields", :js do + include_context "with seeded project custom fields" + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + visit new_admin_settings_project_custom_field_path + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + visit new_admin_settings_project_custom_field_path + end + + it "shows a correct breadcrumb menu" do + within "#breadcrumb" do + expect(page).to have_link("Administration") + expect(page).to have_link("Project attributes") + expect(page).to have_text("New attribute") + end + end + + it "allows to create a new project custom field with an associated section" do + # TODO: reuse specs for classic custom field form in order to test for other attribute settings + expect(page).to have_css(".PageHeader-title", text: "New attribute") + + fill_in("custom_field_name", with: "New custom field") + select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") + + click_on("Save") + + # redirects to the overview page + # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) + + expect(page).to have_text("New custom field") + + latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last + + expect(latest_custom_field.name).to eq("New custom field") + expect(latest_custom_field.project_custom_field_section).to eq(section_for_select_fields) + end + + it "allows to create a new project custom field with a prefilled section via url param" do + visit new_admin_settings_project_custom_field_path(custom_field_section_id: section_for_multi_select_fields.id) + + fill_in("custom_field_name", with: "New custom field") + + click_on("Save") + + # redirects to the overview page + # the tab parameter is set as the redirect originates from the former custom field controller but does not have an effect + expect(page).to have_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) + + latest_custom_field = ProjectCustomField.reorder(created_at: :asc).last + + expect(latest_custom_field.name).to eq("New custom field") + expect(latest_custom_field.project_custom_field_section).to eq(section_for_multi_select_fields) + end + + it "prevents creating a new project custom field with an empty name" do + click_on("Save") + + expect(page).to have_field "custom_field_name", validation_message: "Please fill out this field." + + # expect no redirect + expect(page).to have_no_current_path(admin_settings_project_custom_fields_path(tab: "ProjectCustomField")) + expect(page).to have_current_path(new_admin_settings_project_custom_field_path) + end + + context "without any existing sections" do + before do + ProjectCustomFieldSection.destroy_all + visit new_admin_settings_project_custom_field_path + end + + it "prevents creating a new project custom field with an empty name" do + fill_in("custom_field_name", with: "New custom field") + + click_on("Save") + + expect(page).to have_field "custom_field_custom_field_section_id", + validation_message: "Please select an item in the list." + end + end + end +end diff --git a/spec/features/admin/project_custom_fields/edit_spec.rb b/spec/features/admin/project_custom_fields/edit_spec.rb new file mode 100644 index 000000000000..3ff90c44b8d0 --- /dev/null +++ b/spec/features/admin/project_custom_fields/edit_spec.rb @@ -0,0 +1,95 @@ +#-- 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_relative "shared_context" + +RSpec.describe "Edit project custom fields", :js do + include_context "with seeded project custom fields" + + context "with insufficient permissions" do + it "is not accessible" do + login_as(non_admin) + visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) + + expect(page).to have_text("You are not authorized to access this page.") + end + end + + context "with sufficient permissions" do + before do + login_as(admin) + visit edit_admin_settings_project_custom_field_path(boolean_project_custom_field) + end + + it "shows a correct breadcrumb menu" do + within "#breadcrumb" do + expect(page).to have_link("Administration") + expect(page).to have_link("Project attributes") + expect(page).to have_text(boolean_project_custom_field.name) + end + end + + it "allows to change basic attributes and the section of the project custom field" do + # TODO: reuse specs for classic custom field form in order to test for other attribute manipulations + expect(page).to have_css(".PageHeader-title", text: boolean_project_custom_field.name) + + fill_in("custom_field_name", with: "Updated name") + select(section_for_select_fields.name, from: "custom_field_custom_field_section_id") + + click_on("Save") + + expect(page).to have_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: "Updated name") + + expect(boolean_project_custom_field.reload.name).to eq("Updated name") + expect(boolean_project_custom_field.reload.project_custom_field_section).to eq(section_for_select_fields) + + within "#breadcrumb" do + expect(page).to have_link("Administration") + expect(page).to have_link("Project attributes") + expect(page).to have_text("Updated name") + end + end + + it "prevents saving a project custom field with an empty name" do + original_name = boolean_project_custom_field.name + + fill_in("custom_field_name", with: "") + click_on("Save") + + expect(page).to have_field "custom_field_name", validation_message: "Please fill out this field." + + expect(page).to have_no_text("Successful update") + + expect(page).to have_css(".PageHeader-title", text: original_name) + expect(boolean_project_custom_field.reload.name).to eq(original_name) + end + end +end diff --git a/spec/features/admin/project_custom_fields/list_spec.rb b/spec/features/admin/project_custom_fields/list_spec.rb new file mode 100644 index 000000000000..b01692b6606a --- /dev/null +++ b/spec/features/admin/project_custom_fields/list_spec.rb @@ -0,0 +1,276 @@ +#-- 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_relative 'shared_context' + +RSpec.describe 'List project custom fields', :js do + include_context 'with seeded project custom fields' + + context 'with unsufficient permissions' do + it 'is not accessible' do + login_as(non_admin) + visit admin_settings_project_custom_fields_path + + expect(page).to have_text('You are not authorized to access this page.') + end + end + + context 'with sufficient permissions' do + before do + login_as(admin) + visit admin_settings_project_custom_fields_path + end + + it 'shows all sections in the correct order and allows reordering via menu or drag and drop' do + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_select_fields.name) + expect(containers[2].text).to include(section_for_multi_select_fields.name) + + perform_action_for_project_custom_field_section(section_for_multi_select_fields, "Move up") + + visit admin_settings_project_custom_fields_path + + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include(section_for_input_fields.name) + expect(containers[1].text).to include(section_for_multi_select_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + + # TODO: Add drag and drop test + end + + it 'allows to delete a section only if no project custom fields are assigned to it' do + within_project_custom_field_section_menu(section_for_multi_select_fields) do + expect(page).to have_css("button[aria-disabled='true']", text: 'Delete') + end + + multi_list_project_custom_field.destroy + multi_user_project_custom_field.destroy + multi_version_project_custom_field.destroy + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_menu(section_for_multi_select_fields) do + expect(page).to have_no_css("button[aria-disabled='true']", text: 'Delete') + expect(page).to have_button('Delete') + + accept_confirm do + click_on('Delete') + end + end + + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-container-#{section_for_multi_select_fields.id}']") + end + + it 'allows to edit a section' do + within_project_custom_field_section_menu(section_for_input_fields) do + click_on('Edit title') + end + + fill_in('project_custom_field_section_name', with: 'Updated section name') + + click_on('Save') + + expect(page).to have_no_text(section_for_input_fields.name) + expect(page).to have_text('Updated section name') + end + + it 'allows to create a new section' do + within '#settings-project-custom-fields-header-component' do + click_on('dialog-show-project-custom-field-section-dialog') + end + + fill_in('project_custom_field_section_name', with: 'New section name') + + click_on('Save') + + expect(page).to have_text('New section name') + + containers = page.all('.op-project-custom-field-section-container') + + expect(containers[0].text).to include('New section name') + expect(containers[1].text).to include(section_for_input_fields.name) + expect(containers[2].text).to include(section_for_select_fields.name) + expect(containers[3].text).to include(section_for_multi_select_fields.name) + end + + describe 'managing project custom fields' do + it 'shows all custom fields in the correct order within their section and allows reordering via menu or drag and drop' do + within_project_custom_field_section_container(section_for_input_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(boolean_project_custom_field.name) + expect(containers[1].text).to include(string_project_custom_field.name) + expect(containers[2].text).to include(integer_project_custom_field.name) + expect(containers[3].text).to include(float_project_custom_field.name) + expect(containers[4].text).to include(date_project_custom_field.name) + expect(containers[5].text).to include(text_project_custom_field.name) + end + + within_project_custom_field_section_container(section_for_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(list_project_custom_field.name) + expect(containers[1].text).to include(version_project_custom_field.name) + expect(containers[2].text).to include(user_project_custom_field.name) + end + + within_project_custom_field_section_container(section_for_multi_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(multi_list_project_custom_field.name) + expect(containers[1].text).to include(multi_version_project_custom_field.name) + expect(containers[2].text).to include(multi_user_project_custom_field.name) + end + + perform_action_for_project_custom_field(multi_user_project_custom_field, "Move up") + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_container(section_for_multi_select_fields) do + containers = page.all('.op-project-custom-field-container') + + expect(containers[0].text).to include(multi_list_project_custom_field.name) + expect(containers[1].text).to include(multi_user_project_custom_field.name) + expect(containers[2].text).to include(multi_version_project_custom_field.name) + end + + # TODO: Add drag and drop test + end + + it 'shows the number of projects using a custom field' do + within_project_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text('0 Projects') + end + + project = create(:project) + project.project_custom_fields << boolean_project_custom_field + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text('1 Project') + end + end + + it 'allows to delete a custom field' do + within_project_custom_field_menu(boolean_project_custom_field) do + accept_confirm do + click_on('Delete') + end + end + + expect(page).to have_no_css("[data-qa-selector='project-custom-field-container-#{boolean_project_custom_field.id}']") + end + + it 'redirects to the custom field edit page via menu item' do + within_project_custom_field_menu(boolean_project_custom_field) do + click_on('Edit') + end + + expect(page).to have_current_path(edit_admin_settings_project_custom_field_path(boolean_project_custom_field)) + end + + it 'redirects to the custom field edit page via click on the name of the custom field' do + within_project_custom_field_container(boolean_project_custom_field) do + click_on(boolean_project_custom_field.name) + end + + expect(page).to have_current_path(edit_admin_settings_project_custom_field_path(boolean_project_custom_field)) + end + + it 'redirects to the custom field new page via header menu button' do + page.find("[data-qa-selector='new-project-custom-field-button']").click + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path(type: 'ProjectCustomField')) + end + + it 'redirects to the custom field new page via button in empty sections' do + within_project_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_no_css("[data-qa-selector='new-project-custom-field-button']") + end + + multi_list_project_custom_field.destroy + multi_user_project_custom_field.destroy + multi_version_project_custom_field.destroy + + visit admin_settings_project_custom_fields_path + + within_project_custom_field_section_container(section_for_multi_select_fields) do + page.find("[data-qa-selector='new-project-custom-field-button']").click + end + + expect(page).to have_current_path(new_admin_settings_project_custom_field_path( + type: 'ProjectCustomField', + custom_field_section_id: section_for_multi_select_fields.id + )) + end + end + end + + # helper methods: + + def within_project_custom_field_section_container(section, &block) + within("[data-qa-selector='project-custom-field-section-container-#{section.id}']", &block) + end + + def within_project_custom_field_section_menu(section, &block) + within_project_custom_field_section_container(section) do + page.find("[data-qa-selector='project-custom-field-section-action-menu']").click + within('anchored-position', &block) + end + end + + def perform_action_for_project_custom_field_section(section, action) + within_project_custom_field_section_menu(section) do + click_on(action) + end + sleep 0.5 # quick fix: allow the brower to process the action + end + + def within_project_custom_field_container(custom_field, &block) + within("[data-qa-selector='project-custom-field-container-#{custom_field.id}']", &block) + end + + def within_project_custom_field_menu(section, &block) + within_project_custom_field_container(section) do + page.find("[data-qa-selector='project-custom-field-action-menu']").click + within('anchored-position', &block) + end + end + + def perform_action_for_project_custom_field(custom_field, action) + within_project_custom_field_menu(custom_field) do + click_on(action) + end + sleep 0.5 # quick fix: allow the brower to process the action + end +end diff --git a/spec/features/admin/project_custom_fields/shared_context.rb b/spec/features/admin/project_custom_fields/shared_context.rb new file mode 100644 index 000000000000..ee6b3faf084b --- /dev/null +++ b/spec/features/admin/project_custom_fields/shared_context.rb @@ -0,0 +1,128 @@ +#-- 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. +#++ + +RSpec.shared_context 'with seeded project custom fields' do + shared_let(:admin) { create(:admin) } + shared_let(:non_admin) { create(:user) } + + shared_let(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + shared_let(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + shared_let(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + create(:boolean_project_custom_field, name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + end + + let!(:string_project_custom_field) do + create(:string_project_custom_field, name: 'String field', + project_custom_field_section: section_for_input_fields) + end + + let!(:integer_project_custom_field) do + create(:integer_project_custom_field, name: 'Integer field', + project_custom_field_section: section_for_input_fields) + end + + let!(:float_project_custom_field) do + create(:float_project_custom_field, name: 'Float field', + project_custom_field_section: section_for_input_fields) + end + + let!(:date_project_custom_field) do + create(:date_project_custom_field, name: 'Date field', + project_custom_field_section: section_for_input_fields) + end + + let!(:text_project_custom_field) do + create(:text_project_custom_field, name: 'Text field', + project_custom_field_section: section_for_input_fields) + end + + let!(:list_project_custom_field) do + create(:list_project_custom_field, name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + end + + let!(:version_project_custom_field) do + create(:version_project_custom_field, name: 'Version field', + project_custom_field_section: section_for_select_fields) + end + + let!(:user_project_custom_field) do + create(:user_project_custom_field, name: 'User field', + project_custom_field_section: section_for_select_fields) + end + + let!(:multi_list_project_custom_field) do + create(:list_project_custom_field, name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + end + + let!(:multi_version_project_custom_field) do + create(:version_project_custom_field, name: 'Multi version field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + end + + let!(:multi_user_project_custom_field) do + create(:user_project_custom_field, name: 'Multi user field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + end + + let!(:input_fields) do + [ + boolean_project_custom_field, + string_project_custom_field, + integer_project_custom_field, + float_project_custom_field, + date_project_custom_field, + text_project_custom_field + ] + end + + let!(:select_fields) do + [ + list_project_custom_field, + version_project_custom_field, + user_project_custom_field + ] + end + + let!(:multi_select_fields) do + [ + multi_list_project_custom_field, + multi_version_project_custom_field, + multi_user_project_custom_field + ] + end +end diff --git a/spec/features/custom_fields/custom_fields_spec.rb b/spec/features/custom_fields/custom_fields_spec.rb index eafa5778e4a8..ee07c28bde2e 100644 --- a/spec/features/custom_fields/custom_fields_spec.rb +++ b/spec/features/custom_fields/custom_fields_spec.rb @@ -183,10 +183,6 @@ def expect_page_not_to_have_texts(*text) end end - describe 'projects' do - it_behaves_like "creating a new custom field", 'Projects' - end - describe 'work packages' do it_behaves_like "creating a new custom field", 'Work packages' end diff --git a/spec/features/members/invitation_spec.rb b/spec/features/members/invitation_spec.rb index 8a6ac7c17b24..09e8017613f8 100644 --- a/spec/features/members/invitation_spec.rb +++ b/spec/features/members/invitation_spec.rb @@ -26,11 +26,11 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'invite user via email', :js do - let!(:project) { create(:project, name: 'Project 1', identifier: 'project1', members: project_members) } - let!(:developer) { create(:project_role, name: 'Developer') } +RSpec.describe "invite user via email", :js, :with_cuprite do + let!(:project) { create(:project, name: "Project 1", identifier: "project1", members: project_members) } + let!(:developer) { create(:project_role, name: "Developer") } let(:project_members) { {} } let(:members_page) { Pages::Members.new project.identifier } @@ -41,7 +41,7 @@ member_with_permissions: { project => %i[view_members manage_members] }) end - context 'with a new user' do + context "with a new user" do before do @old_value = Capybara.raise_server_errors Capybara.raise_server_errors = false @@ -51,27 +51,27 @@ Capybara.raise_server_errors = @old_value end - it 'adds the invited user to the project' do + it "adds the invited user to the project" do members_page.visit! members_page.open_new_member! - members_page.search_and_select_principal! 'finkelstein@openproject.com', - 'Send invite to finkelstein@openproject.com' - members_page.select_role! 'Developer' - expect(members_page).to have_selected_new_principal('finkelstein@openproject.com') + members_page.search_and_select_principal! "finkelstein@openproject.com", + "Send invite to finkelstein@openproject.com" + members_page.select_role! "Developer" + expect(members_page).to have_selected_new_principal("finkelstein@openproject.com") - click_on 'Add' + click_on "Add" - expect(members_page).to have_added_user('finkelstein @openproject.com') + expect(members_page).to have_added_user("finkelstein @openproject.com") - expect(members_page).to have_user 'finkelstein @openproject.com' + expect(members_page).to have_user "finkelstein @openproject.com" # Should show the invited user on the default filter as well members_page.visit! - expect(members_page).to have_user 'finkelstein @openproject.com' + expect(members_page).to have_user "finkelstein @openproject.com" end - context 'with an instance with a user limit (regression)' do + context "with an instance with a user limit (regression)" do before do allow(OpenProject::Enterprise).to receive_messages( user_limit: 10, @@ -79,18 +79,18 @@ ) end - it 'shows a warning when the limit is reached' do + it "shows a warning when the limit is reached" do members_page.visit! members_page.open_new_member! - members_page.search_and_select_principal! 'finkelstein@openproject.com', - 'Send invite to finkelstein@openproject.com' + members_page.search_and_select_principal! "finkelstein@openproject.com", + "Send invite to finkelstein@openproject.com" expect(members_page).to have_no_text sanitize_string(I18n.t(:warning_user_limit_reached)), normalize_ws: true - members_page.search_and_select_principal! 'frankenstein@openproject.com', - 'Send invite to frankenstein@openproject.com' + members_page.search_and_select_principal! "frankenstein@openproject.com", + "Send invite to frankenstein@openproject.com" expect(members_page).to have_text sanitize_string(I18n.t(:warning_user_limit_reached)), normalize_ws: true @@ -98,44 +98,44 @@ end end - context 'with a registered user' do + context "with a registered user" do let!(:user) do - create(:user, mail: 'hugo@openproject.com', - login: 'hugo@openproject.com', - firstname: 'Hugo', - lastname: 'Hurried') + create(:user, mail: "hugo@openproject.com", + login: "hugo@openproject.com", + firstname: "Hugo", + lastname: "Hurried") end - it 'user lookup by email' do + it "user lookup by email" do members_page.visit! retry_block do members_page.open_new_member! - find_by_id('members_add_form') + find_by_id("members_add_form") end - members_page.search_and_select_principal! 'hugo@openproject.com', - 'Hugo Hurried' - members_page.select_role! 'Developer' + members_page.search_and_select_principal! "hugo@openproject.com", + "Hugo Hurried" + members_page.select_role! "Developer" - click_on 'Add' - expect(members_page).to have_added_user 'Hugo Hurried' + click_on "Add" + expect(members_page).to have_added_user "Hugo Hurried" end - context 'who is already a member' do + context "who is already a member" do let(:project_members) { { user => developer } } - shared_examples 'no user to invite is found' do - it 'no matches found' do + shared_examples "no user to invite is found" do + it "no matches found" do members_page.visit! members_page.open_new_member! - members_page.search_principal! 'hugo@openproject.com' + members_page.search_principal! "hugo@openproject.com" expect(members_page).to have_no_search_results end end - it_behaves_like 'no user to invite is found' + it_behaves_like "no user to invite is found" ## # This is a edge case where the email address to be invited is free in principle @@ -143,13 +143,13 @@ # cannot be used after all as the login is the same as the email address for new users # which means the login for this invited user will already by taken. # Accordingly it should not be offered to invite a user with that email address. - context 'with different email but email as login' do + context "with different email but email as login" do before do - user.mail = 'foo@bar.de' + user.mail = "foo@bar.de" user.save! end - it_behaves_like 'no user to invite is found' + it_behaves_like "no user to invite is found" end end end diff --git a/spec/features/menu_items/admin_menu_item_spec.rb b/spec/features/menu_items/admin_menu_item_spec.rb index 6067eb2bcb4a..d3e624593564 100644 --- a/spec/features/menu_items/admin_menu_item_spec.rb +++ b/spec/features/menu_items/admin_menu_item_spec.rb @@ -45,8 +45,8 @@ context 'without having any menu items hidden in configuration' do it 'must display all menu items' do expect(page).to have_test_selector('menu-blocks--container') - expect(page).to have_test_selector('menu-block', count: 21) - expect(page).to have_test_selector('op-menu--item-action', count: 22) # All plus 'overview' + expect(page).to have_test_selector('menu-block', count: 22) + expect(page).to have_test_selector('op-menu--item-action', count: 23) # All plus 'overview' end end @@ -56,10 +56,10 @@ } do it 'must not display the hidden menu items and blocks' do expect(page).to have_test_selector('menu-blocks--container') - expect(page).to have_test_selector('menu-block', count: 20) + expect(page).to have_test_selector('menu-block', count: 21) expect(page).not_to have_test_selector('menu-block', text: I18n.t(:label_color_plural)) - expect(page).to have_test_selector('op-menu--item-action', count: 21) # All plus 'overview' + expect(page).to have_test_selector('op-menu--item-action', count: 22) # All plus 'overview' expect(page).not_to have_test_selector('op-menu--item-action', text: I18n.t(:label_color_plural)) end end diff --git a/spec/features/projects/copy_spec.rb b/spec/features/projects/copy_spec.rb index 4fe5acde4afe..63bc9606f8bb 100644 --- a/spec/features/projects/copy_spec.rb +++ b/spec/features/projects/copy_spec.rb @@ -35,7 +35,12 @@ parent: parent_project, types: active_types, members: { user => role }, - custom_field_values: { project_custom_field.id => "some text cf" }).tap do |p| + # custom_fields which are not used below are not activated for this project + custom_field_values: { + project_custom_field.id => "some text cf", + optional_project_custom_field.id => "some optional text cf", + optional_project_custom_field_with_default.id => "foo" + }).tap do |p| p.work_package_custom_fields << wp_custom_field p.types.first.custom_fields << wp_custom_field @@ -53,8 +58,15 @@ roles: [role]) project end + let!(:project_custom_field_section) { create(:project_custom_field_section, name: "Section A") } let!(:project_custom_field) do - create(:text_project_custom_field, is_required: true) + create(:text_project_custom_field, is_required: true, project_custom_field_section:) + end + let!(:optional_project_custom_field) do + create(:text_project_custom_field, is_required: false, project_custom_field_section:) + end + let!(:optional_project_custom_field_with_default) do + create(:text_project_custom_field, is_required: false, default_value: "foo", project_custom_field_section:) end let!(:wp_custom_field) do create(:text_wp_custom_field) @@ -140,6 +152,203 @@ clear_performed_jobs end + context "with correct project custom field activations" do + before do + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find(".toolbar a", text: "Copy").click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in "Name", with: "Copied project" + end + + it "enables the same project custom fields as activated on the source project if untouched" do + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + end + + it "does not disable optional project custom fields if explicitly set to blank" do + # Expand advanced settings + click_on "Advanced settings" + + editor = Components::WysiwygEditor.new "[data-qa-field-name='customField#{optional_project_custom_field.id}']" + editor.clear + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + # the optional custom field is activated, but set to blank value + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq("") + end + + it "does enable project custom fields if set to blank in source project" do + project.update!(custom_field_values: { + optional_project_custom_field.id => "", + optional_project_custom_field_with_default.id => "" + }) + + # the optional custom fields are activated, but set to blank values + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find(".toolbar a", text: "Copy").click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in "Name", with: "Copied project" + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + # the optional custom fields are activated, but set to blank values as seen in source project + expect(copied_project.custom_value_for(optional_project_custom_field).typed_value).to eq("") + expect(copied_project.custom_value_for(optional_project_custom_field_with_default).typed_value).to eq("") + end + + context "with project custom fields with default values, which are disabled in source project" do + let!(:optional_boolean_project_custom_field_with_default) do + create(:boolean_project_custom_field, is_required: false, default_value: true, project_custom_field_section:) + end + let!(:optional_boolean_project_custom_field_with_no_default) do + create(:boolean_project_custom_field, is_required: false, project_custom_field_section:) + end + let!(:optional_string_project_custom_field_with_default) do + create(:string_project_custom_field, is_required: false, default_value: "bar", project_custom_field_section:) + end + + it "does not enable optional project custom fields with default values when not enabled in source project" do + # the optional boolean and string fields are not activated in the source project + expect(project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + # the optional boolean and string fields are not activated in the target project, although they have a default value + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id + ) + end + end + end + + context "with correct handling of invisible values" do + let!(:invisible_field) do + create(:string_project_custom_field, name: "Text for Admins only", + visible: false, + project_custom_field_section:, + projects: [project]) + end + let!(:source_custom_value_for_invisible_field) do + create(:custom_value, customized: project, custom_field: invisible_field, value: "foo") + end + + before do + original_settings_page = Pages::Projects::Settings.new(project) + original_settings_page.visit! + + find(".toolbar a", text: "Copy").click + + expect(page).to have_text "Copy project \"#{project.name}\"" + + fill_in "Name", with: "Copied project" + click_on "Advanced settings" + end + + context "with an admin user" do + let(:user) { create(:admin) } + + it "shows invisible fields in the form and allows their activation" do + expect(page).to have_content "Text for Admins only" + + # don't touch the source value + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id, + invisible_field.id + ) + + expect(copied_project.custom_value_for(invisible_field).typed_value).to eq("foo") + end + end + + context "with non-admin user" do + it "does not show invisible fields in the form and but still activates them" do + expect(page).to have_no_content "Text for Admins only" + + click_button "Save" + + wait_for_copy_to_finish + + copied_project = Project.find_by(name: "Copied project") + + expect(copied_project.project_custom_field_ids).to contain_exactly( + project_custom_field.id, + optional_project_custom_field.id, + optional_project_custom_field_with_default.id, + invisible_field.id + ) + end + end + end + it "copies projects and the associated objects" do original_settings_page = Pages::Projects::Settings.new(project) original_settings_page.visit! @@ -159,11 +368,7 @@ click_on "Save" - expect(page).to have_text "The job has been queued and will be processed shortly." - - # ensure all jobs are run especially emails which might be sent later on - while perform_enqueued_jobs > 0 - end + wait_for_copy_to_finish copied_project = Project.find_by(name: "Copied project") @@ -300,4 +505,12 @@ wp_table.expect_work_package_order *order end end + + def wait_for_copy_to_finish + expect(page).to have_text "The job has been queued and will be processed shortly." + + # ensure all jobs are run especially emails which might be sent later on + while perform_enqueued_jobs > 0 + end + end end diff --git a/spec/features/projects/create_spec.rb b/spec/features/projects/create_spec.rb index dea5ad1e81ff..286077a6c56f 100644 --- a/spec/features/projects/create_spec.rb +++ b/spec/features/projects/create_spec.rb @@ -32,6 +32,7 @@ :js, :with_cuprite do shared_let(:name_field) { FormFields::InputFormField.new :name } + shared_let(:project_custom_field_section) { create(:project_custom_field_section, name: 'Section A') } current_user { create(:admin) } @@ -74,7 +75,9 @@ end context 'with a multi-select custom field' do - let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true) } + let!(:list_custom_field) do + create(:list_project_custom_field, name: 'List CF', multi_value: true, project_custom_field_section:) + end let(:list_field) { FormFields::SelectFormField.new list_custom_field } it 'can create a project' do @@ -110,15 +113,17 @@ context 'with optional and required custom fields' do let!(:optional_custom_field) do - create(:custom_field, name: 'Optional Foo', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: 'Optional Foo', + field_format: 'string', + is_for_all: true, + project_custom_field_section:) end let!(:required_custom_field) do - create(:custom_field, name: 'Required Foo', - type: ProjectCustomField, - is_for_all: true, - is_required: true) + create(:project_custom_field, name: 'Required Foo', + field_format: 'string', + is_for_all: true, + is_required: true, + project_custom_field_section:) end it 'separates optional and required custom fields for new' do @@ -133,5 +138,256 @@ expect(page).to have_no_text 'Required Foo' end end + + context 'with correct validations' do + before do + visit new_project_path + end + + it 'requires the required custom field' do + click_on 'Save' + + expect(page).to have_content "Required Foo can't be blank" + expect(page).to have_no_content "Optional Foo can't be blank" + end + end + + context 'with correct custom field activation' do + let!(:unused_custom_field) do + create(:project_custom_field, name: 'Unused Foo', + field_format: 'string', + project_custom_field_section:) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'enables custom fields with provided values for this project' do + fill_in 'Optional Foo', with: 'Optional value' + fill_in 'Unused Foo', with: '' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # unused custom field should not be activated + expect(project.project_custom_field_ids).to contain_exactly( + optional_custom_field.id, required_custom_field.id + ) + end + + context 'with correct handling of default values' do + let!(:custom_field_with_default_value) do + create(:project_custom_field, name: 'Foo with default value', + field_format: 'string', + default_value: 'Default value', + project_custom_field_section:) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'enables custom fields with default values if not set to blank explicitly' do + # don't touch the default value + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the default value + expect(project.project_custom_field_ids).to contain_exactly( + custom_field_with_default_value.id, required_custom_field.id + ) + + expect(project.custom_value_for(custom_field_with_default_value).value).to eq('Default value') + end + + it 'does not enable custom fields with default values if set to blank explicitly' do + # native blank input does not work with this input, using support class here + field = FormFields::InputFormField.new(custom_field_with_default_value) + field.set_value("") + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should not be activated + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id + ) + end + + it 'does enable custom fields with default values if overwritten with a new value' do + fill_in 'Foo with default value', with: 'foo' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + # custom_field_with_default_value should be activated and contain the overwritten value + expect(project.project_custom_field_ids).to contain_exactly( + custom_field_with_default_value.id, required_custom_field.id + ) + + expect(project.custom_value_for(custom_field_with_default_value).value).to eq('foo') + end + end + + context 'with correct handling of optional boolean values' do + let!(:custom_boolean_field_default_true) do + create(:project_custom_field, name: 'Boolean with default true', + field_format: 'bool', + default_value: true, + project_custom_field_section:) + end + + let!(:custom_boolean_field_default_false) do + create(:project_custom_field, name: 'Boolean with default false', + field_format: 'bool', + default_value: false, + project_custom_field_section:) + end + + let!(:custom_boolean_field_with_no_default) do + create(:project_custom_field, name: 'Boolean with no default', + field_format: 'bool', + project_custom_field_section:) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + it 'only enables boolean custom fields with default values if untouched' do + # do not touch any of the boolean fields + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id + ) + + expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_truthy + expect(project.custom_value_for(custom_boolean_field_default_false).typed_value).to be_falsy + end + + it 'enables boolean custom fields without default values if set to true explicitly' do + check 'Boolean with no default' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id, + custom_boolean_field_with_no_default.id + ) + + expect(project.custom_value_for(custom_boolean_field_with_no_default).typed_value).to be_truthy + end + + it 'enables boolean custom fields with default values if set to false explicitly' do + uncheck 'Boolean with default true' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, + custom_boolean_field_default_true.id, + custom_boolean_field_default_false.id + ) + + expect(project.custom_value_for(custom_boolean_field_default_true).typed_value).to be_falsy + end + end + end + + context 'with correct handling of invisible values' do + let!(:invisible_field) do + create(:string_project_custom_field, name: 'Text for Admins only', + visible: false, + project_custom_field_section:) + end + + before do + visit new_project_path + fill_in 'Name', with: 'Foo bar' + fill_in 'Required Foo', with: 'Required value' + + click_on 'Advanced settings' + end + + context 'with an admin user' do + it 'shows invisible fields in the form and allows their activation' do + expect(page).to have_content 'Text for Admins only' + + fill_in 'Text for Admins only', with: 'foo' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id, invisible_field.id + ) + + expect(project.custom_value_for(invisible_field).typed_value).to eq('foo') + end + end + + context 'with a non-admin user' do + current_user { create(:user, global_permissions: %i[add_project]) } + + it 'does not show invisible fields in the form and thus not activates the invisible field' do + expect(page).to have_no_content 'Text for Admins only' + + click_on 'Save' + + expect(page).to have_current_path /\/projects\/foo-bar\/?/ + + project = Project.last + + expect(project.project_custom_field_ids).to contain_exactly( + required_custom_field.id + ) + end + end + end end end diff --git a/spec/features/projects/edit_settings_spec.rb b/spec/features/projects/edit_settings_spec.rb index f6be75da0e2b..a30791f83dec 100644 --- a/spec/features/projects/edit_settings_spec.rb +++ b/spec/features/projects/edit_settings_spec.rb @@ -26,9 +26,9 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'Projects', 'editing settings', :js, :with_cuprite do +RSpec.describe "Projects", "editing settings", :js, :with_cuprite do let(:name_field) { FormFields::InputFormField.new :name } let(:parent_field) { FormFields::SelectFormField.new :parent } let(:permissions) { %i(edit_project) } @@ -38,170 +38,189 @@ end shared_let(:project) do - create(:project, name: 'Foo project', identifier: 'foo-project') + create(:project, name: "Foo project", identifier: "foo-project") end - it 'hides the field whose functionality is presented otherwise' do + it "hides the field whose functionality is presented otherwise" do visit project_settings_general_path(project.id) - expect(page).to have_no_text :all, 'Active' - expect(page).to have_no_text :all, 'Identifier' + expect(page).to have_no_text :all, "Active" + expect(page).to have_no_text :all, "Identifier" end - describe 'identifier edit' do - it 'updates the project identifier' do + describe "identifier edit" do + it "updates the project identifier" do visit projects_path click_on project.name - click_on 'Project settings' - click_on 'Change identifier' + click_on "Project settings" + click_on "Change identifier" expect(page).to have_content "Change the project's identifier".upcase - expect(page).to have_current_path '/projects/foo-project/identifier' + expect(page).to have_current_path "/projects/foo-project/identifier" - fill_in 'project[identifier]', with: 'foo-bar' - click_on 'Update' + fill_in "project[identifier]", with: "foo-bar" + click_on "Update" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." expect(page) .to have_current_path %r{/projects/foo-bar/settings/general} - expect(Project.first.identifier).to eq 'foo-bar' + expect(Project.first.identifier).to eq "foo-bar" end - it 'displays error messages on invalid input' do + it "displays error messages on invalid input" do visit project_identifier_path(project) - fill_in 'project[identifier]', with: 'FOOO' - click_on 'Update' + fill_in "project[identifier]", with: "FOOO" + click_on "Update" - expect(page).to have_content 'Identifier is invalid.' - expect(page).to have_current_path '/projects/foo-project/identifier' + expect(page).to have_content "Identifier is invalid." + expect(page).to have_current_path "/projects/foo-project/identifier" end end - context 'with optional and required custom fields' do + context "with optional and required custom fields" do let!(:optional_custom_field) do - create(:custom_field, name: 'Optional Foo', - type: ProjectCustomField, - is_for_all: true) + create(:project_custom_field, name: "Optional Foo", + is_for_all: true, + projects: [project]) end let!(:required_custom_field) do - create(:custom_field, name: 'Required Foo', - type: ProjectCustomField, - is_for_all: true, - is_required: true) + create(:project_custom_field, name: "Required Foo", + is_for_all: true, + is_required: true, + projects: [project]) end - it 'shows optional and required custom fields for edit without a separation' do - project.custom_field_values.last.value = 'FOO' + it "shows optional and required custom fields for edit without a separation" do + project.custom_field_values.last.value = "FOO" project.save! visit project_settings_general_path(project.id) - expect(page).to have_text 'Optional Foo' - expect(page).to have_text 'Required Foo' + expect(page).to have_text "Optional Foo" + expect(page).to have_text "Required Foo" end end - context 'with a length restricted custom field' do + context "with a length restricted custom field" do let!(:required_custom_field) do create(:string_project_custom_field, - name: 'Foo', - type: ProjectCustomField, + name: "Foo", min_length: 1, max_length: 2, - is_for_all: true) + is_for_all: true, + projects: [project]) end let(:foo_field) { FormFields::InputFormField.new required_custom_field } - it 'shows the errors of that field when saving (Regression #33766)' do + it "shows the errors of that field when saving (Regression #33766)" do visit project_settings_general_path(project.id) - expect(page).to have_content 'Foo' + expect(page).to have_content "Foo" # Enter something too long - foo_field.set_value '1234' + foo_field.set_value "1234" # It should cut of that remaining value - foo_field.expect_value '12' + foo_field.expect_value "12" - click_button 'Save' + click_button "Save" - expect(page).to have_text 'Successful update.' + expect(page).to have_text "Successful update." end end - context 'with a multi-select custom field' do - include_context 'ng-select-autocomplete helpers' + context "with a multi-select custom field" do + include_context "ng-select-autocomplete helpers" - let!(:list_custom_field) { create(:list_project_custom_field, name: 'List CF', multi_value: true) } + let!(:list_custom_field) { create(:list_project_custom_field, name: "List CF", multi_value: true, projects: [project]) } let(:form_field) { FormFields::SelectFormField.new list_custom_field } - it 'can select multiple values' do + it "can select multiple values" do visit project_settings_general_path(project.id) - form_field.select_option 'A', 'B' + form_field.select_option "A", "B" - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." - form_field.expect_selected 'A', 'B' + form_field.expect_selected "A", "B" cvs = project.reload.custom_value_for(list_custom_field) expect(cvs.count).to eq 2 - expect(cvs.map(&:typed_value)).to contain_exactly 'A', 'B' + expect(cvs.map(&:typed_value)).to contain_exactly "A", "B" end end - context 'with a date custom field' do - let!(:date_custom_field) { create(:date_project_custom_field, name: 'Date') } + context "with a date custom field" do + let!(:date_custom_field) { create(:date_project_custom_field, name: "Date", projects: [project]) } let(:form_field) { FormFields::InputFormField.new date_custom_field } - it 'can save and remove the date (Regression #37459)' do + it "can save and remove the date (Regression #37459)" do visit project_settings_general_path(project.id) - form_field.set_value '2021-05-26' + form_field.set_value "2021-05-26" form_field.send_keys :enter - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." - form_field.expect_value '2021-05-26' + form_field.expect_value "2021-05-26" cv = project.reload.custom_value_for(date_custom_field) - expect(cv.typed_value).to eq '2021-05-26'.to_date + expect(cv.typed_value).to eq "2021-05-26".to_date end end - context 'with a user not allowed to see the parent project' do - include_context 'ng-select-autocomplete helpers' + context "with a user not allowed to see the parent project" do + include_context "ng-select-autocomplete helpers" let(:parent_project) { create(:project) } - let(:parent_field) { FormFields::SelectFormField.new 'parent' } + let(:parent_field) { FormFields::SelectFormField.new "parent" } before do project.update_attribute(:parent, parent_project) end - it 'can update the project without destroying the relation to the parent' do + it "can update the project without destroying the relation to the parent" do visit project_settings_general_path(project.id) - fill_in 'Name', with: 'New project name' + fill_in "Name", with: "New project name" - parent_field.expect_selected I18n.t(:'api_v3.undisclosed.parent') + parent_field.expect_selected I18n.t(:"api_v3.undisclosed.parent") - click_on 'Save' + click_on "Save" - expect(page).to have_content 'Successful update.' + expect(page).to have_content "Successful update." project.reload expect(project.name) - .to eql 'New project name' + .to eql "New project name" expect(project.parent) .to eql parent_project end end + + context "with correct scoping of project custom fields" do + let!(:optional_custom_field_activated_in_project) do + create(:project_custom_field, name: "Optional Foo", + is_for_all: true, + projects: [project]) + end + let!(:optional_custom_field_not_activated_in_project) do + create(:project_custom_field, name: "Optional Bar", + is_for_all: true) + end + + it "shows only the custom fields that are activated in the project" do + visit project_settings_general_path(project.id) + + expect(page).to have_text "Optional Foo" + expect(page).to have_no_text "Optional Bar" + end + end end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb new file mode 100644 index 000000000000..0d4b5f53637d --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/inputs_spec.rb @@ -0,0 +1,681 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe "with correct initialization and input behaviour" do + describe "with input fields" do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a custom field checkbox" do + it "shows the correct value if given" do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + if expected_initial_value + expect(page).to have_checked_field(custom_field.name) + else + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + it "is unchecked if no value and no default value is given" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + + it "shows default value if no value is given" do + custom_field.custom_values.destroy_all + + custom_field.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_checked_field(custom_field.name) + end + + custom_field.update!(default_value: false) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_no_checked_field(custom_field.name) + end + end + end + + shared_examples "a custom field input" do + it "shows the correct value if given" do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_initial_value) + end + end + + it "shows a blank input if no value or default value is given" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: expected_blank_value) + end + end + + it "shows the default value if no value is given" do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_field(custom_field.name, with: default_value) + end + end + end + + shared_examples "a rich text custom field input" do + it "shows the correct value if given" do + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_initial_value) + end + end + + it "shows a blank input if no value or default value is given" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(expected_blank_value) + end + end + + it "shows the default value if no value is given" do + custom_field.custom_values.destroy_all + custom_field.update!(default_value:) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content(close_after_yield: true) do + field.expect_value(default_value) + end + end + end + + describe "with boolean CF" do + let(:custom_field) { boolean_project_custom_field } + let(:expected_initial_value) { true } + + it_behaves_like "a custom field checkbox" + end + + describe "with string CF" do + let(:custom_field) { string_project_custom_field } + let(:default_value) { "Default value" } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { "Foo" } + + it_behaves_like "a custom field input" + end + + describe "with integer CF" do + let(:custom_field) { integer_project_custom_field } + let(:default_value) { 789 } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { 123 } + + it_behaves_like "a custom field input" + end + + describe "with float CF" do + let(:custom_field) { float_project_custom_field } + let(:default_value) { 789.123 } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { 123.456 } + + it_behaves_like "a custom field input" + end + + describe "with date CF" do + let(:custom_field) { date_project_custom_field } + let(:default_value) { Date.new(2026, 1, 1) } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { Date.new(2024, 1, 1) } + + it_behaves_like "a custom field input" + end + + describe "with text CF" do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:default_value) { "Default value" } + let(:expected_blank_value) { "" } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + + it_behaves_like "a rich text custom field input" + end + end + + describe "with single select fields" do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a autocomplete single select field" do + it "shows the correct value if given" do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(expected_initial_value) + end + + it "shows a blank input if no value or default value is given" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it "filters the list based on the input" do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it "enables the user to select a single value from a list" do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + field.select_option(second_option) + + field.expect_selected(second_option) + + field.search(third_option) + field.select_option(third_option) + + field.expect_selected(third_option) + field.expect_not_selected(second_option) + end + + it "clears the input if clicked on the clear button" do + overview_page.open_edit_dialog_for_section(section) + + field.clear + + field.expect_blank + end + end + + describe "with single select list CF" do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { custom_field.custom_options.first.value } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like "a autocomplete single select field" + + it "shows the default value if no value is given" do + custom_field.custom_values.destroy_all + + custom_field.custom_options.first.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(custom_field.custom_options.first.value) + end + end + + describe "with single version select list CF" do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { first_version.name } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like "a autocomplete single select field" + + describe "with correct version scoping" do + context "with a version on a different project" do + let!(:version_in_other_project) do + create(:version, name: "Version 1 in other project", project: other_project) + end + + it "shows only versions that are associated with this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Version 1") + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + + context "with a closed version" do + let!(:closed_version) { create(:version, name: "Closed version", project:, status: "closed") } + + before do + custom_field.update(allow_non_open_versions:) + end + + context "when non-open versions are not allowed" do + let(:allow_non_open_versions) { false } + + it "does not shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_no_option(closed_version.name) + end + end + + context "when non-open versions are allowed" do + let(:allow_non_open_versions) { true } + + it "shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_option(closed_version.name) + end + end + end + end + end + + describe "with single user select list CF" do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { member_in_project.name } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like "a autocomplete single select field" + + describe "with correct user scoping" do + let!(:member_in_other_project) do + create(:user, + firstname: "Member 1", + lastname: "In other Project", + member_with_roles: { other_project => reader_role }) + end + + it "shows only users that are members of the project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Member 1") + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe "with support for user groups" do + let!(:member_in_other_project) do + create(:user, + firstname: "Member 1", + lastname: "In other Project", + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: "Group 1 in project", + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: "Group 1 in other project", members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it "shows only groups that are associated with this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Group 1") + + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + end + + describe "with support for placeholder users" do + let!(:placeholder_user) do + create(:placeholder_user, name: "Placeholder User", + member_with_roles: { project => reader_role }) + end + + it "shows the placeholder user" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Placeholder User") + + field.expect_option(placeholder_user.name) + end + end + end + end + + describe "with multi select fields" do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a autocomplete multi select field" do + it "shows the correct value if given" do + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(*expected_initial_value) + end + + it "shows a blank input if no value or default value is given" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.expect_blank + end + + it "filters the list based on the input" do + overview_page.open_edit_dialog_for_section(section) + + field.search(second_option) + + field.expect_option(second_option) + field.expect_no_option(first_option) + field.expect_no_option(third_option) + end + + it "allows to select multiple values" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.expect_selected(second_option) + field.expect_selected(third_option) + end + + it "allows to remove selected values" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.deselect_option(third_option) + + field.expect_selected(second_option) + field.expect_not_selected(third_option) + end + + it "allows to remove all selected values at once" do + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + field.select_option(third_option) + + field.clear + + field.expect_not_selected(second_option) + field.expect_not_selected(third_option) + end + end + + describe "with multi select list CF" do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [custom_field.custom_options.first.value, custom_field.custom_options.second.value] } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + let(:third_option) { custom_field.custom_options.third.value } + + it_behaves_like "a autocomplete multi select field" + + it "shows the default value if no value is given" do + multi_list_project_custom_field.custom_values.destroy_all + + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(multi_list_project_custom_field.custom_options.first.value) + field.expect_selected(multi_list_project_custom_field.custom_options.second.value) + end + end + + describe "with multi version select list CF" do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [first_version.name, second_version.name] } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + let(:third_option) { third_version.name } + + it_behaves_like "a autocomplete multi select field" + + describe "with correct version scoping" do + context "with a version on a different project" do + let!(:version_in_other_project) do + create(:version, name: "Version 1 in other project", project: other_project) + end + + it "shows only versions that are associated with this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Version 1") + + field.expect_option(first_version.name) + field.expect_no_option(version_in_other_project.name) + end + end + + context "with a closed version" do + let!(:closed_version) { create(:version, name: "Closed version", project:, status: "closed") } + + before do + custom_field.update(allow_non_open_versions:) + end + + context "when non-open versions are not allowed" do + let(:allow_non_open_versions) { false } + + it "does not shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_no_option(closed_version.name) + end + end + + context "when non-open versions are allowed" do + let(:allow_non_open_versions) { true } + + it "shows closed version option" do + overview_page.open_edit_dialog_for_section(section) + field.open_options + + field.expect_option(first_version.name) + field.expect_option(closed_version.name) + end + end + end + end + end + + describe "with multi user select list CF" do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:expected_initial_value) { [member_in_project.name, another_member_in_project.name] } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + let(:third_option) { one_more_member_in_project.name } + + it_behaves_like "a autocomplete multi select field" + + describe "with correct user scoping" do + let!(:member_in_other_project) do + create(:user, + firstname: "Member 1", + lastname: "In other Project", + member_with_roles: { other_project => reader_role }) + end + + it "shows only users that are members of the project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Member 1") + + field.expect_option(member_in_project.name) + field.expect_no_option(member_in_other_project.name) + end + end + + describe "with support for user groups" do + let!(:member_in_other_project) do + create(:user, + firstname: "Member 1", + lastname: "In other Project", + member_with_roles: { other_project => reader_role }) + end + let!(:group) do + create(:group, name: "Group 1 in project", + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: "Group 2 in project", + member_with_roles: { project => reader_role }) + end + let!(:group_in_other_project) do + create(:group, name: "Group 1 in other project", members: [member_in_other_project], + member_with_roles: { other_project => reader_role }) + end + + it "shows only groups that are associated with this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Group 1") + field.expect_option(group.name) + field.expect_no_option(group_in_other_project.name) + end + + it "enables to select multiple user groups" do + overview_page.open_edit_dialog_for_section(section) + + field.select_option("Group 1 in project") + field.select_option("Group 2 in project") + + field.expect_selected("Group 1 in project") + field.expect_selected("Group 2 in project") + end + end + + describe "with support for placeholder users" do + let!(:placeholder_user) do + create(:placeholder_user, name: "Placeholder user", + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: "Another placeholder User", + member_with_roles: { project => reader_role }) + end + let!(:placeholder_user_in_other_project) do + create(:placeholder_user, name: "Placeholder user in other project", + member_with_roles: { other_project => reader_role }) + end + + it "shows only placeholder users from this project" do + overview_page.open_edit_dialog_for_section(section) + + field.search("Placeholder User") + + field.expect_option(placeholder_user.name) + field.expect_option(another_placeholder_user.name) + field.expect_no_option(placeholder_user_in_other_project.name) + end + + it "enables to select multiple placeholder users" do + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) + + field.expect_selected(placeholder_user.name) + field.expect_selected(another_placeholder_user.name) + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb new file mode 100644 index 000000000000..60c7414eaf94 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/permission_spec.rb @@ -0,0 +1,65 @@ +#-- 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. +#++ + +require 'spec_helper' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + + describe 'with insufficient permissions' do + # turboframe sidebar request is covered by a controller spec checking for 403 + # async dialog content request is be covered by a controller spec checking for 403 + # via spec/permissions/manage_project_custom_values_spec.rb + before do + login_as member_without_project_edit_permissions + overview_page.visit_page + end + + it 'does not show the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_css("[data-qa-selector='project-custom-field-section-edit-button']") + end + end + end + + describe 'with sufficient permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'shows the edit buttons' do + overview_page.within_async_loaded_sidebar do + expect(page).to have_css("[data-qa-selector='project-custom-field-section-edit-button']", count: 3) + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb new file mode 100644 index 000000000000..8bf00e23429c --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/render_spec.rb @@ -0,0 +1,196 @@ +#-- 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. +#++ + +require 'spec_helper' +require_relative '../shared_context' + +RSpec.describe 'Edit project custom fields on project overview page', :js do + include_context 'with seeded projects, members and project custom fields' + + let(:overview_page) { Pages::Projects::Show.new(project) } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) } + + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'opens a dialog showing inputs for project custom fields of a specific section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.expect_open + end + + it 'renders the dialog body asynchronically' do + expect(page).to have_no_css(dialog.async_content_container_css_selector, visible: :all) + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + expect(page).to have_css(dialog.async_content_container_css_selector, visible: :visible) + end + + it 'can be closed via close icon or cancel button' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_icon + + dialog.expect_closed + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.close_via_button + + dialog.expect_closed + end + + it 'shows only the project custom fields of the specific section within the dialog' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if input_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + + dialog = Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_multi_select_fields) + + overview_page.open_edit_dialog_for_section(section_for_multi_select_fields) + + dialog.within_async_content(close_after_yield: true) do + (input_fields + select_fields + multi_select_fields).each do |project_custom_field| + if multi_select_fields.include?(project_custom_field) + expect(page).to have_content(project_custom_field.name) + else + expect(page).to have_no_content(project_custom_field.name) + end + end + end + end + + it 'shows the inputs in the correct order defined by the position of project custom field in a section' do + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('Boolean field') + expect(containers[1].text).to include('String field') + expect(containers[2].text).to include('Integer field') + expect(containers[3].text).to include('Float field') + expect(containers[4].text).to include('Date field') + expect(containers[5].text).to include('Text field') + end + + boolean_project_custom_field.move_to_bottom + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + + dialog.within_async_content(close_after_yield: true) do + containers = dialog.input_containers + + expect(containers[0].text).to include('String field') + expect(containers[1].text).to include('Integer field') + expect(containers[2].text).to include('Float field') + expect(containers[3].text).to include('Date field') + expect(containers[4].text).to include('Text field') + expect(containers[5].text).to include('Boolean field') + end + end + + context 'with visibility of project custom fields' do + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Normal field', + visible: true, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section_with_invisible_fields) } + + context 'with admin permissions' do + before do + login_as admin + overview_page.visit_page + end + + it 'shows all project custom fields' do + overview_page.open_edit_dialog_for_section(section_with_invisible_fields) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_content('Normal field') + expect(page).to have_content('Admin only field') + end + end + end + + context 'with non-admin permissions' do + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + it 'shows only visible project custom fields' do + overview_page.open_edit_dialog_for_section(section_with_invisible_fields) + + dialog.within_async_content(close_after_yield: true) do + expect(page).to have_content('Normal field') + expect(page).to have_no_content('Admin only field') + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb new file mode 100644 index 000000000000..15f61de8bbe9 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/update_spec.rb @@ -0,0 +1,655 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe "with correct updating behaviour" do + describe "with input fields" do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a custom field checkbox" do + it "sets the value to true if checked" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content I18n.t("placeholders.default") + end + + overview_page.open_edit_dialog_for_section(section) + + field.check + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + end + + it "sets the value to false if unchecked" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + + overview_page.open_edit_dialog_for_section(section) + + field.uncheck + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "No" + end + end + + it "does not change the value if untouched" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content "Yes" + end + end + end + + shared_examples "a custom field input" do + it "saves the value properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content I18n.t("placeholders.default") + end + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: update_value) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_updated_value + end + end + + it "does not change the value if untouched" do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + end + + it "removes the value properly" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + field.fill_in(with: "") + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content I18n.t("placeholders.default") + end + end + end + + shared_examples "a rich text custom field input" do + it "saves the value properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_updated_value) + end + + overview_page.open_edit_dialog_for_section(section) + + field.set_value(update_value) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_updated_value) + end + end + + it "does not change the value if untouched" do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + + overview_page.open_edit_dialog_for_section(section) + + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_content expected_initial_value + end + end + + it "removes the value properly" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text(expected_initial_value) + end + + overview_page.open_edit_dialog_for_section(section) + + field.set_value("") + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text(expected_initial_value) + end + end + end + + describe "with boolean CF" do + let(:custom_field) { boolean_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like "a custom field checkbox" + end + + describe "with string CF" do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { "Foo" } + let(:update_value) { "Bar" } + let(:expected_updated_value) { update_value } + + it_behaves_like "a custom field input" + end + + describe "with integer CF" do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123 } + let(:update_value) { 456 } + let(:expected_updated_value) { update_value } + + it_behaves_like "a custom field input" + end + + describe "with float CF" do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { 123.456 } + let(:update_value) { 456.789 } + let(:expected_updated_value) { update_value } + + it_behaves_like "a custom field input" + end + + describe "with date CF" do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + let(:expected_initial_value) { "01/01/2024" } + let(:update_value) { Date.new(2024, 1, 2) } + let(:expected_updated_value) { "01/02/2024" } + + it_behaves_like "a custom field input" + end + + describe "with text CF" do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + let(:expected_initial_value) { "Lorem\nipsum" } # TBD: why is the second newline missing? + let(:update_value) { "Dolor\n\nsit" } + let(:expected_updated_value) { "Dolor\nsit" } + + it_behaves_like "a rich text custom field input" + end + end + + describe "with select fields" do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a select field" do + it "saves the value properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it "does not change the value if untouched" do + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(first_option) # wait for proper initialization + # don't touch the input + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it "removes the value properly" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.clear + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + end + end + + describe "with list CF" do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { custom_field.custom_options.first.value } + + it_behaves_like "a select field" + end + + describe "with version select CF" do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { first_version.name } + + it_behaves_like "a select field" + end + + describe "with user select CF" do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { member_in_project.name } + + it_behaves_like "a select field" + + describe "with support for user groups" do + let!(:group) do + create(:group, name: "Group 1 in project", + member_with_roles: { project => reader_role }) + end + + it "saves selected user group properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(group.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name + end + end + end + + describe "with support for placeholder users" do + let!(:placeholder_user) do + create(:placeholder_user, name: "Placeholder user", + member_with_roles: { project => reader_role }) + end + + it "saves selected placeholer user properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name + end + end + end + end + end + + describe "with multi select fields" do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a autocomplete multi select field" do + it "saves single selected values properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + end + end + + it "saves multi selected values properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + field.select_option(second_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + + it "removes deselected values properly" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.deselect_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_text second_option + end + end + + it "does not remove values when not touching the init values" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.expect_selected(first_option, second_option) # wait for proper initialization + # don't touch the values + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + + it "removes all values when clearing the input" do + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.clear + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + end + + it "adds values properly to init values" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_no_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(first_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_no_text second_option + end + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(second_option) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text first_option + expect(page).to have_text second_option + end + end + end + + describe "with multi select list CF" do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { custom_field.custom_options.first.value } + let(:second_option) { custom_field.custom_options.second.value } + + it_behaves_like "a autocomplete multi select field" + end + + describe "with multi version select list CF" do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { first_version.name } + let(:second_option) { second_version.name } + + it_behaves_like "a autocomplete multi select field" + end + + describe "with multi user select list CF" do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + let(:first_option) { member_in_project.name } + let(:second_option) { another_member_in_project.name } + + it_behaves_like "a autocomplete multi select field" + + describe "with support for user groups" do + let!(:group) do + create(:group, name: "Group 1 in project", + member_with_roles: { project => reader_role }) + end + let!(:another_group) do + create(:group, name: "Group 2 in project", + member_with_roles: { project => reader_role }) + end + + it "saves selected user groups properly" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(group.name) + field.select_option(another_group.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text group.name + expect(page).to have_text another_group.name + end + end + end + + describe "with support for placeholder users" do + let!(:placeholder_user) do + create(:placeholder_user, name: "Placeholder user", + member_with_roles: { project => reader_role }) + end + let!(:another_placeholder_user) do + create(:placeholder_user, name: "Another placeholder User", + member_with_roles: { project => reader_role }) + end + + it "shows only placeholder users from this project" do + custom_field.custom_values.destroy_all + + overview_page.visit_page + + overview_page.open_edit_dialog_for_section(section) + + field.select_option(placeholder_user.name) + field.select_option(another_placeholder_user.name) + + dialog.submit + dialog.expect_closed + + overview_page.within_custom_field_container(custom_field) do + expect(page).to have_text placeholder_user.name + expect(page).to have_text another_placeholder_user.name + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb new file mode 100644 index 000000000000..833cc8063f63 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/dialog/validation_spec.rb @@ -0,0 +1,412 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "../shared_context" + +RSpec.describe "Edit project custom fields on project overview page", :js do + include_context "with seeded projects, members and project custom fields" + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as member_with_project_edit_permissions + overview_page.visit_page + end + + describe "with correct validation behaviour" do + describe "after validation" do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + it "keeps showing only activated custom fields (tricky regression)" do + custom_field = string_project_custom_field + custom_field.update!(is_required: true) + field = FormFields::Primerized::InputField.new(custom_field) + + overview_page.open_edit_dialog_for_section(section) + + dialog.within_async_content do + containers = dialog.input_containers + + expect(containers[0].text).to include("Boolean field") + expect(containers[1].text).to include("String field") + expect(containers[2].text).to include("Integer field") + expect(containers[3].text).to include("Float field") + expect(containers[4].text).to include("Date field") + expect(containers[5].text).to include("Text field") + + expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) + end + + field.fill_in(with: "") # this will trigger the validation + + dialog.submit + + field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + dialog.within_async_content do + containers = dialog.input_containers + + expect(containers[0].text).to include("Boolean field") + expect(containers[1].text).to include("String field") + expect(containers[2].text).to include("Integer field") + expect(containers[3].text).to include("Float field") + expect(containers[4].text).to include("Date field") + expect(containers[5].text).to include("Text field") + + expect(page).to have_no_text(boolean_project_custom_field_activated_in_other_project.name) + end + end + + describe "does not loose the unpersisted values of the custom fields" do + context "with input fields" do + let(:section) { section_for_input_fields } + + let(:invalid_custom_field) { string_project_custom_field } + let(:valid_custom_field) { integer_project_custom_field } + let(:invalid_field) { FormFields::Primerized::InputField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::InputField.new(valid_custom_field) } + + it "keeps the value" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.fill_in(with: "") + valid_field.fill_in(with: "123") + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_value("") + valid_field.expect_value("123") + end + end + + context "with select fields" do + let(:section) { section_for_select_fields } + + context "with version selected" do + let(:invalid_custom_field) { list_project_custom_field } + let(:valid_custom_field) { version_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the value" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option(third_version.name) + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected(third_version.name) + end + end + + context "with user selected" do + let(:invalid_custom_field) { list_project_custom_field } + let(:valid_custom_field) { user_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the value" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option(another_member_in_project.name) + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected(another_member_in_project.name) + end + end + + context "with list selected" do + let(:invalid_custom_field) { user_project_custom_field } + let(:valid_custom_field) { list_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the value" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.select_option("Option 3") + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected("Option 3") + end + end + end + + context "with multi select fields" do + let(:section) { section_for_multi_select_fields } + + context "with multi version selected" do + let(:invalid_custom_field) { multi_list_project_custom_field } + let(:valid_custom_field) { multi_version_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the values" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option(first_version.name, third_version.name) + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected(first_version.name, third_version.name) + end + end + + context "with multi user selected" do + let(:invalid_custom_field) { multi_list_project_custom_field } + let(:valid_custom_field) { multi_user_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the values" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option(member_in_project.name, one_more_member_in_project.name) + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected(member_in_project.name, one_more_member_in_project.name) + end + end + + context "with multi list selected" do + let(:invalid_custom_field) { multi_user_project_custom_field } + let(:valid_custom_field) { multi_list_project_custom_field } + let(:invalid_field) { FormFields::Primerized::AutocompleteField.new(invalid_custom_field) } + let(:valid_field) { FormFields::Primerized::AutocompleteField.new(valid_custom_field) } + + it "keeps the value" do + invalid_custom_field.update!(is_required: true) + overview_page.open_edit_dialog_for_section(section) + + invalid_field.clear + valid_field.clear + valid_field.select_option("Option 1", "Option 3") + + dialog.submit + + invalid_field.expect_error(I18n.t("activerecord.errors.messages.blank")) + + invalid_field.expect_blank + valid_field.expect_selected("Option 1", "Option 3") + end + end + end + end + end + + describe "editing multiple sections" do + let(:input_fields_dialog) do + Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_input_fields) + end + let(:select_fields_dialog) do + Components::Projects::ProjectCustomFields::EditDialog.new(project, section_for_select_fields) + end + let(:field) { FormFields::Primerized::AutocompleteField.new(list_project_custom_field) } + + it "displays validation errors, when the previous section modal was canceled (Regression)" do + list_project_custom_field.update!(is_required: true) + list_project_custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section_for_input_fields) + input_fields_dialog.close + overview_page.open_edit_dialog_for_section(section_for_select_fields) + select_fields_dialog.submit + + field.expect_error(I18n.t("activerecord.errors.messages.blank")) + end + end + + describe "with input fields" do + let(:section) { section_for_input_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a custom field input" do + it "shows an error if the value is invalid" do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t("activerecord.errors.messages.blank")) + end + end + + # boolean CFs can not be validated + + describe "with string CF" do + let(:custom_field) { string_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like "a custom field input" + end + + describe "with integer CF" do + let(:custom_field) { integer_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like "a custom field input" + end + + describe "with float CF" do + let(:custom_field) { float_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like "a custom field input" + end + + describe "with date CF" do + let(:custom_field) { date_project_custom_field } + let(:field) { FormFields::Primerized::InputField.new(custom_field) } + + it_behaves_like "a custom field input" + end + + describe "with text CF" do + let(:custom_field) { text_project_custom_field } + let(:field) { FormFields::Primerized::EditorFormField.new(custom_field) } + + it_behaves_like "a custom field input" + end + end + + describe "with select fields" do + let(:section) { section_for_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a custom field select" do + it "shows an error if the value is invalid" do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t("activerecord.errors.messages.blank")) + end + end + + describe "with list CF" do + let(:custom_field) { list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field select" + end + + describe "with version CF" do + let(:custom_field) { version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field select" + end + + describe "with user CF" do + let(:custom_field) { user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field select" + end + end + + describe "with multi select fields" do + let(:section) { section_for_multi_select_fields } + let(:dialog) { Components::Projects::ProjectCustomFields::EditDialog.new(project, section) } + + shared_examples "a custom field multi select" do + it "shows an error if the value is invalid" do + custom_field.update!(is_required: true) + custom_field.custom_values.destroy_all + + overview_page.open_edit_dialog_for_section(section) + + dialog.submit + + field.expect_error(I18n.t("activerecord.errors.messages.blank")) + end + end + + describe "with multi list CF" do + let(:custom_field) { multi_list_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field multi select" + end + + describe "with multi version CF" do + let(:custom_field) { multi_version_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field multi select" + end + + describe "with multi user CF" do + let(:custom_field) { multi_user_project_custom_field } + let(:field) { FormFields::Primerized::AutocompleteField.new(custom_field) } + + it_behaves_like "a custom field multi select" + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/shared_context.rb b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb new file mode 100644 index 000000000000..5cfb258b2434 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/shared_context.rb @@ -0,0 +1,232 @@ +#-- 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. +#++ + +RSpec.shared_context 'with seeded projects, members and project custom fields' do + let(:project) { create(:project, name: 'Foo project', identifier: 'foo-project') } + let(:other_project) { create(:project, name: 'Bar project', identifier: 'bar-project') } + + let!(:first_version) { create(:version, name: 'Version 1', project:) } + let!(:second_version) { create(:version, name: 'Version 2', project:) } + let!(:third_version) { create(:version, name: 'Version 3', project:) } + + shared_let(:reader_role) do + create(:project_role, permissions: %i[view_work_packages]) + end + + shared_let(:edit_role) do + create(:project_role, permissions: %i[view_work_packages edit_project]) + end + + let!(:admin) do + create(:admin) + end + + let!(:member_in_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In Project', + member_with_roles: { project => reader_role }) + end + + let!(:another_member_in_project) do + create(:user, + firstname: 'Member 2', + lastname: 'In Project', + member_with_roles: { project => reader_role }) + end + + let!(:one_more_member_in_project) do + create(:user, + firstname: 'Member 3', + lastname: 'In Project', + member_with_roles: { project => reader_role }) + end + + let!(:member_with_project_edit_permissions) do + create(:user, + firstname: 'Member', + lastname: 'With Project Edit Permissions', + member_with_roles: { project => edit_role }) + end + + let!(:member_without_project_edit_permissions) do + member_in_project + end + + let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + let!(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + let!(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + field = create(:boolean_project_custom_field, projects: [project], name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: true) + + field + end + + let!(:string_project_custom_field) do + field = create(:string_project_custom_field, projects: [project], name: 'String field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 'Foo') + + field + end + + let!(:integer_project_custom_field) do + field = create(:integer_project_custom_field, projects: [project], name: 'Integer field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 123) + + field + end + + let!(:float_project_custom_field) do + field = create(:float_project_custom_field, projects: [project], name: 'Float field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: 123.456) + + field + end + + let!(:date_project_custom_field) do + field = create(:date_project_custom_field, projects: [project], name: 'Date field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: Date.new(2024, 1, 1)) + + field + end + + let!(:text_project_custom_field) do + field = create(:text_project_custom_field, projects: [project], name: 'Text field', + project_custom_field_section: section_for_input_fields) + + create(:custom_value, customized: project, custom_field: field, value: "Lorem\n\nipsum") + + field + end + + let!(:list_project_custom_field) do + field = create(:list_project_custom_field, projects: [project], name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first) + + field + end + + let!(:version_project_custom_field) do + field = create(:version_project_custom_field, projects: [project], name: 'Version field', + project_custom_field_section: section_for_select_fields) + + create(:custom_value, customized: project, custom_field: field, value: first_version.id) + + field + end + + let!(:user_project_custom_field) do + field = create(:user_project_custom_field, projects: [project], name: 'User field', + project_custom_field_section: section_for_select_fields) + + create(:custom_value, customized: project, custom_field: field, value: member_in_project.id) + + field + end + + let!(:multi_list_project_custom_field) do + field = create(:list_project_custom_field, projects: [project], name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.first.id) + create(:custom_value, customized: project, custom_field: field, value: field.custom_options.second.id) + + field + end + + let!(:multi_version_project_custom_field) do + field = create(:version_project_custom_field, projects: [project], name: 'Multi version field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: first_version.id) + create(:custom_value, customized: project, custom_field: field, value: second_version.id) + + field + end + + let!(:multi_user_project_custom_field) do + field = create(:user_project_custom_field, projects: [project], name: 'Multi user field', + project_custom_field_section: section_for_multi_select_fields, + multi_value: true) + + create(:custom_value, customized: project, custom_field: field, value: member_in_project.id) + create(:custom_value, customized: project, custom_field: field, value: another_member_in_project.id) + + field + end + + let!(:input_fields) do + [ + boolean_project_custom_field, + string_project_custom_field, + integer_project_custom_field, + float_project_custom_field, + date_project_custom_field, + text_project_custom_field + ] + end + + let!(:select_fields) do + [ + list_project_custom_field, + version_project_custom_field, + user_project_custom_field + ] + end + + let!(:multi_select_fields) do + [ + multi_list_project_custom_field, + multi_version_project_custom_field, + multi_user_project_custom_field + ] + end + + let!(:boolean_project_custom_field_activated_in_other_project) do + create(:boolean_project_custom_field, projects: [other_project], name: 'Other Boolean field', + project_custom_field_section: section_for_input_fields) + end +end diff --git a/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb new file mode 100644 index 000000000000..4eae8b7909e2 --- /dev/null +++ b/spec/features/projects/project_custom_fields/overview_page/sidebar_spec.rb @@ -0,0 +1,843 @@ +#-- 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. +#++ + +require "spec_helper" +require_relative "shared_context" + +RSpec.describe "Show project custom fields on project overview page", :js, :with_cuprite do + include_context "with seeded projects, members and project custom fields" + + let(:overview_page) { Pages::Projects::Show.new(project) } + + before do + login_as admin + end + + it "does show the project attributes sidebar" do + overview_page.visit_page + + within ".op-grid-page" do + expect(page).to have_css("#project-custom-fields-sidebar") + end + end + + describe "with correct order and scoping" do + it "shows the project custom field sections in the correct order" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + sections = page.all(".op-project-custom-field-section-container") + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include("Input fields") + expect(sections[1].text).to include("Select fields") + expect(sections[2].text).to include("Multi select fields") + end + + section_for_input_fields.move_to_bottom + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + sections = page.all(".op-project-custom-field-section-container") + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include("Select fields") + expect(sections[1].text).to include("Multi select fields") + expect(sections[2].text).to include("Input fields") + end + end + + it "shows the project custom fields in the correct order within the sections" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all(".op-project-custom-field-container") + + expect(fields.size).to eq(6) + + expect(fields[0].text).to include("Boolean field") + expect(fields[1].text).to include("String field") + expect(fields[2].text).to include("Integer field") + expect(fields[3].text).to include("Float field") + expect(fields[4].text).to include("Date field") + expect(fields[5].text).to include("Text field") + end + + overview_page.within_custom_field_section_container(section_for_select_fields) do + fields = page.all(".op-project-custom-field-container") + + expect(fields.size).to eq(3) + + expect(fields[0].text).to include("List field") + expect(fields[1].text).to include("Version field") + expect(fields[2].text).to include("User field") + end + + overview_page.within_custom_field_section_container(section_for_multi_select_fields) do + fields = page.all(".op-project-custom-field-container") + + expect(fields.size).to eq(3) + + expect(fields[0].text).to include("Multi list field") + expect(fields[1].text).to include("Multi version field") + expect(fields[2].text).to include("Multi user field") + end + end + + string_project_custom_field.move_to_bottom + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_section_container(section_for_input_fields) do + fields = page.all(".op-project-custom-field-container") + + expect(fields.size).to eq(6) + + expect(fields[0].text).to include("Boolean field") + expect(fields[1].text).to include("Integer field") + expect(fields[2].text).to include("Float field") + expect(fields[3].text).to include("Date field") + expect(fields[4].text).to include("Text field") + expect(fields[5].text).to include("String field") + end + end + end + + it "does not show project custom fields not enabled for this project in a sidebar" do + create(:string_project_custom_field, projects: [other_project], name: "String field enabled for other project") + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + expect(page).to have_no_text "String field enabled for other project" + end + end + end + + describe "with correct values" do + describe "with boolean CF" do + # it_behaves_like 'a project custom field' do + # let(subject) { boolean_project_custom_field } + # end + + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text "Boolean field" + expect(page).to have_text "Yes" + end + end + end + end + + describe "with value unset by user" do + # A boolean cannot be completely unset via UI, only toggle between true and false, no blank value possible + before do + boolean_project_custom_field.custom_values.where(customized: project).first.update!(value: false) + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text "Boolean field" + expect(page).to have_text "No" + end + end + end + end + + describe "with no value set by user" do + before do + boolean_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + boolean_project_custom_field.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + + boolean_project_custom_field.update!(default_value: false) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_text "Boolean field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with string CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text "String field" + expect(page).to have_text "Foo" + end + end + end + end + + describe "with value unset by user" do + before do + string_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + string_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + string_project_custom_field.update!(default_value: "Bar") + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(string_project_custom_field) do + expect(page).to have_text "String field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with integer CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text "Integer field" + expect(page).to have_text "123" + end + end + end + end + + describe "with value unset by user" do + before do + integer_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + integer_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + integer_project_custom_field.update!(default_value: 456) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(integer_project_custom_field) do + expect(page).to have_text "Integer field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with date CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text "Date field" + expect(page).to have_text "01/01/2024" + end + end + end + end + + describe "with value unset by user" do + before do + date_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + date_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + date_project_custom_field.update!(default_value: Date.new(2024, 2, 2)) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(date_project_custom_field) do + expect(page).to have_text "Date field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with float CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text "Float field" + expect(page).to have_text "123.456" + end + end + end + end + + describe "with value unset by user" do + before do + float_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + float_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "dies not show the default value for the project custom field if no value given" do + float_project_custom_field.update!(default_value: 456.789) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(float_project_custom_field) do + expect(page).to have_text "Float field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with text CF" do + describe "with value set by user" do + context "with a value that is shorter than 100 characters" do + it "shows the correct value for the project custom field if given without truncation and dialog button" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text "Text field" + expect(page).to have_text "Lorem\nipsum" + end + end + end + end + + context "with a value that is longer than 100 characters" do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: "a" * 101) + end + + it "shows the correct value for the project custom field if given with truncation and dialog button" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text "Text field" + expect(page).to have_text ("#{'a' * 97}...") + expect(page).to have_text "Expand" + + click_on "Expand" + + within "dialog" do + expect(page).to have_text "a" * 101 + end + end + end + end + end + end + + describe "with value unset by user" do + before do + text_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + text_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + text_project_custom_field.update!(default_value: "Dolor sit amet") + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(text_project_custom_field) do + expect(page).to have_text "Text field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with list CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text "List field" + expect(page).to have_text "Option 1" + end + end + end + end + + describe "with value unset by user" do + before do + list_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + list_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value for the project custom field if no value given" do + list_project_custom_field.custom_options.first.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(list_project_custom_field) do + expect(page).to have_text "List field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with version CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text "Version field" + expect(page).to have_text "Version 1" + end + end + end + end + + describe "with value unset by user" do + before do + version_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text "Version field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + version_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(version_project_custom_field) do + expect(page).to have_text "Version field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with user CF" do + describe "with value set by user" do + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text "User field" + expect(page).to have_css("opce-principal") + expect(page).to have_text "Member 1 In Project" + end + end + end + end + + describe "with value unset by user" do + before do + user_project_custom_field.custom_values.where(customized: project).first.update!(value: "") + end + + it "shows the correct value for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text "User field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with no value set by user" do + before do + user_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(user_project_custom_field) do + expect(page).to have_text "User field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + + describe "with support for user groups" do + # TODO + end + + describe "with support for user placeholders" do + # TODO + end + end + + describe "with multi list CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text "Multi list field" + expect(page).to have_text "Option 1, Option 2" + end + end + end + end + + describe "with no value set by user" do + before do + multi_list_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text "Multi list field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + + it "does not show the default value(s) for the project custom field if no value given" do + multi_list_project_custom_field.custom_options.first.update!(default_value: true) + multi_list_project_custom_field.custom_options.second.update!(default_value: true) + + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_text "Multi list field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with multi version CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text "Multi version field" + expect(page).to have_text "Version 1, Version 2" + end + end + end + end + + describe "with no value set by user" do + before do + multi_version_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_version_project_custom_field) do + expect(page).to have_text "Multi version field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + + describe "with multi user CF" do + describe "with value set by user" do + it "shows the correct values for the project custom field if given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text "Multi user field" + expect(page).to have_css "opce-principal", count: 2 + expect(page).to have_text "Member 1 In Project" + expect(page).to have_text "Member 2 In Project" + end + end + end + end + + describe "with no value set by user" do + before do + multi_user_project_custom_field.custom_values.where(customized: project).destroy_all + end + + it "shows an N/A text for the project custom field if no value given" do + overview_page.visit_page + + overview_page.within_async_loaded_sidebar do + overview_page.within_custom_field_container(multi_user_project_custom_field) do + expect(page).to have_text "Multi user field" + expect(page).to have_text I18n.t("placeholders.default") + end + end + end + end + end + end +end diff --git a/spec/features/projects/project_custom_fields/settings/mapping_spec.rb b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb new file mode 100644 index 000000000000..61db89df1e04 --- /dev/null +++ b/spec/features/projects/project_custom_fields/settings/mapping_spec.rb @@ -0,0 +1,448 @@ +#-- 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. +#++ + +require 'spec_helper' + +RSpec.describe 'Projects custom fields mapping via project settings', :js, :with_cuprite do + let(:project) { create(:project, name: 'Foo project', identifier: 'foo-project') } + let(:other_project) { create(:project, name: 'Bar project', identifier: 'bar-project') } + + let!(:user_with_sufficient_permissions) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ], + other_project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + let!(:member_in_project) do + create(:user, + firstname: 'Member 1', + lastname: 'In Project', + member_with_permissions: { project => %w[ + edit_project + view_work_packages + ] }) + end + + let!(:another_member_in_project) do + create(:user, + firstname: 'Member 2', + lastname: 'In Project', + member_with_permissions: { project => %w[ + view_work_packages + ] }) + end + + let!(:section_for_input_fields) { create(:project_custom_field_section, name: 'Input fields') } + let!(:section_for_select_fields) { create(:project_custom_field_section, name: 'Select fields') } + let!(:section_for_multi_select_fields) { create(:project_custom_field_section, name: 'Multi select fields') } + + let!(:boolean_project_custom_field) do + create(:boolean_project_custom_field, name: 'Boolean field', + project_custom_field_section: section_for_input_fields) + end + + let!(:string_project_custom_field) do + create(:string_project_custom_field, name: 'String field', + project_custom_field_section: section_for_input_fields) + end + + let!(:list_project_custom_field) do + create(:list_project_custom_field, name: 'List field', + project_custom_field_section: section_for_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3']) + end + + let!(:multi_list_project_custom_field) do + create(:list_project_custom_field, name: 'Multi list field', + project_custom_field_section: section_for_multi_select_fields, + possible_values: ['Option 1', 'Option 2', 'Option 3'], + multi_value: true) + end + + describe 'with insufficient permissions' do + before do + login_as member_in_project # can edit project but is not allowed to select project custom fields + end + + it 'does not show the menu entry in the project settings menu' do + visit project_settings_general_path(project) + + within '#menu-sidebar' do + expect(page).to have_no_css("li[data-name='settings_project_custom_fields']") + end + end + + it 'does not show the project custom fields page' do + visit project_settings_project_custom_fields_path(project) + + expect(page).to have_content('You are not authorized to access this page.') + end + end + + describe 'with sufficient permissions' do + before do + login_as user_with_sufficient_permissions + end + + it 'does show the menu entry in the project settings menu' do + visit project_settings_general_path(project) + + within '#menu-sidebar' do + expect(page).to have_css("li[data-name='settings_project_custom_fields']") + end + end + + it 'shows all available project custom fields with their correct mapping state' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect(page).to have_content('Boolean field') + expect_type("Bool") + expect_unchecked_state + end + within_custom_field_container(string_project_custom_field) do + expect(page).to have_content('String field') + expect_type("String") + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect(page).to have_content('List field') + expect_type("List") + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect(page).to have_content('Multi list field') + expect_type("List") + expect_unchecked_state + end + end + end + + it 'toggles the mapping state of a project custom field for a specific project when clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + + page.find("[data-qa-selector='toggle-project-custom-field-mapping-#{boolean_project_custom_field.id}'] > button").click + + expect_checked_state # without reloading the page + end + end + + # propely persisted and visible after full page reload + visit project_settings_project_custom_fields_path(project) + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + + # only for this project + visit project_settings_project_custom_fields_path(other_project) + + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + end + end + + it 'enables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + within_custom_field_container(string_project_custom_field) do + expect_checked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state + end + end + end + + it 'disables all mapping states of a section for a specific project when bulk action button clicked' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_checked_state + end + within_custom_field_container(string_project_custom_field) do + expect_checked_state + end + end + + within_custom_field_section_container(section_for_select_fields) do + within_custom_field_container(list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_multi_select_fields) do + within_custom_field_container(multi_list_project_custom_field) do + expect_unchecked_state + end + end + + within_custom_field_section_container(section_for_input_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_for_input_fields.id}']").click + + within_custom_field_container(boolean_project_custom_field) do + expect_unchecked_state + end + within_custom_field_container(string_project_custom_field) do + expect_unchecked_state + end + end + end + + it 'filters the project custom fields by name with given user input' do + visit project_settings_project_custom_fields_path(project) + + fill_in 'project-custom-fields-mapping-filter', with: 'Boolean' + + within_custom_field_section_container(section_for_input_fields) do + expect(page).to have_content('Boolean field') + expect(page).to have_no_content('String field') + end + + within_custom_field_section_container(section_for_select_fields) do + expect(page).to have_no_content('List field') + end + + within_custom_field_section_container(section_for_multi_select_fields) do + expect(page).to have_no_content('Multi list field') + end + end + + it 'shows the project custom field sections in the correct order' do + visit project_settings_project_custom_fields_path(project) + + sections = page.all('.op-project-custom-field-section') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Input fields') + expect(sections[1].text).to include('Select fields') + expect(sections[2].text).to include('Multi select fields') + + section_for_input_fields.move_to_bottom + + visit project_settings_project_custom_fields_path(project) + + sections = page.all('.op-project-custom-field-section') + + expect(sections.size).to eq(3) + + expect(sections[0].text).to include('Select fields') + expect(sections[1].text).to include('Multi select fields') + expect(sections[2].text).to include('Input fields') + end + + it 'shows the project custom fields in the correct order within the sections' do + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') + + expect(custom_fields.size).to eq(2) + + expect(custom_fields[0].text).to include('Boolean field') + expect(custom_fields[1].text).to include('String field') + end + + boolean_project_custom_field.move_to_bottom + + visit project_settings_project_custom_fields_path(project) + + within_custom_field_section_container(section_for_input_fields) do + custom_fields = page.all('.op-project-custom-field') + + expect(custom_fields.size).to eq(2) + + expect(custom_fields[0].text).to include('String field') + expect(custom_fields[1].text).to include('Boolean field') + end + end + + context 'with visibility of project custom fields' do + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Normal field', + visible: true, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + projects: [project], + project_custom_field_section: section_with_invisible_fields) + end + + context 'with admin permissions' do + let!(:admin) do + create(:admin) + end + + before do + login_as admin + visit project_settings_project_custom_fields_path(project) + end + + it 'shows the invisible project custom fields' do + within_custom_field_section_container(section_with_invisible_fields) do + expect(page).to have_content('Normal field') + expect(page).to have_content('Admin only field') + end + end + + it 'includeds the invisible project custom fields in the bulk actions' do + within_custom_field_section_container(section_with_invisible_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_unchecked_state + end + within_custom_field_container(invisible_project_custom_field) do + expect_unchecked_state + end + + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_checked_state + end + within_custom_field_container(invisible_project_custom_field) do + expect_checked_state + end + end + end + end + + context 'with non-admin permissions' do + before do + login_as user_with_sufficient_permissions + visit project_settings_project_custom_fields_path(project) + end + + it 'does not show the invisible project custom fields' do + within_custom_field_section_container(section_with_invisible_fields) do + expect(page).to have_content('Normal field') + expect(page).to have_no_content('Admin only field') + end + end + + it 'does not include the invisible project custom fields in the bulk actions' do + within_custom_field_section_container(section_with_invisible_fields) do + page.find("[data-qa-selector='disable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_unchecked_state + end + + # the invisible field is not affected by the bulk action + expect(project.project_custom_fields).to include(invisible_project_custom_field) + + # disable manually + project.project_custom_field_project_mappings.find_by(custom_field_id: invisible_project_custom_field.id).destroy! + + page.find("[data-qa-selector='enable-all-project-custom-field-mappings-#{section_with_invisible_fields.id}']").click + + within_custom_field_container(visible_project_custom_field) do + expect_checked_state + end + + # the invisible field is not affected by the bulk action + expect(project.project_custom_fields).not_to include(invisible_project_custom_field) + end + end + end + end + end + + def expect_type(type) + within "[data-qa-selector='custom-field-type']" do + expect(page).to have_content(type) + end + end + + def expect_checked_state + expect(page).to have_css('.ToggleSwitch-statusOn') + end + + def expect_unchecked_state + expect(page).to have_css('.ToggleSwitch-statusOff') + end + + def within_custom_field_section_container(section, &) + within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + end + + def within_custom_field_container(custom_field, &) + within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + end +end diff --git a/spec/features/projects/projects_custom_fields_spec.rb b/spec/features/projects/projects_custom_fields_spec.rb index 856274324bd5..60aba1edbcd2 100644 --- a/spec/features/projects/projects_custom_fields_spec.rb +++ b/spec/features/projects/projects_custom_fields_spec.rb @@ -96,7 +96,10 @@ default_int_field.expect_value default_int_custom_field.default_value.to_s default_string_field.expect_value 'Overwritten' - no_default_string_field.expect_value '' + + # The custom field without default value should not be shown + # as it didn't got activated with a value during project creation + expect(page).to have_no_css("[data-qa-field-name='customField#{no_default_string_custom_field.id}']") end end @@ -106,6 +109,11 @@ end let(:editor) { Components::WysiwygEditor.new "[data-qa-field-name='customField#{custom_field.id}']" } + before do + # enable the custom field for the project + project.project_custom_fields << custom_field + end + it 'allows settings the project boolean CF (regression #26313)' do visit project_settings_general_path(project.id) @@ -196,6 +204,11 @@ create(:boolean_project_custom_field) end + before do + # enable the custom field for the project + project.project_custom_fields << custom_field + end + it 'allows settings the project boolean CF (regression #26313)' do visit project_settings_general_path(project.id) field = page.find(identifier) @@ -256,6 +269,8 @@ end it 'allows inviting a new user immediately (regression #39166)' do + project.project_custom_fields << custom_field # enable the custom field for the project + visit project_settings_general_path(project.id) cf_field.expect_visible diff --git a/spec/features/projects/projects_index_spec.rb b/spec/features/projects/projects_index_spec.rb index 1e3095b6e6e0..ab09bd73b957 100644 --- a/spec/features/projects/projects_index_spec.rb +++ b/spec/features/projects/projects_index_spec.rb @@ -822,6 +822,15 @@ def expect_projects_in_order(*projects) expect(page).to have_text(project_created_on_today.name) expect(page).to have_no_text(project_created_on_fixed_date.name) + + # Disabling a CF in the project should remove the project from results + + project_created_on_today.project_custom_field_project_mappings.destroy_all + click_on 'Apply' + wait_for_reload + + expect(page).to have_no_text(project_created_on_today.name, wait: 1) + expect(page).to have_no_text(project_created_on_fixed_date.name) end pending "NOT WORKING YET: Date vs. DateTime issue: Selecting same date for from and to value shows projects of that date" diff --git a/spec/features/work_packages/attachments/attachment_upload_spec.rb b/spec/features/work_packages/attachments/attachment_upload_spec.rb index 274ba35ff1e7..d4e6516473d5 100644 --- a/spec/features/work_packages/attachments/attachment_upload_spec.rb +++ b/spec/features/work_packages/attachments/attachment_upload_spec.rb @@ -26,26 +26,26 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require 'features/page_objects/notification' +require "spec_helper" +require "features/page_objects/notification" -RSpec.describe 'Upload attachment to work package', :js, :with_cuprite do +RSpec.describe "Upload attachment to work package", :js, :with_cuprite do let(:role) do create(:project_role, permissions: %i[view_work_packages add_work_packages edit_work_packages add_work_package_notes]) end let(:dev) do create(:user, - firstname: 'Dev', - lastname: 'Guy', + firstname: "Dev", + lastname: "Guy", member_with_roles: { project => role }) end let(:project) { create(:project) } - let(:work_package) { create(:work_package, project:, description: 'Initial description') } + let(:work_package) { create(:work_package, project:, description: "Initial description") } let(:wp_page) { Pages::FullWorkPackage.new(work_package, project) } let(:attachments) { Components::Attachments.new } - let(:field) { TextEditorField.new wp_page, 'description' } - let(:image_fixture) { UploadedFile.load_from('spec/fixtures/files/image.png') } + let(:field) { TextEditorField.new wp_page, "description" } + let(:image_fixture) { UploadedFile.load_from("spec/fixtures/files/image.png") } let(:editor) { Components::WysiwygEditor.new } before do @@ -54,78 +54,78 @@ wp_page.ensure_page_loaded end - describe 'wysiwyg editor' do - context 'when on an existing page' do + describe "wysiwyg editor" do + context "when on an existing page" do before do wp_page.visit! wp_page.ensure_page_loaded end - it 'can upload an image via drag & drop' do + it "can upload an image via drag & drop" do # Activate the edit field field.activate! - editor.expect_button 'Upload image from computer' + editor.expect_button "Upload image from computer" - editor.drag_attachment image_fixture.path, 'Some image caption' + editor.drag_attachment image_fixture.path, "Some image caption" field.submit_by_click - expect(field.display_element).to have_css('img') - expect(field.display_element).to have_content('Some image caption') + expect(field.display_element).to have_css("img") + expect(field.display_element).to have_content("Some image caption") end - context 'when editing comment' do - let(:selector) { '.work-packages--activity--add-comment' } + context "when editing comment" do + let(:selector) { ".work-packages--activity--add-comment" } let(:comment_field) do TextEditorField.new wp_page, - 'comment', + "comment", selector: end - let(:editor) { Components::WysiwygEditor.new '.work-packages--activity--add-comment' } + let(:editor) { Components::WysiwygEditor.new ".work-packages--activity--add-comment" } - context 'with a user that is not allowed to add images (Regression #28541)' do + context "with a user that is not allowed to add images (Regression #28541)" do let(:role) do create(:project_role, permissions: %i[view_work_packages add_work_packages add_work_package_notes]) end - it 'can open the editor to add an image, but image upload is not shown' do + it "can open the editor to add an image, but image upload is not shown" do # Add comment comment_field.activate! # Button should be hidden - editor.expect_no_button 'Upload image from computer' + editor.expect_no_button "Upload image from computer" - editor.click_and_type_slowly 'this is a comment!1' + editor.click_and_type_slowly "this is a comment!1" comment_field.submit_by_click - wp_page.expect_comment text: 'this is a comment!1' + wp_page.expect_comment text: "this is a comment!1" end end - context 'with a user that is allowed add attachments but not edit WP (#29203)' do + context "with a user that is allowed add attachments but not edit WP (#29203)" do let(:role) do create(:project_role, permissions: %i[view_work_packages add_work_package_attachments add_work_package_notes]) end - it 'can open the editor and image upload is shown' do + it "can open the editor and image upload is shown" do comment_field.activate! - editor.expect_button 'Upload image from computer' + editor.expect_button "Upload image from computer" - editor.click_and_type_slowly 'this is a comment!2' - editor.drag_attachment image_fixture.path, 'Some image caption' + editor.click_and_type_slowly "this is a comment!2" + editor.drag_attachment image_fixture.path, "Some image caption" comment_field.submit_by_click - wp_page.expect_comment text: 'this is a comment!2' + wp_page.expect_comment text: "this is a comment!2" end end end end - context 'when on a split page' do + context "when on a split page" do let!(:type) { create(:type_task) } let!(:status) { create(:status, is_default: true) } let!(:priority) { create(:priority, is_default: true) } @@ -134,13 +134,13 @@ end let!(:table) { Pages::WorkPackagesTable.new project } - it 'can add two work packages in a row when uploading (Regression #42933)' do |example| + it "can add two work packages in a row when uploading (Regression #42933)" do |example| table.visit! new_page = table.create_wp_by_button type subject = new_page.edit_field :subject - subject.set_value 'My subject' + subject.set_value "My subject" - target = find('.ck-content') + target = find(".ck-content") attachments.drag_and_drop_file(target, image_fixture.path) sleep 2 unless example.metadata[:with_cuprite] @@ -148,39 +148,39 @@ editor.in_editor do |_container, editable| expect(editable).to have_css('img[src*="/api/v3/attachments/"]', wait: 20) - expect(editable).to have_no_css('.ck-upload-placeholder-loader') + expect(editable).to have_no_css(".ck-upload-placeholder-loader") end sleep 2 unless example.metadata[:with_cuprite] - scroll_to_and_click find_by_id('work-packages--edit-actions-save') + scroll_to_and_click find_by_id("work-packages--edit-actions-save") new_page.expect_and_dismiss_toaster( - message: 'Successful creation.' + message: "Successful creation." ) split_view = Pages::SplitWorkPackage.new(WorkPackage.last) field = split_view.edit_field :description - expect(field.display_element).to have_css('img') + expect(field.display_element).to have_css("img") wp = WorkPackage.last - expect(wp.subject).to eq('My subject') + expect(wp.subject).to eq("My subject") expect(wp.attachments.count).to eq(1) # create another one new_page = table.create_wp_by_button type subject = new_page.edit_field :subject - subject.set_value 'A second task' + subject.set_value "A second task" - scroll_to_and_click find_by_id('work-packages--edit-actions-save') + scroll_to_and_click find_by_id("work-packages--edit-actions-save") new_page.expect_toast( - message: 'Successful creation.' + message: "Successful creation." ) last = WorkPackage.last - expect(last.subject).to eq('A second task') + expect(last.subject).to eq("A second task") expect(last.attachments.count).to eq(0) wp.reload @@ -188,8 +188,8 @@ end end - context 'when on a new page' do - shared_examples 'it supports image uploads via drag & drop' do + context "when on a new page" do + shared_examples "it supports image uploads via drag & drop" do let!(:new_page) { Pages::FullWorkPackageCreate.new } let!(:type) { create(:type_task) } let!(:status) { create(:status, is_default: true) } @@ -204,11 +204,11 @@ visit new_project_work_packages_path(project.identifier, type: type.id) end - it 'can upload an image via drag & drop (Regression #28189)' do |example| + it "can upload an image via drag & drop (Regression #28189)" do |example| subject = new_page.edit_field :subject - subject.set_value 'My subject' + subject.set_value "My subject" - target = find('.ck-content') + target = find(".ck-content") attachments.drag_and_drop_file(target, image_fixture.path) sleep 2 unless example.metadata[:with_cuprite] @@ -216,29 +216,29 @@ editor.in_editor do |_container, editable| expect(editable).to have_css('img[src*="/api/v3/attachments/"]', wait: 20) - expect(editable).to have_no_css('.ck-upload-placeholder-loader') + expect(editable).to have_no_css(".ck-upload-placeholder-loader") end sleep 2 unless example.metadata[:with_cuprite] - scroll_to_and_click find_by_id('work-packages--edit-actions-save') + scroll_to_and_click find_by_id("work-packages--edit-actions-save") wp_page.expect_toast( - message: 'Successful creation.' + message: "Successful creation." ) field = wp_page.edit_field :description - expect(field.display_element).to have_css('img') + expect(field.display_element).to have_css("img") wp = WorkPackage.last - expect(wp.subject).to eq('My subject') + expect(wp.subject).to eq("My subject") expect(wp.attachments.count).to eq(1) post_conditions end end - it_behaves_like 'it supports image uploads via drag & drop' + it_behaves_like "it supports image uploads via drag & drop" # We do a complete integration test for direct uploads on this example. # If this works all parts in the backend and frontend work properly together. @@ -247,12 +247,12 @@ # everywhere so if this works it should work everywhere else too. # TODO: Add better_cuprite_billy. I'm not sure what needs to be set up so the request to AWS passes. # Need help - context 'with direct uploads', :with_direct_uploads, with_cuprite: false do + context "with direct uploads", :with_direct_uploads, with_cuprite: false do before do allow_any_instance_of(Attachment).to receive(:diskfile).and_return Struct.new(:path).new(image_fixture.path.to_s) end - it_behaves_like 'it supports image uploads via drag & drop' do + it_behaves_like "it supports image uploads via drag & drop" do let(:post_conditions) do # check the attachment was created successfully expect(Attachment.count).to eq 1 @@ -267,58 +267,58 @@ end end - describe 'attachment dropzone' do - shared_examples 'attachment dropzone common' do - it 'can drag something to the files tab and have it open' do - wp_page.expect_tab 'Activity' - attachments.drag_and_drop_file test_selector('op-attachments--drop-box'), + describe "attachment dropzone" do + shared_examples "attachment dropzone common" do + it "can drag something to the files tab and have it open" do + wp_page.expect_tab "Activity" + attachments.drag_and_drop_file test_selector("op-attachments--drop-box"), image_fixture.path, :center, page.find('[data-qa-tab-id="files"]'), delay_dragleave: true - expect(page).to have_test_selector('op-files-tab--file-list-item-title', text: 'image.png', wait: 10) + expect(page).to have_test_selector("op-files-tab--file-list-item-title", text: "image.png", wait: 10) editor.wait_until_upload_progress_toaster_cleared - wp_page.expect_tab 'Files' + wp_page.expect_tab "Files" end - it 'can drag something from the files tab and create a comment with it' do - wp_page.switch_to_tab(tab: 'files') + it "can drag something from the files tab and create a comment with it" do + wp_page.switch_to_tab(tab: "files") - attachments.drag_and_drop_file '.work-package-comment', + attachments.drag_and_drop_file ".work-package-comment", image_fixture.path, :center, page.find('[data-qa-tab-id="activity"]'), delay_dragleave: true - wp_page.expect_tab 'Activity' + wp_page.expect_tab "Activity" end end - include_examples 'attachment dropzone common' + include_examples "attachment dropzone common" - context 'with a user that is allowed to add attachments but not edit WP (#29203)' do + context "with a user that is allowed to add attachments but not edit WP (#29203)" do let(:role) do create(:project_role, permissions: %i[view_work_packages add_work_package_attachments add_work_package_notes]) end - include_examples 'attachment dropzone common' + include_examples "attachment dropzone common" end # This one is not shared, because it requires edit WP - it 'can upload an image via attaching and drag & drop' do - wp_page.switch_to_tab(tab: 'files') - container = page.find_test_selector('op-attachments--drop-box') + it "can upload an image via attaching and drag & drop" do + wp_page.switch_to_tab(tab: "files") + container = page.find_test_selector("op-attachments--drop-box") ## # Attach file manually - expect(page).not_to have_test_selector('op-files-tab--file-list-item-title') + expect(page).not_to have_test_selector("op-files-tab--file-list-item-title") attachments.attach_file_on_input(image_fixture.path) editor.wait_until_upload_progress_toaster_cleared expect(page) - .to have_test_selector('op-files-tab--file-list-item-title', - text: 'image.png', + .to have_test_selector("op-files-tab--file-list-item-title", + text: "image.png", wait: 5) # Drop zone should become hidden again @@ -332,8 +332,8 @@ attachments.drag_and_drop_file(container, image_fixture.path) editor.wait_until_upload_progress_toaster_cleared expect(page) - .to have_test_selector('op-files-tab--file-list-item-title', - text: 'image.png', + .to have_test_selector("op-files-tab--file-list-item-title", + text: "image.png", count: 2, wait: 5) @@ -345,12 +345,12 @@ attachments.drag_and_drop_file container, image_fixture.path, :center, - ["#{field.selector} #{field.display_selector}", '.work-package--single-view'] + ["#{field.selector} #{field.display_selector}", ".work-package--single-view"] editor.wait_until_upload_progress_toaster_cleared expect(page) - .to have_test_selector('op-files-tab--file-list-item-title', - text: 'image.png', + .to have_test_selector("op-files-tab--file-list-item-title", + text: "image.png", count: 3, wait: 5) @@ -368,8 +368,8 @@ editor.wait_until_upload_progress_toaster_cleared expect(page) - .to have_test_selector('op-files-tab--file-list-item-title', - text: 'image.png', + .to have_test_selector("op-files-tab--file-list-item-title", + text: "image.png", count: 3, wait: 5) diff --git a/spec/features/work_packages/details/markdown/todolist_spec.rb b/spec/features/work_packages/details/markdown/todolist_spec.rb index c78914665e16..4e03179a6f03 100644 --- a/spec/features/work_packages/details/markdown/todolist_spec.rb +++ b/spec/features/work_packages/details/markdown/todolist_spec.rb @@ -26,117 +26,117 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" -RSpec.describe 'Todolists in CKEditor', :js do +RSpec.describe "Todolists in CKEditor", :js do let(:user) { create(:admin) } before do login_as user end - describe 'with an existing work package' do + describe "with an existing work package" do let(:work_package) { create(:work_package) } let(:wp_page) { Pages::FullWorkPackage.new(work_package) } let(:field) { wp_page.edit_field :description } let(:ckeditor) { field.ckeditor } - it 'can add task list and edit them again' do + it "can add task list and edit them again" do wp_page.visit! wp_page.ensure_page_loaded field.activate! ckeditor.clear - ckeditor.click_toolbar_button 'To-do List' - ckeditor.type_slowly 'Todo item 1' + ckeditor.click_toolbar_button "To-do List" + ckeditor.type_slowly "Todo item 1" ckeditor.type_slowly :enter - ckeditor.type_slowly 'Todo item 2' + ckeditor.type_slowly "Todo item 2" ckeditor.type_slowly :enter # Indent ckeditor.type_slowly :tab - ckeditor.type_slowly 'Nested item 1' + ckeditor.type_slowly "Nested item 1" ckeditor.type_slowly :enter - ckeditor.type_slowly 'Nested item 2' + ckeditor.type_slowly "Nested item 2" ckeditor.type_slowly :enter # Outdent ckeditor.type_slowly %i[shift tab] - ckeditor.type_slowly 'Todo item 3' + ckeditor.type_slowly "Todo item 3" # Select first and first nested item ckeditor.in_editor do |_container, editable| - first_item = editable.all('.todo-list li')[0] - first_item.find('input[type=checkbox]', visible: :all).set true + first_item = editable.all(".todo-list li")[0] + first_item.find("input[type=checkbox]", visible: :all).set true # First nested - first_nested_item = editable.all('.todo-list .todo-list li')[0] - first_nested_item.find('input[type=checkbox]', visible: :all).set true + first_nested_item = editable.all(".todo-list .todo-list li")[0] + first_nested_item.find("input[type=checkbox]", visible: :all).set true sleep 0.5 end field.submit_by_click - wp_page.expect_and_dismiss_toaster message: 'Successful update.' + wp_page.expect_and_dismiss_toaster message: "Successful update." within(field.display_element) do - expect(page).to have_css('.op-uc-list--task-checkbox', count: 5) - expect(page).to have_css('.op-uc-list--task-checkbox[checked]', count: 2) + expect(page).to have_css(".op-uc-list--task-checkbox", count: 5) + expect(page).to have_css(".op-uc-list--task-checkbox[checked]", count: 2) - expect(page).to have_css('.op-uc-list--item', text: 'Todo item 1') - expect(page).to have_css('.op-uc-list--item', text: 'Todo item 2') - expect(page).to have_css('.op-uc-list--item', text: 'Todo item 3') + expect(page).to have_css(".op-uc-list--item", text: "Todo item 1") + expect(page).to have_css(".op-uc-list--item", text: "Todo item 2") + expect(page).to have_css(".op-uc-list--item", text: "Todo item 3") - expect(page).to have_css('.op-uc-list .op-uc-list .op-uc-list--item', text: 'Nested item 1') - expect(page).to have_css('.op-uc-list .op-uc-list .op-uc-list--item', text: 'Nested item 2') + expect(page).to have_css(".op-uc-list .op-uc-list .op-uc-list--item", text: "Nested item 1") + expect(page).to have_css(".op-uc-list .op-uc-list .op-uc-list--item", text: "Nested item 2") - first_item = page.find('.op-uc-list--item', text: 'Todo item 1') - expect(first_item).to have_css('.op-uc-list--task-checkbox[checked]') - first_nested_item = page.find('.op-uc-list .op-uc-list .op-uc-list--item', text: 'Nested item 1') - expect(first_nested_item).to have_css('.op-uc-list--task-checkbox[checked]') + first_item = page.find(".op-uc-list--item", text: "Todo item 1") + expect(first_item).to have_css(".op-uc-list--task-checkbox[checked]") + first_nested_item = page.find(".op-uc-list .op-uc-list .op-uc-list--item", text: "Nested item 1") + expect(first_nested_item).to have_css(".op-uc-list--task-checkbox[checked]") end # Expect still the same when editing again field.activate! ckeditor.in_editor do |_container, editable| - expect(editable).to have_css('.op-uc-list li', count: 5) + expect(editable).to have_css(".op-uc-list li", count: 5) - first_item = editable.all('.op-uc-list li')[0].find('input[type=checkbox]', visible: :all) + first_item = editable.all(".op-uc-list li")[0].find("input[type=checkbox]", visible: :all) expect(first_item).to be_checked # First nested - first_nested_item = editable.all('.op-uc-list .op-uc-list li')[0].find('input[type=checkbox]', visible: :all) + first_nested_item = editable.all(".op-uc-list .op-uc-list li")[0].find("input[type=checkbox]", visible: :all) expect(first_nested_item).to be_checked # Check last item - last_item = editable.all('.op-uc-list li').last - last_item.find('input[type=checkbox]', visible: :all).set true + last_item = editable.all(".op-uc-list li").last + last_item.find("input[type=checkbox]", visible: :all).set true sleep 0.5 end field.submit_by_click - wp_page.expect_and_dismiss_toaster message: 'Successful update.' + wp_page.expect_and_dismiss_toaster message: "Successful update." within(field.display_element) do - expect(page).to have_css('.op-uc-list--task-checkbox', count: 5) - expect(page).to have_css('.op-uc-list--task-checkbox[checked]', count: 3) + expect(page).to have_css(".op-uc-list--task-checkbox", count: 5) + expect(page).to have_css(".op-uc-list--task-checkbox[checked]", count: 3) - first_item = page.find('.op-uc-list--item', text: 'Todo item 1') - expect(first_item).to have_css('.op-uc-list--task-checkbox[checked]') - first_nested_item = page.find('.op-uc-list .op-uc-list .op-uc-list--item', text: 'Nested item 1') - expect(first_nested_item).to have_css('.op-uc-list--task-checkbox[checked]') + first_item = page.find(".op-uc-list--item", text: "Todo item 1") + expect(first_item).to have_css(".op-uc-list--task-checkbox[checked]") + first_nested_item = page.find(".op-uc-list .op-uc-list .op-uc-list--item", text: "Nested item 1") + expect(first_nested_item).to have_css(".op-uc-list--task-checkbox[checked]") - last_item = page.find('.op-uc-list .op-uc-list--item', text: 'Todo item 3') - expect(last_item).to have_css('.op-uc-list--task-checkbox[checked]') + last_item = page.find(".op-uc-list .op-uc-list--item", text: "Todo item 3") + expect(last_item).to have_css(".op-uc-list--task-checkbox[checked]") end end end - describe 'creating a new work package' do + describe "creating a new work package" do let!(:status) { create(:default_status) } let!(:priority) { create(:default_priority) } let!(:type) { create(:type_task) } @@ -148,45 +148,45 @@ before do wp_page.visit! - wp_page.edit_field(:subject).set_value 'Title' + wp_page.edit_field(:subject).set_value "Title" field.expect_active! ckeditor.clear end - it 'can add a task list with links in them (Regression #30920)' do - ckeditor.click_toolbar_button 'To-do List' - ckeditor.type_slowly 'Todo item 1' + it "can add a task list with links in them (Regression #30920)" do + ckeditor.click_toolbar_button "To-do List" + ckeditor.type_slowly "Todo item 1" ckeditor.type_slowly :enter - ckeditor.insert_link 'https://community.openproject.com' + ckeditor.insert_link "https://community.openproject.com" ckeditor.type_slowly :enter ckeditor.type_slowly :tab - ckeditor.insert_link 'https://community.openproject.com/nested' + ckeditor.insert_link "https://community.openproject.com/nested" # Update the link text, no idea how to do this differently ckeditor.in_editor do |_container, editable| - link = editable.find('.todo-list .todo-list a') - link.set('This is a link') + link = editable.find(".todo-list .todo-list a") + link.set("This is a link") sleep 0.5 end # Select nested item ckeditor.in_editor do |_container, editable| - editable.find('.todo-list .todo-list input[type=checkbox]', visible: :all).set true + editable.find(".todo-list .todo-list input[type=checkbox]", visible: :all).set true sleep 0.5 end wp_page.save! - wp_page.expect_and_dismiss_toaster message: 'Successful creation.' + wp_page.expect_and_dismiss_toaster message: "Successful creation." - expect(page).to have_css('.op-uc-list--task-checkbox', count: 3) - expect(page).to have_css('.op-uc-list--task-checkbox[checked]', count: 1) + expect(page).to have_css(".op-uc-list--task-checkbox", count: 3) + expect(page).to have_css(".op-uc-list--task-checkbox[checked]", count: 1) expect(page).to have_css('.op-uc-list--item a[href="https://community.openproject.com/"]') nested_link = page.find('.op-uc-list--item .op-uc-list--item a[href="https://community.openproject.com/nested"]') - expect(nested_link.text).to eq 'This is a link' + expect(nested_link.text).to eq "This is a link" description = WorkPackage.last.description expected = <<~EOS @@ -198,61 +198,61 @@ expect(description.strip).to eq expected.strip end - it 'can add task list and edit them again' do - ckeditor.click_toolbar_button 'To-do List' - ckeditor.type_slowly 'Todo item 1' + it "can add task list and edit them again" do + ckeditor.click_toolbar_button "To-do List" + ckeditor.type_slowly "Todo item 1" ckeditor.type_slowly :enter - ckeditor.type_slowly 'Todo item 2' + ckeditor.type_slowly "Todo item 2" # Select first item ckeditor.in_editor do |_container, editable| - first_item = editable.all('.todo-list li')[0] - first_item.find('input[type=checkbox]', visible: :all).set true + first_item = editable.all(".todo-list li")[0] + first_item.find("input[type=checkbox]", visible: :all).set true sleep 0.5 end wp_page.save! - wp_page.expect_and_dismiss_toaster message: 'Successful creation.' + wp_page.expect_and_dismiss_toaster message: "Successful creation." within(field.display_element) do - expect(page).to have_css('.op-uc-list--task-checkbox', count: 2) - expect(page).to have_css('.op-uc-list--task-checkbox[checked]', count: 1) + expect(page).to have_css(".op-uc-list--task-checkbox", count: 2) + expect(page).to have_css(".op-uc-list--task-checkbox[checked]", count: 1) - expect(page).to have_css('.op-uc-list--item', text: 'Todo item 1') - expect(page).to have_css('.op-uc-list--item', text: 'Todo item 2') + expect(page).to have_css(".op-uc-list--item", text: "Todo item 1") + expect(page).to have_css(".op-uc-list--item", text: "Todo item 2") - first_item = page.find('.op-uc-list--item', text: 'Todo item 1') - expect(first_item).to have_css('.op-uc-list--task-checkbox[checked]') + first_item = page.find(".op-uc-list--item", text: "Todo item 1") + expect(first_item).to have_css(".op-uc-list--task-checkbox[checked]") end # Expect still the same when editing again field.activate! ckeditor.in_editor do |_container, editable| - expect(editable).to have_css('.todo-list li', count: 2) + expect(editable).to have_css(".todo-list li", count: 2) - first_item = editable.all('.todo-list li')[0].find('input[type=checkbox]', visible: :all) + first_item = editable.all(".todo-list li")[0].find("input[type=checkbox]", visible: :all) expect(first_item).to be_checked # Check last item - last_item = editable.all('.todo-list li').last - last_item.find('input[type=checkbox]', visible: :all).set true + last_item = editable.all(".todo-list li").last + last_item.find("input[type=checkbox]", visible: :all).set true sleep 0.5 end field.submit_by_click - wp_page.expect_and_dismiss_toaster message: 'Successful update.' + wp_page.expect_and_dismiss_toaster message: "Successful update." within(field.display_element) do - expect(page).to have_css('.op-uc-list--task-checkbox', count: 2) - expect(page).to have_css('.op-uc-list--task-checkbox[checked]', count: 2) + expect(page).to have_css(".op-uc-list--task-checkbox", count: 2) + expect(page).to have_css(".op-uc-list--task-checkbox[checked]", count: 2) - first_item = page.find('.op-uc-list--item', text: 'Todo item 1') - expect(first_item).to have_css('.op-uc-list--task-checkbox[checked]') + first_item = page.find(".op-uc-list--item", text: "Todo item 1") + expect(first_item).to have_css(".op-uc-list--task-checkbox[checked]") - last_item = page.find('.op-uc-list .op-uc-list--item', text: 'Todo item 2') - expect(last_item).to have_css('.op-uc-list--task-checkbox[checked]') + last_item = page.find(".op-uc-list .op-uc-list--item", text: "Todo item 2") + expect(last_item).to have_css(".op-uc-list--task-checkbox[checked]") end end end diff --git a/spec/fixtures/ldap/users.ldif b/spec/fixtures/ldap/users.ldif index e369e7cd773f..1daaeed36865 100644 --- a/spec/fixtures/ldap/users.ldif +++ b/spec/fixtures/ldap/users.ldif @@ -182,3 +182,17 @@ mail: xara@example.org uid: xx396 userpassword:: e1NIQX1ZYzJFbjJSL3NiZGpsRU9pdGtMbGt3WTRqQVk9 +dn: uid=bölle,ou=people,dc=example,dc=com +objectClass: inetOrgPerson +objectClass: simulatedMicrosoftSecurityPrincipal +objectClass: organizationalPerson +objectClass: person +objectClass: top +cn: Bölle Büllendorf +sn: Büllendorf +givenName: Bölle +mail: boelle@example.org +uid: bölle +samAccountName: bölle +# Password is "bólle" +userpassword:: e1NIQX1rNDBGWHRYQ3RFL3l2cENhblRpQmZ2cE1ON1k9Cg== diff --git a/spec/lib/open_project/appsignal_spec.rb b/spec/lib/open_project/appsignal_spec.rb new file mode 100644 index 000000000000..3cf4c8d4f1a5 --- /dev/null +++ b/spec/lib/open_project/appsignal_spec.rb @@ -0,0 +1,70 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "appsignal" + +RSpec.describe OpenProject::Appsignal do + describe ".exception_handler" do + let(:exception) { StandardError.new("I am a fake exception") } + + before do + allow(Appsignal).to receive(:active?).and_return(true) + allow(Appsignal).to receive(:send_error) + end + + it "does nothing if there is no exception in the log context" do + described_class.exception_handler("message") + expect(Appsignal).not_to have_received(:send_error) + end + + it "stores the exception in current appsignal transaction if one is available" do + transaction = Appsignal::Transaction.create( + SecureRandom.uuid, + Appsignal::Transaction::BACKGROUND_JOB, + Appsignal::Transaction::GenericRequest.new({}) + ) + allow(transaction).to receive(:set_error) + described_class.exception_handler("message", exception:) + + expect(transaction).to have_received(:set_error).with(exception) + expect(Appsignal).not_to have_received(:send_error) + ensure + Appsignal::Transaction.complete_current! + end + + it "sends an error through Appsignal if no current appsignal transaction available" do + allow(Appsignal).to receive(:send_error) + described_class.exception_handler("message", exception:) + + expect(Appsignal).to have_received(:send_error).with(exception) + end + end +end diff --git a/spec/models/custom_value_spec.rb b/spec/models/custom_value_spec.rb index 873e9139b87a..47966bc810ff 100644 --- a/spec/models/custom_value_spec.rb +++ b/spec/models/custom_value_spec.rb @@ -126,7 +126,7 @@ end context 'for a boolean custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :boolean) } + shared_let(:custom_field) { create(:project_custom_field, :boolean, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: false @@ -150,7 +150,7 @@ end context "for a string custom field without default value" do - shared_let(:custom_field) { create(:project_custom_field, :string) } + shared_let(:custom_field) { create(:project_custom_field, :string, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 'Hello world!' @@ -159,8 +159,8 @@ context 'for a string custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :string, default_value: 'Hello world!') - create(:project_custom_field, :string, default_value: '') + create(:project_custom_field, :string, default_value: 'Hello world!', projects: [project]) + create(:project_custom_field, :string, default_value: '', projects: [project]) custom_values = project.custom_field_values @@ -170,7 +170,7 @@ end context 'for a text custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :text) } + shared_let(:custom_field) { create(:project_custom_field, :text, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: "Hello world!" @@ -180,8 +180,8 @@ context 'for a text custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :text, default_value: 'Hello world!') - create(:project_custom_field, :text, default_value: '') + create(:project_custom_field, :text, default_value: 'Hello world!', projects: [project]) + create(:project_custom_field, :text, default_value: '', projects: [project]) custom_values = project.custom_field_values @@ -191,7 +191,7 @@ end context 'for an integer custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :integer) } + shared_let(:custom_field) { create(:project_custom_field, :integer, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 123 @@ -202,11 +202,11 @@ context 'for an integer custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :integer, default_value: 0) - create(:project_custom_field, :integer, default_value: 123) - create(:project_custom_field, :integer, default_value: '456') - create(:project_custom_field, :integer, default_value: -987) - create(:project_custom_field, :integer, default_value: '-678') + create(:project_custom_field, :integer, default_value: 0, projects: [project]) + create(:project_custom_field, :integer, default_value: 123, projects: [project]) + create(:project_custom_field, :integer, default_value: '456', projects: [project]) + create(:project_custom_field, :integer, default_value: -987, projects: [project]) + create(:project_custom_field, :integer, default_value: '-678', projects: [project]) custom_values = project.custom_field_values @@ -216,7 +216,7 @@ end context 'for a float custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :float) } + shared_let(:custom_field) { create(:project_custom_field, :float, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: 3.14 @@ -227,11 +227,11 @@ context 'for a float custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :float, default_value: 0.0) - create(:project_custom_field, :float, default_value: 12.3) - create(:project_custom_field, :float, default_value: '45.6') - create(:project_custom_field, :float, default_value: -98.7) - create(:project_custom_field, :float, default_value: '-67') + create(:project_custom_field, :float, default_value: 0.0, projects: [project]) + create(:project_custom_field, :float, default_value: 12.3, projects: [project]) + create(:project_custom_field, :float, default_value: '45.6', projects: [project]) + create(:project_custom_field, :float, default_value: -98.7, projects: [project]) + create(:project_custom_field, :float, default_value: '-67', projects: [project]) custom_values = project.custom_field_values @@ -241,7 +241,7 @@ end context 'for a date custom field' do - shared_let(:custom_field) { create(:project_custom_field, :date) } + shared_let(:custom_field) { create(:project_custom_field, :date, projects: [project]) } include_examples 'returns true for generated custom value' include_examples 'returns false for custom value with value', value: "2023-08-08" @@ -249,7 +249,7 @@ end context 'for a list custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :list) } + shared_let(:custom_field) { create(:project_custom_field, :list, projects: [project]) } include_examples 'returns true for generated custom value' @@ -266,7 +266,7 @@ context 'for a list custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :list, default_option: 'B') + create(:project_custom_field, :list, default_option: 'B', projects: [project]) custom_values = project.custom_field_values @@ -277,7 +277,7 @@ end context 'for a multi-value list custom field without default value' do - shared_let(:custom_field) { create(:project_custom_field, :multi_list) } + shared_let(:custom_field) { create(:project_custom_field, :multi_list, projects: [project]) } include_examples 'returns true for generated custom value' @@ -294,8 +294,8 @@ context 'for a multi-value list custom field with default value' do describe 'for a generated custom value' do it 'returns true' do - create(:project_custom_field, :multi_list, default_options: ['B']) - create(:project_custom_field, :multi_list, default_options: ['G', 'B', 'C']) + create(:project_custom_field, :multi_list, default_options: ['B'], projects: [project]) + create(:project_custom_field, :multi_list, default_options: ['G', 'B', 'C'], projects: [project]) custom_values = project.custom_field_values @@ -307,7 +307,7 @@ end context 'for a version custom field' do - shared_let(:custom_field) { create(:project_custom_field, :version) } + shared_let(:custom_field) { create(:project_custom_field, :version, projects: [project]) } include_examples 'returns true for generated custom value' @@ -325,7 +325,7 @@ end context 'for a multi version custom field' do - shared_let(:custom_field) { create(:project_custom_field, :multi_version) } + shared_let(:custom_field) { create(:project_custom_field, :multi_version, projects: [project]) } include_examples 'returns true for generated custom value' @@ -343,7 +343,7 @@ end context 'for a user custom field' do - shared_let(:custom_field) { create(:project_custom_field, :user) } + shared_let(:custom_field) { create(:project_custom_field, :user, projects: [project]) } include_examples 'returns true for generated custom value' @@ -363,7 +363,7 @@ end context 'for a multi user custom field' do - shared_let(:custom_field) { create(:project_custom_field, :multi_user) } + shared_let(:custom_field) { create(:project_custom_field, :multi_user, projects: [project]) } include_examples 'returns true for generated custom value' @@ -397,7 +397,9 @@ end describe '#valid?' do - let(:custom_field) { build_stubbed(:custom_field, field_format:, is_required:, min_length:, max_length:, regexp:) } + let(:custom_field) do + build_stubbed(:custom_field, field_format:, is_required:, min_length:, max_length:, regexp:) + end let(:custom_value) { described_class.new(custom_field:, value:) } let(:is_required) { false } let(:min_length) { 0 } @@ -551,7 +553,9 @@ context 'for a list custom field' do let(:custom_option1) { build_stubbed(:custom_option, value: 'value1') } let(:custom_option2) { build_stubbed(:custom_option, value: 'value1') } - let(:custom_field) { build_stubbed(:custom_field, field_format: 'list', custom_options: [custom_option1, custom_option2]) } + let(:custom_field) do + build_stubbed(:custom_field, field_format: 'list', custom_options: [custom_option1, custom_option2]) + end context 'with a value from the list' do let(:value) { custom_option1.id } @@ -729,4 +733,66 @@ expect(custom_value.value).to eql parsed_value end end + + describe '#activate_custom_field_in_customized_project' do + let(:project) { create(:project) } + + context 'with a given default value' do + let(:custom_field) { create(:string_project_custom_field, default_value: "foo") } + + context 'when a value other than the default value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "bar") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end + + context 'when a value equal to the default value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "foo") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end + end + + context 'when a value is not set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'does not activate the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end + end + end + + context 'with no default value given' do + let(:custom_field) { create(:string_project_custom_field) } + + context 'when a value is set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project, value: "bar") } + + it 'activates the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).to include(custom_field) + end + end + + context 'when a value is not set' do + let(:custom_value) { build(:custom_value, custom_field:, customized: project) } + + it 'does not activate the custom field in the project after create if missing' do + expect(project.project_custom_fields).not_to include(custom_field) + custom_value.save! + expect(project.reload.project_custom_fields).not_to include(custom_field) + end + end + end + end end diff --git a/spec/models/project_custom_field_project_mapping_spec.rb b/spec/models/project_custom_field_project_mapping_spec.rb new file mode 100644 index 000000000000..f7baa6fc572d --- /dev/null +++ b/spec/models/project_custom_field_project_mapping_spec.rb @@ -0,0 +1,47 @@ +#-- 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 ProjectCustomFieldProjectMapping do + describe 'uniqueness by project' do + let!(:project) { create(:project) } + let!(:project_custom_field) { create(:project_custom_field) } + + it 'a project custom field can only be mapped to a project once' do + project.project_custom_fields << project_custom_field + + expect(described_class).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + + expect do + project.project_custom_fields << project_custom_field + end.to raise_error(ActiveRecord::RecordInvalid, /Custom field has already been taken/) + end + end +end diff --git a/spec/models/project_custom_field_spec.rb b/spec/models/project_custom_field_spec.rb new file mode 100644 index 000000000000..376bfd811bad --- /dev/null +++ b/spec/models/project_custom_field_spec.rb @@ -0,0 +1,104 @@ +#-- 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 ProjectCustomField do + describe 'activation in projects' do + context 'when creating a new required project custom field' do + let!(:project) { create(:project) } + let!(:another_project) { create(:project) } + + it 'activates the required project custom fields in all projects' do + project_custom_field = create(:project_custom_field, is_required: true) + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + end + + context 'when setting an existing project custom field to required' do + let!(:project_custom_field) { create(:string_project_custom_field) } # optional now + let!(:project) do + create(:project, custom_field_values: { "#{project_custom_field.id}": "foo" }) + end + let!(:another_project) { create(:project) } # not using the custom field + + it 'activates the required project custom fields in all projects where it is not already activated' do + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + + project_custom_field.update!(is_required: true) # required now + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + + it 'does not disabled project custom fields when set to optional' do + project_custom_field.update!(is_required: true) # required now + project_custom_field.update!(is_required: false) # optional again + + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: another_project.id) + end + + it 'does not create duplicate mappings' do + project_custom_field.update!(is_required: true) # required now + + # mapping existed before, should not be duplicated + expect(ProjectCustomFieldProjectMapping.where(project_id: project.id, + custom_field_id: project_custom_field.id).count).to eq(1) + end + end + + context 'when deleting a project custom field' do + let!(:project_custom_field) { create(:string_project_custom_field) } + let!(:project) do + create(:project, custom_field_values: { "#{project_custom_field.id}": "foo" }) + end + + it 'deletes the project custom field mappings' do + expect(ProjectCustomFieldProjectMapping).to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + + project_custom_field.destroy + + expect(ProjectCustomFieldProjectMapping).not_to exist(custom_field_id: project_custom_field.id, + project_id: project.id) + end + end + end +end diff --git a/spec/models/projects/customizable_spec.rb b/spec/models/projects/customizable_spec.rb index ce203305ace6..6dc4d64fe222 100644 --- a/spec/models/projects/customizable_spec.rb +++ b/spec/models/projects/customizable_spec.rb @@ -26,93 +26,448 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" +RSpec.describe Project, "customizable" do + let!(:section) { create(:project_custom_field_section) } -RSpec.describe Project, 'customizable' do - let(:project) do - build_stubbed(:project, - custom_values:) + let!(:bool_custom_field) do + create(:boolean_project_custom_field, project_custom_field_section: section) end - let(:stub_available_custom_fields) do - custom_fields_stub = double('custom fields stub') - allow(CustomField) - .to receive(:where) - .with(type: "ProjectCustomField") - .and_return(custom_fields_stub) - - allow(custom_fields_stub) - .to receive(:order) - .with(:position) - .and_return(available_custom_fields) + let!(:text_custom_field) do + create(:text_project_custom_field, project_custom_field_section: section) end - let(:custom_values) { [] } - let(:bool_custom_field) { build_stubbed(:boolean_project_custom_field) } - let(:text_custom_field) { build_stubbed(:text_project_custom_field) } - let(:list_custom_field) { build_stubbed(:list_project_custom_field) } + let!(:list_custom_field) do + create(:list_project_custom_field, project_custom_field_section: section) + end + + context "when not persisted" do + let(:project) { build(:project) } - before do - stub_available_custom_fields + describe "#available_custom_fields" do + it "returns all existing project custom fields as available custom fields" do + expect(project.project_custom_field_project_mappings) + .to be_empty + expect(project.project_custom_fields) + .to be_empty + # but: + expect(project.available_custom_fields) + .to contain_exactly(bool_custom_field, text_custom_field, list_custom_field) + end + end end - describe '#custom_value_for' do - subject { project.custom_value_for(custom_field) } + context "when persisted" do + let(:project) { create(:project) } - context 'for a boolean custom field' do - let(:custom_field) { bool_custom_field } - let(:available_custom_fields) { [custom_field] } + describe "#available_custom_fields" do + it "returns only mapped project custom fields as available custom fields" do + expect(project.project_custom_field_project_mappings) + .to be_empty + expect(project.project_custom_fields) + .to be_empty + # and thus: + expect(project.available_custom_fields) + .to be_empty - context 'with no value set' do - it 'returns a custom value' do - expect(subject) - .to be_present - end + project.project_custom_fields << bool_custom_field - it 'is unpersisted' do - expect(subject) - .to be_new_record - end + expect(project.available_custom_fields) + .to contain_exactly(bool_custom_field) + end + end - it 'has nil as its value' do - expect(subject.value) + describe "#custom_field_values and #custom_value_for" do + context "when no custom fields are mapped to this project" do + it "#custom_value_for returns nil" do + expect(project.custom_value_for(text_custom_field)) .to be_nil + expect(project.custom_value_for(bool_custom_field)) + .to be_nil + expect(project.custom_value_for(list_custom_field)) + .to be_nil + end + + it "#custom_field_values returns an empty hash" do + expect(project.custom_field_values) + .to be_empty end end - context 'with a value set' do - let(:custom_value) do - build_stubbed(:custom_value, - custom_field:, - value: true) + context "when custom fields are mapped to this project" do + before do + project.project_custom_fields << [text_custom_field, bool_custom_field] + end + + it "#custom_field_values returns a hash of mapped custom fields with nil values" do + text_custom_field_custom_field_value = project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == text_custom_field.id + end + + expect(text_custom_field_custom_field_value).to be_present + expect(text_custom_field_custom_field_value.value).to be_nil + + bool_custom_field_custom_field_value = project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == bool_custom_field.id + end + + expect(bool_custom_field_custom_field_value).to be_present + expect(bool_custom_field_custom_field_value.value).to be_nil end - let(:custom_values) { [custom_value] } - it 'returns the custom value' do - expect(subject) - .to eql custom_value + context "when values are set for mapped custom fields" do + before do + project.custom_field_values = { + text_custom_field.id => "foo", + bool_custom_field.id => true + } + end + + it "#custom_value_for returns the set custom values" do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq("foo") + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + expect(project.custom_value_for(list_custom_field).typed_value) + .to be_nil + end + + it "#custom_field_values returns a hash of mapped custom fields with their set values" do + expect(project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == text_custom_field.id + end.typed_value) + .to eq("foo") + + expect(project.custom_field_values.find do |custom_value| + custom_value.custom_field_id == bool_custom_field.id + end.typed_value) + .to be_truthy + end end end end end - describe '#custom_value_attributes' do - let(:available_custom_fields) { [bool_custom_field, list_custom_field, text_custom_field] } - let(:text_custom_value) do - build_stubbed(:custom_value, - custom_field: text_custom_field, - value: 'blubs') + context "when creating with custom field values" do + let(:project) do + create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + end + + it "saves the custom field values properly" do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq("foo") + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + end + + it "enables fields with provided values and disables fields with none" do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + context "with correct validation" do + let(:another_section) { create(:project_custom_field_section) } + + let!(:required_text_custom_field) do + create(:text_project_custom_field, + is_required: true, + project_custom_field_section: another_section) + end + + it "validates all custom values if not scoped to a section" do + project = build(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + + expect(project).not_to be_valid + + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + + it "rejects section validation scoping for project creation" do + project = build(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }, + _limit_custom_fields_validation_to_section_id: section.id) + + expect { project.save! }.to raise_error(ArgumentError) + end + + it "temporarly validates only custom values of a section if section scope is provided while updating" do + project = create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true, + required_text_custom_field.id => "bar" + }) + + expect(project).to be_valid + + # after a project is created, a new required custom field is added + # which gets automatically activated for all projects + create(:text_project_custom_field, + is_required: true, + project_custom_field_section: another_section) + + # thus, the project is invalid in total + expect(project.reload).not_to be_valid + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + + # but we still want to allow updating other sections without invalid required custom field values + # by limiting the validation scope to a section temporarily + project._limit_custom_fields_validation_to_section_id = section.id + + expect(project).to be_valid + + expect { project.save! }.not_to raise_error + + # section scope is resetted after each update + expect { project.save! }.to raise_error(ActiveRecord::RecordInvalid) + end + end + + context "with correct handling of custom fields with default values" do + let!(:text_custom_field_with_default) do + create(:text_project_custom_field, + default_value: "default", + project_custom_field_section: section) + end + + it "activates custom fields with default values if not explicitly set to blank" do + project = create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, text_custom_field_with_default.id) + end + + it "does not activate custom fields with default values if explicitly set to blank" do + project = create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true, + text_custom_field_with_default.id => "" + }) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + end + end + end + + context "when updating with custom field values" do + let!(:project) { create(:project) } + + shared_examples "implicitly enabled and saved custom values" do + it "enables fields with provided values" do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + it "saves the custom field values properly" do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq("foo") + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + end + end + + context "with #update method" do + before do + project.update(custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + end + + it_behaves_like "implicitly enabled and saved custom values" + end + + context "with #update! method" do + before do + project.update!(custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + end + + it_behaves_like "implicitly enabled and saved custom values" + end + + context "with #custom_field_values= method" do + before do + project.custom_field_values = { + text_custom_field.id => "foo", + bool_custom_field.id => true + } + + project.save! + end + + it_behaves_like "implicitly enabled and saved custom values" + end + + it "does not re-enable fields without new value which have been disabled in the past (regression)" do + project.update!(custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + project.project_custom_field_project_mappings.find_by(custom_field_id: text_custom_field.id).destroy + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + + project.update!(custom_field_values: { + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + end + + context "with correct handling of custom fields with default values" do + let!(:text_custom_field_with_default) do + create(:text_project_custom_field, + default_value: "default", + project_custom_field_section: section) + end + + it "does not activate custom fields with default values if not explicitly set to a value" do + project.update!(custom_field_values: { + text_custom_field.id => "bar", + bool_custom_field.id => false + }) + + # text_custom_field_with_default is not provided, thus it should not be enabled (in contrast to creation) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + end + + it "does activate custom fields with default values if explicitly set to a value" do + project.update!(custom_field_values: { + text_custom_field.id => "bar", + bool_custom_field.id => false, + text_custom_field_with_default.id => "overwritten default" + }) + + # text_custom_field_with_default is not provided, thus it should not be enabled (in contrast to creation) + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, text_custom_field_with_default.id) + end + end + + it "does re-enable fields with new value which have been disabled in the past" do + project.update!(custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + project.project_custom_field_project_mappings.find_by(custom_field_id: text_custom_field.id).destroy + + expect(project.reload.project_custom_fields) + .to contain_exactly(bool_custom_field) + + project.update!(custom_field_values: { + text_custom_field.id => "bar" + }) + + expect(project.reload.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq("bar") + end + end + + context "when updating with custom field setter methods (API approach)" do + let(:project) { create(:project) } + + shared_examples "implicitly enabled and saved custom values" do + it "enables fields with provided values" do + # list_custom_field is not provided, thus it should not be enabled + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + expect(project.project_custom_fields) + .to contain_exactly(text_custom_field, bool_custom_field) + end + + it "saves the custom field values properly" do + expect(project.custom_value_for(text_custom_field).typed_value) + .to eq("foo") + expect(project.custom_value_for(bool_custom_field).typed_value) + .to be_truthy + + # or via getter methods: + + expect(project.send(:"custom_field_#{text_custom_field.id}")).to eq("foo") + expect(project.send(:"custom_field_#{bool_custom_field.id}")).to be_truthy + end + end + + context "when setting a value for a disabled custom field" do + before do + project.send(:"custom_field_#{text_custom_field.id}=", "foo") + project.send(:"custom_field_#{bool_custom_field.id}=", true) + project.save! + end + + it_behaves_like "implicitly enabled and saved custom values" + end + end + + context "with hidden custom fields" do + let!(:hidden_custom_field) do + create(:text_project_custom_field, project_custom_field_section: section, visible: false) end - let(:bool_custom_value) do - build_stubbed(:custom_value, - custom_field: bool_custom_field, - value: true) + let(:project) do + create(:project, custom_field_values: { + text_custom_field.id => "foo", + bool_custom_field.id => true, + hidden_custom_field.id => "hidden" + }) + end + + before do + User.current = user # needs to be executed before project creation! end - let(:custom_values) { [bool_custom_value, text_custom_value] } - subject { project.custom_value_attributes } + context "with admin permission" do + let(:user) { create(:admin) } - it 'returns a hash with all the custom values available' do - expect(subject) - .to eql(text_custom_field.id => 'blubs', bool_custom_field.id => 't', list_custom_field.id => nil) + it "does activate hidden custom fields" do + # project creation happens with an admin user as let(:project) called after setting the current user to an admin + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id, hidden_custom_field.id) + + expect(project.custom_value_for(hidden_custom_field).typed_value).to eq("hidden") + end + end + + context "without admin permission" do + let(:user) { create(:user) } + + it "does not activate hidden custom fields" do + # project creation happens with an non-admin user as let(:project) called after setting the current user to an non-admin + expect(project.project_custom_field_project_mappings.pluck(:custom_field_id)) + .to contain_exactly(text_custom_field.id, bool_custom_field.id) + + expect(project.custom_value_for(hidden_custom_field)).to be_nil + end end end end diff --git a/spec/models/projects/exporter/csv_integration_spec.rb b/spec/models/projects/exporter/csv_integration_spec.rb index 325c1a363014..464e77efa57d 100644 --- a/spec/models/projects/exporter/csv_integration_spec.rb +++ b/spec/models/projects/exporter/csv_integration_spec.rb @@ -26,12 +26,12 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' -require_relative 'exportable_project_context' +require "spec_helper" +require_relative "exportable_project_context" -RSpec.describe Projects::Exports::CSV, 'integration' do - include_context 'with a project with an arrangement of custom fields' - include_context 'with an instance of the described exporter' +RSpec.describe Projects::Exports::CSV, "integration" do + include_context "with a project with an arrangement of custom fields" + include_context "with an instance of the described exporter" let(:parsed) do CSV.parse(output) @@ -41,62 +41,112 @@ let(:rows) { parsed.drop(1) } - it 'performs a successful export' do + it "performs a successful export" do expect(parsed.size).to eq(2) expect(parsed.last).to eq [project.id.to_s, project.identifier, - project.name, project.description, 'Off track', 'false'] + project.name, project.description, "Off track", "false"] end - context 'with status_explanation enabled' do + context "with status_explanation enabled" do let(:query_columns) { %w[name description project_status status_explanation public] } - it 'performs a successful export' do + it "performs a successful export" do expect(parsed.size).to eq(2) expect(parsed.last).to eq [project.id.to_s, project.identifier, project.name, project.description, - 'Off track', 'some explanation', 'false'] + "Off track", "some explanation", "false"] end end - describe 'custom field columns selected' do + describe "custom field columns selected" do let(:query_columns) do - %w[name description project_status public] + custom_fields.map(&:column_name) + %w[name description project_status public] + global_project_custom_fields.map(&:column_name) end - context 'when ee enabled', with_ee: %i[custom_fields_in_projects_list] do - it 'renders all those columns' do - expect(parsed.size).to eq 2 - - cf_names = custom_fields.map(&:name) - expect(header).to eq ['id', 'Identifier', 'Name', 'Description', 'Status', 'Public', *cf_names] - - custom_values = custom_fields.map do |cf| - case cf - when bool_cf - 'true' - when text_cf - project.typed_custom_value_for(cf) - else - project.formatted_custom_value_for(cf) + context "when ee enabled", with_ee: %i[custom_fields_in_projects_list] do + before do + project # re-evaluate project to ensure it is created within the desired user context + parsed + end + + context "without admin permission" do + it "renders all visible globally available project custom fields in the header" do + expect(parsed.size).to eq 2 + + cf_names = global_project_custom_fields.map(&:name) + + expect(cf_names).to include(not_used_string_cf.name) + expect(cf_names).not_to include(hidden_cf.name) + + expect(header).to eq ["id", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + end + + it "renders the custom field values in the rows if enabled for a project" do + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when text_cf + project.typed_custom_value_for(cf) + when not_used_string_cf + "" + else + project.formatted_custom_value_for(cf) + end + end + expect(rows.first) + .to eq [project.id.to_s, project.identifier, project.name, + project.description, "Off track", "false", *custom_values] + end + end + + context "with admin permission" do + let(:current_user) { create(:admin) } + + it "renders all globally available project custom fields including hidden ones in the header" do + expect(parsed.size).to eq 3 + + cf_names = global_project_custom_fields.map(&:name) + + expect(cf_names).to include(not_used_string_cf.name) + expect(cf_names).to include(hidden_cf.name) + + expect(header).to eq ["id", "Identifier", "Name", "Description", "Status", "Public", *cf_names] + end + + it "renders the custom field values in the rows if enabled for a project" do + custom_values = global_project_custom_fields.map do |cf| + case cf + when bool_cf + "true" + when hidden_cf + "hidden" + when not_used_string_cf + "" + when text_cf + project.typed_custom_value_for(cf) + else + project.formatted_custom_value_for(cf) + end end + expect(rows.first) + .to eq [project.id.to_s, project.identifier, project.name, + project.description, "Off track", "false", *custom_values] end - expect(rows.first) - .to eq [project.id.to_s, project.identifier, project.name, - project.description, 'Off track', 'false', *custom_values] end end - context 'when ee not enabled' do - it 'renders only the default columns' do + context "when ee not enabled" do + it "renders only the default columns" do expect(header).to eq %w[id Identifier Name Description Status Public] end end end - context 'with no project visible' do + context "with no project visible" do let(:current_user) { User.anonymous } - it 'does not include the project' do + it "does not include the project" do expect(output).not_to include project.identifier expect(parsed.size).to eq(1) end diff --git a/spec/models/projects/exporter/exportable_project_context.rb b/spec/models/projects/exporter/exportable_project_context.rb index 68151bf381c7..77960e542566 100644 --- a/spec/models/projects/exporter/exportable_project_context.rb +++ b/spec/models/projects/exporter/exportable_project_context.rb @@ -26,7 +26,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -RSpec.shared_context 'with a project with an arrangement of custom fields' do +RSpec.shared_context "with a project with an arrangement of custom fields" do shared_let(:version_cf) { create(:version_project_custom_field, position: 1) } shared_let(:bool_cf) { create(:boolean_project_custom_field, position: 2) } shared_let(:user_cf) { create(:user_project_custom_field, position: 3) } @@ -35,8 +35,11 @@ shared_let(:text_cf) { create(:text_project_custom_field, position: 6) } shared_let(:string_cf) { create(:string_project_custom_field, position: 7) } shared_let(:date_cf) { create(:date_project_custom_field, position: 8) } + shared_let(:hidden_cf) { create(:string_project_custom_field, position: 9, visible: false) } - shared_let(:system_version) { create(:version, sharing: 'system') } + let!(:not_used_string_cf) { create(:string_project_custom_field, position: 10) } + + shared_let(:system_version) { create(:version, sharing: "system") } shared_let(:role) do create(:project_role) @@ -44,31 +47,36 @@ shared_let(:other_user) do create(:user, - firstname: 'Other', - lastname: 'User') + firstname: "Other", + lastname: "User") end - shared_let(:project) do - create(:project, - status_code: 'off_track', - status_explanation: 'some explanation', - members: { other_user => role }).tap do |p| - p.description = "The description of the project" - p.send(int_cf.attribute_setter, 5) - p.send(bool_cf.attribute_setter, true) - p.send(version_cf.attribute_setter, system_version) - p.send(float_cf.attribute_setter, 4.5) - p.send(text_cf.attribute_setter, 'Some **long** text') - p.send(string_cf.attribute_setter, 'Some small text') - p.send(date_cf.attribute_setter, Time.zone.today) - p.send(user_cf.attribute_setter, other_user) + # project needs to be reevaluted before every example as the creation behaves differently from different user contexts + # shared_let cannot be used here as it would create the project only once + let(:project) do + project = build(:project, + status_code: "off_track", + status_explanation: "some explanation", + members: { other_user => role }, + description: "The description of the project", + custom_field_values: { + int_cf.id => 5, + bool_cf.id => true, + version_cf.id => system_version, + float_cf.id => 4.5, + text_cf.id => "Some **long** text", + string_cf.id => "Some small text", + date_cf.id => Time.zone.today, + user_cf.id => other_user.id, + hidden_cf.id => "hidden" + }) + project.save!(validate: false) - p.save!(validate: false) - end + project end end -RSpec.shared_context 'with an instance of the described exporter' do +RSpec.shared_context "with an instance of the described exporter" do before do login_as current_user end @@ -85,7 +93,8 @@ described_class.new(query) end - let(:custom_fields) { project.available_custom_fields } + let(:global_project_custom_fields) { ProjectCustomField.visible } + let(:custom_fields_of_project) { project.available_custom_fields } let(:output) do instance.export!.content diff --git a/spec/models/queries/projects/factory_spec.rb b/spec/models/queries/projects/factory_spec.rb index bea1ddad631a..e43930ea6642 100644 --- a/spec/models/queries/projects/factory_spec.rb +++ b/spec/models/queries/projects/factory_spec.rb @@ -50,6 +50,24 @@ query.select(:project_status, :name, :created_at) end end + let(:custom_field) do + build_stubbed(:project_custom_field, id: 1) do |cf| + scope = instance_double(ActiveRecord::Relation) + + allow(ProjectCustomField) + .to receive(:visible) + .and_return(scope) + + allow(scope) + .to receive(:find_by) + .and_return nil + + allow(scope) + .to receive(:find_by) + .with(id: cf.id.to_s) + .and_return(cf) + end + end let(:id) { nil } let(:params) { {} } @@ -80,7 +98,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -91,7 +109,11 @@ with_settings: { enabled_projects_columns: %w[name created_at cf_1] } do current_user { build_stubbed(:admin) } - it "has the enabled project columns columns as selects" do + before do + custom_field + end + + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -120,7 +142,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -149,7 +171,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -178,7 +200,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -207,7 +229,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -236,7 +258,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -265,7 +287,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -383,7 +405,7 @@ .to eq([["id", :asc], ["name", :desc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -428,7 +450,7 @@ .to eq([%i[lft asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -625,6 +647,19 @@ .to be_nil end end + + context "without id, as non admin and with a non existing custom field id", + with_ee: %i[custom_fields_in_projects_list], + with_settings: { enabled_projects_columns: %w[name created_at cf_1 cf_42] } do + before do + custom_field + end + + it "has only the available fields (non admin only and only existing cf)" do + expect(find.selects.map(&:attribute)) + .to eq(%i[name cf_1]) # rubocop:disable Naming/VariableNumber + end + end end describe ".static_query_active" do @@ -650,7 +685,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -679,7 +714,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -708,7 +743,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -737,7 +772,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -766,7 +801,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end @@ -795,7 +830,7 @@ .to eq([["lft", :asc]]) end - it "has the enabled project columns columns as selects" do + it "has the enabled_project_columns columns as selects" do expect(find.selects.map(&:attribute)) .to eq(Setting.enabled_projects_columns.map(&:to_sym)) end diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb index 9bbcb97d49f5..121b8bdce4fc 100644 --- a/spec/models/user_spec.rb +++ b/spec/models/user_spec.rb @@ -143,6 +143,18 @@ end end + context 'with other letter char classes' do + let(:login) { "célîneüberölig" } + + it 'is valid' do + expect(user).to be_valid + end + + it 'may be stored in the database' do + expect(user.save).to be_truthy + end + end + context "with tabs" do let(:login) { 'ab\tc' } @@ -172,7 +184,7 @@ end context "with combination thereof" do - let(:login) { "the+boss-is@the_house." } + let(:login) { "the+boss-is-über@the_house." } it "is valid" do expect(user).to be_valid diff --git a/spec/permissions/manage_project_custom_field_mappings_spec.rb b/spec/permissions/manage_project_custom_field_mappings_spec.rb new file mode 100644 index 000000000000..5920cf562918 --- /dev/null +++ b/spec/permissions/manage_project_custom_field_mappings_spec.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. +#++ + +require 'spec_helper' +require File.expand_path('../support/permission_specs', __dir__) + +RSpec.describe Projects::Settings::ProjectCustomFieldsController, 'manage_project_custom_field mappings permission', + type: :controller do + include PermissionSpecs + + check_permission_required_for('projects/settings/project_custom_fields#show', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#toggle', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#enable_all_of_section', :select_project_custom_fields) + check_permission_required_for('projects/settings/project_custom_fields#disable_all_of_section', :select_project_custom_fields) +end diff --git a/spec/permissions/manage_project_custom_values_spec.rb b/spec/permissions/manage_project_custom_values_spec.rb new file mode 100644 index 000000000000..4aa31a852cf7 --- /dev/null +++ b/spec/permissions/manage_project_custom_values_spec.rb @@ -0,0 +1,50 @@ +#-- 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 File.expand_path('../support/permission_specs', __dir__) + +RSpec.describe Overviews::OverviewsController, 'manage_project_custom_values permission', + type: :controller do + include PermissionSpecs + + # render sidebar on project overview page with view_project permission + # TODO: prevents calling overviews/overviews#project_custom_fields_sidebar when not having the permission view_project (FAILED - 1) + it 'prevents calling overviews/overviews#project_custom_fields_sidebar when not having the permission view_project' do + pending 'spec failing, reason unknown' + # wrapping the check_permission_required_for in a block to mark it as pending + # spec should not be executed in this block, but standalone, will fail then with real error + check_permission_required_for('overviews/overviews#project_custom_fields_sidebar', :view_project) + end + + # render dialog with inputs for editing project attributes with edit_project permission + check_permission_required_for('overviews/overviews#project_custom_field_section_dialog', :edit_project) + + # update project attributes with edit_project permission, deeper permission check via contract in place + check_permission_required_for('overviews/overviews#update_project_custom_values', :edit_project) +end diff --git a/spec/requests/api/v3/projects/create_resource_spec.rb b/spec/requests/api/v3/projects/create_resource_spec.rb index 67df535c2ec3..0ff82ecfcbd0 100644 --- a/spec/requests/api/v3/projects/create_resource_spec.rb +++ b/spec/requests/api/v3/projects/create_resource_spec.rb @@ -36,6 +36,9 @@ let(:custom_field) do create(:text_project_custom_field) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end let(:custom_value) do CustomValue.create(custom_field:, value: '1234', @@ -113,7 +116,7 @@ end end - context 'with a custom field' do + context 'with a visible custom field' do let(:body) do { identifier: 'new_project_identifier', @@ -129,6 +132,50 @@ .to be_json_eql("CF text".to_json) .at_path("customField#{custom_field.id}/raw") end + + it 'automatically activates the cf for project if the value was provided' do + expect(Project.last.project_custom_fields) + .to contain_exactly(custom_field) + end + end + + context 'with an invisible custom field' do + let(:body) do + { + identifier: 'new_project_identifier', + name: 'Project name', + invisible_custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + }.to_json + end + + context 'with admin permissions' do + current_user { create(:admin) } + + it 'sets the cf value' do + expect(last_response.body) + .to be_json_eql("CF text".to_json) + .at_path("customField#{invisible_custom_field.id}/raw") + end + + it 'automatically activates the cf for project if the value was provided' do + expect(Project.last.project_custom_fields) + .to contain_exactly(invisible_custom_field) + end + end + + context 'with non-admin permissions' do + it 'does not set the cf value' do + expect(last_response.body) + .not_to have_json_path("customField#{invisible_custom_field.id}/raw") + end + + it 'does not activate the cf for project' do + expect(Project.last.project_custom_fields) + .to be_empty + end + end end context 'without permission to create projects' do diff --git a/spec/requests/api/v3/projects/show_resource_spec.rb b/spec/requests/api/v3/projects/show_resource_spec.rb index a27fd4958c87..7fe2ac10cab9 100644 --- a/spec/requests/api/v3/projects/show_resource_spec.rb +++ b/spec/requests/api/v3/projects/show_resource_spec.rb @@ -53,6 +53,14 @@ value: '1234', customized: project) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end + let(:invisible_custom_value) do + CustomValue.create(custom_field: invisible_custom_field, + value: '1234', + customized: project) + end let(:get_path) { api_v3_paths.project project.id } let!(:parent_project) do @@ -96,12 +104,33 @@ .at_path('_links/ancestors/0/href') end - it 'includes custom fields' do + it 'includes only visible custom fields' do custom_value + invisible_custom_value expect(subject.body) .to be_json_eql(custom_value.value.to_json) .at_path("customField#{custom_field.id}/raw") + + expect(subject.body) + .not_to have_json_path("customField#{invisible_custom_field.id}/raw") + end + + context 'with admin permissions' do + current_user { admin } + + it 'includes invisible custom fields' do + custom_value + invisible_custom_value + + expect(subject.body) + .to be_json_eql(custom_value.value.to_json) + .at_path("customField#{custom_field.id}/raw") + + expect(subject.body) + .to be_json_eql(invisible_custom_value.value.to_json) + .at_path("customField#{invisible_custom_field.id}/raw") + end end it 'includes the project status' do diff --git a/spec/requests/api/v3/projects/update_resource_spec.rb b/spec/requests/api/v3/projects/update_resource_spec.rb index 23df8bc06406..fe1a05c6b21f 100644 --- a/spec/requests/api/v3/projects/update_resource_spec.rb +++ b/spec/requests/api/v3/projects/update_resource_spec.rb @@ -44,6 +44,9 @@ let(:custom_field) do create(:text_project_custom_field) end + let(:invisible_custom_field) do + create(:text_project_custom_field, visible: false) + end let(:custom_value) do CustomValue.create(custom_field:, value: '1234', @@ -89,7 +92,7 @@ .at_path('name') end - context 'with a custom field' do + context 'with a visible custom field' do let(:body) do { custom_field.attribute_name(:camel_case) => { @@ -106,6 +109,56 @@ expect(project.reload.send(custom_field.attribute_getter)) .to eql("CF text") end + + it 'automatically activates the cf for project if the value was provided' do + expect(project.project_custom_fields) + .to contain_exactly(custom_field) + end + end + + context 'with an invisible custom field' do + let(:body) do + { + invisible_custom_field.attribute_name(:camel_case) => { + raw: "CF text" + } + } + end + + context 'with admin permissions' do + let(:current_user) { create(:admin) } + + it 'responds with 200 OK' do + expect(last_response.status).to eq(200) + end + + it 'sets the cf value' do + expect(project.reload.send(invisible_custom_field.attribute_getter)) + .to eql("CF text") + end + + it 'automatically activates the cf for project if the value was provided' do + expect(project.reload.project_custom_fields) + .to contain_exactly(invisible_custom_field) + end + end + + context 'with non-admin permissions' do + it 'responds with 200 OK' do + # TBD: trying to set a not accessible custom field is silently ignored + expect(last_response.status).to eq(200) + end + + it 'does not set the cf value' do + expect(project.reload.custom_values) + .to be_empty + end + + it 'does not activate the cf for project' do + expect(project.reload.project_custom_fields) + .to be_empty + end + end end context 'without permission to patch projects' do diff --git a/spec/requests/auth/ldap_sso_spec.rb b/spec/requests/auth/ldap_sso_spec.rb index 5d2858a013a6..d1367a9fc0c9 100644 --- a/spec/requests/auth/ldap_sso_spec.rb +++ b/spec/requests/auth/ldap_sso_spec.rb @@ -58,6 +58,23 @@ expect(subject).to redirect_to "/?first_time_user=true" end + context 'with a user that has umlauts in their name' do + let(:username) { 'bölle' } + let(:password) { 'bólle' } + + it 'creates a user with umlauts on the fly' do + expect(User.find_by(login: 'bölle')).to be_nil + + expect { subject }.to change(User.not_builtin.active, :count).by(1) + + user = User.find_by(login: 'bölle') + expect(user).to be_present + expect(user).to be_active + expect(session[:user_id]).to eq user.id + expect(subject).to redirect_to '/?first_time_user=true' + end + end + context "when not all attributes present" do let(:attr_mail) { nil } diff --git a/spec/requests/oauth_clients/ensure_connection_flow_spec.rb b/spec/requests/oauth_clients/ensure_connection_flow_spec.rb index fcc3b3fc854e..eced9e187b6e 100644 --- a/spec/requests/oauth_clients/ensure_connection_flow_spec.rb +++ b/spec/requests/oauth_clients/ensure_connection_flow_spec.rb @@ -27,13 +27,20 @@ #++ require "spec_helper" -require_module_spec_helper +require_relative "../../../modules/storages/spec/spec_helper" RSpec.describe "/oauth_clients/:oauth_client_id/ensure_connection endpoint", :webmock do shared_let(:user) { create(:user) } shared_let(:storage) { create(:nextcloud_storage, :with_oauth_client) } shared_let(:oauth_client) { storage.oauth_client } + before do + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.success } + ) + end + describe "#ensure_connection" do context "when user is not logged in" do it "requires login" do @@ -52,10 +59,15 @@ end context "when storage_id parameter is present" do - context 'when user is not "connected"' do + context "when user is not 'connected'" do let(:nonce) { "57a17c3f-b2ed-446e-9dd8-651ba3aec37d" } before do + Storages::Peripherals::Registry.stub( + "#{storage.short_provider_type}.queries.auth_check", + ->(_) { ServiceResult.failure(errors: Storages::StorageError.new(code: :unauthorized)) } + ) + allow(SecureRandom).to receive(:uuid).and_call_original.ordered allow(SecureRandom).to receive(:uuid).and_return(nonce).ordered end @@ -72,7 +84,9 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end @@ -91,7 +105,9 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F123%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F123%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end @@ -109,13 +125,15 @@ "%2Foauth_clients%2F#{oauth_client.client_id}%2F" \ "callback&response_type=code&state=#{nonce}" ) - expect(last_response.cookies["oauth_state_#{nonce}"]).to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) + expect(last_response.cookies["oauth_state_#{nonce}"]) + .to eq(["%7B%22href%22%3A%22http%3A%2F%2Fwww.example.com" \ + "%2F%22%2C%22storageId%22%3A%22#{storage.id}%22%7D"]) end end end end - context 'when user is "connected"' do + context "when user is 'connected'" do let!(:oauth_client_token) do create(:oauth_client_token, oauth_client:, user:) end diff --git a/spec/services/oauth_clients/connection_manager_spec.rb b/spec/services/oauth_clients/connection_manager_spec.rb index b89a424a7196..a707b7ee3ef7 100644 --- a/spec/services/oauth_clients/connection_manager_spec.rb +++ b/spec/services/oauth_clients/connection_manager_spec.rb @@ -28,7 +28,7 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" RSpec.describe OAuthClients::ConnectionManager, :webmock, type: :model do using Storages::Peripherals::ServiceResultRefinements @@ -45,78 +45,24 @@ let(:instance) { described_class.new(user:, configuration:) } - # The get_authorization_uri method returns the OAuth2 authorization URI as a string. That URI is the starting point for - # a user to grant OpenProject access to Nextcloud. - describe '#get_authorization_uri' do - let(:scope) { nil } - let(:state) { nil } - - subject { instance.get_authorization_uri(state:) } - - context 'with empty state and scope' do - shared_examples_for 'returns the authorization URI relative to the host' do - it 'returns the authorization URI' do - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "scope" - expect(subject).not_to include "state" - end - end - - context 'when Nextcloud is installed in the server root' do - it_behaves_like 'returns the authorization URI relative to the host' - end - - context 'when Nextcloud is installed in a sub-directory' do - let(:host) { "https://example.org/nextcloud" } - - it_behaves_like 'returns the authorization URI relative to the host' - end - end - - context 'with state but empty scope' do - let(:state) { "https://example.com/page" } - - it 'returns the redirect URL' do - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "scope" - expect(subject).to include "&state=https" - end - end - - context 'with multiple scopes but empty state' do - let(:scope) { %i(email profile) } - - it 'returns the redirect URL' do - allow(configuration).to receive(:scope).and_return(scope) - - expect(subject).to be_a String - expect(subject).to include oauth_client.integration.host - expect(subject).not_to include "state" - expect(subject).to include "&scope=email%20profile" - end - end - end - # The first step in the OAuth2 flow is to produce a URL for the # user to authenticate and authorize access at the OAuth2 provider # (Nextcloud). - describe '#get_access_token' do + describe "#get_access_token" do subject { instance.get_access_token } - context 'with no OAuthClientToken present' do - it 'returns a redirection URL' do + context "with no OAuthClientToken present" do + it "returns a redirection URL" do expect(subject.success).to be_falsy expect(subject.result).to be_a String # Details of string are tested above in section #get_authorization_uri end end - context 'with no OAuthClientToken present and state parameters' do + context "with no OAuthClientToken present and state parameters" do subject { instance.get_access_token(state: "some_state") } - it 'returns the redirect URL' do + it "returns the redirect URL" do allow(configuration).to receive(:scope).and_return(%w[email]) expect(subject.success).to be_falsy @@ -127,12 +73,12 @@ end end - context 'with an OAuthClientToken present' do + context "with an OAuthClientToken present" do before do oauth_client_token end - it 'returns the OAuthClientToken' do + it "returns the OAuthClientToken" do expect(subject).to be_truthy expect(subject.result).to be_a OAuthClientToken # The one and only... expect(subject.result).to eql oauth_client_token @@ -147,12 +93,12 @@ # The callback endpoint calls `code_to_token(code)` with the code # received and exchanges the code for a bearer+refresh token # using a HTTP request. - describe '#code_to_token', :webmock do + describe "#code_to_token", :webmock do let(:code) { "7kRGJ...jG3KZ" } subject { instance.code_to_token(code) } - context 'with happy path' do + context "with happy path" do before do # Simulate a successful authorization returning the tokens response_body = { @@ -162,11 +108,11 @@ refresh_token: "UwFp...1FROJ", user_id: "admin" }.to_json - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns a valid ClientToken object and issues an appropriate event' do + it "returns a valid ClientToken object and issues an appropriate event" do expect(OpenProject::Notifications) .to(receive(:send)) .with(OpenProject::Events::OAUTH_CLIENT_TOKEN_CREATED, integration_type: "Storages::Storage") @@ -174,156 +120,156 @@ expect(subject.result).to be_a OAuthClientToken end - it 'fills in the origin_user_id' do + it "fills in the origin_user_id" do expect { subject }.to change(OAuthClientToken, :count).by(1) - last_token = OAuthClientToken.where(access_token: 'yjTDZ...RYvRH').last + last_token = OAuthClientToken.where(access_token: "yjTDZ...RYvRH").last - expect(last_token.origin_user_id).to eq('admin') + expect(last_token.origin_user_id).to eq("admin") end end - context 'with known error' do + context "with known error" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: error_message }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - shared_examples 'OAuth2 error response' do - it 'returns a specific error message' do + shared_examples "OAuth2 error response" do + it "returns a specific error message" do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) expect(subject.error_payload[:error]).to eq(error_message) end end - context 'when "invalid_request"' do - let(:error_message) { 'invalid_request' } + context "when 'invalid_request'" do + let(:error_message) { "invalid_request" } - it_behaves_like 'OAuth2 error response' + it_behaves_like "OAuth2 error response" end - context 'when "invalid_grant"' do - let(:error_message) { 'invalid_grant' } + context "when 'invalid_grant'" do + let(:error_message) { "invalid_grant" } - it_behaves_like 'OAuth2 error response' + it_behaves_like "OAuth2 error response" end end - context 'with unknown reply' do + context "with unknown reply" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: "invalid_requesttt" }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns an error wrapping the unknown response' do + it "returns an error wrapping the unknown response" do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) - expect(subject.error_payload[:error]).to eq('invalid_requesttt') + expect(subject.error_payload[:error]).to eq("invalid_requesttt") expect(subject.error_source).to be_a(described_class) - expect(subject.errors.log_message).to include I18n.t('oauth_client.errors.oauth_returned_error') + expect(subject.errors.log_message).to include I18n.t("oauth_client.errors.oauth_returned_error") end end - context 'with reply including JSON syntax error' do + context "with reply including JSON syntax error" do before do - stub_request(:post, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:post, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return( status: 400, - headers: { 'Content-Type' => 'application/json; charset=utf-8' }, + headers: { "Content-Type" => "application/json; charset=utf-8" }, body: "some: very, invalid> "application/json; charset=utf-8" }) end - it 'returns a valid ClientToken object', :webmock do + it "returns a valid ClientToken object", :webmock do expect(subject.success).to be_truthy expect(subject.result).to be_a OAuthClientToken expect(subject.result.access_token).to eq("xyjTDZ...RYvRH") @@ -346,7 +292,7 @@ end end - context 'with invalid access_token data' do + context "with invalid access_token data" do before do # Simulate a token too long response_body = { @@ -356,11 +302,11 @@ refresh_token: "xUwFp...1FROJ", user_id: "admin" }.to_json - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 200, body: response_body, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns dependent error from model validation', :webmock do + it "returns dependent error from model validation", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:error) expect(subject.error_payload.class).to be(AttrRequired::AttrMissing) @@ -368,38 +314,38 @@ end end - context 'with server error from OAuth2 provider' do + context "with server error from OAuth2 provider" do before do - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_return(status: 400, body: { error: "invalid_request" }.to_json, headers: { "content-type" => "application/json; charset=utf-8" }) end - it 'returns a server error', :webmock do + it "returns a server error", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:bad_request) - expect(subject.error_payload[:error]).to eq('invalid_request') + expect(subject.error_payload[:error]).to eq("invalid_request") end end - context 'with successful response but invalid data' do + context "with successful response but invalid data" do before do # Simulate timeout - stub_request(:any, File.join(host, '/index.php/apps/oauth2/api/v1/token')) + stub_request(:any, File.join(host, "/index.php/apps/oauth2/api/v1/token")) .to_timeout end - it 'returns an error wrapping a timeout', :webmock do + it "returns an error wrapping a timeout", :webmock do expect(subject.success).to be_falsy expect(subject.result).to eq(:internal_server_error) expect(subject.error_payload.class).to be(Faraday::ConnectionFailed) expect(subject.error_source).to be_a(described_class) - expect(subject.errors.log_message).to include('Faraday::ConnectionFailed: execution expired') + expect(subject.errors.log_message).to include("Faraday::ConnectionFailed: execution expired") end end - context 'with parallel requests for refresh', :aggregate_failures do + context "with parallel requests for refresh", :aggregate_failures do after do Storages::Storage.destroy_all User.destroy_all @@ -407,7 +353,7 @@ OAuthClient.destroy_all end - it 'requests token only once and other thread uses new token' do + it "requests token only once and other thread uses new token" do response_body1 = { access_token: "xyjTDZ...RYvRH", token_type: "Bearer", @@ -417,7 +363,7 @@ } response_body2 = response_body1.dup response_body2[:access_token] = "differ...RYvRH" - request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token') + request_url = File.join(host, "/index.php/apps/oauth2/api/v1/token") stub_request(:any, request_url).to_return( { status: 200, body: response_body1.to_json, headers: { "content-type" => "application/json; charset=utf-8" } }, { status: 200, body: response_body2.to_json, headers: { "content-type" => "application/json; charset=utf-8" } } @@ -443,7 +389,7 @@ expect(WebMock).to have_requested(:any, request_url).once end - it 'requests token refresh twice if enough time passes between requests' do + it "requests token refresh twice if enough time passes between requests" do stub_const("OAuthClients::ConnectionManager::TOKEN_IS_FRESH_DURATION", 2.seconds) response_body1 = { access_token: "xyjTDZ...RYvRH", @@ -454,7 +400,7 @@ } response_body2 = response_body1.dup response_body2[:access_token] = "differ...RYvRH" - request_url = File.join(host, '/index.php/apps/oauth2/api/v1/token') + request_url = File.join(host, "/index.php/apps/oauth2/api/v1/token") headers = { "content-type" => "application/json; charset=utf-8" } stub_request(:any, request_url) .to_return(status: 200, body: response_body1.to_json, headers:).then @@ -483,8 +429,8 @@ end end - context 'when token is fresh' do - it 'does not send refresh request and respond with existing token', :webmock do + context "when token is fresh" do + it "does not send refresh request and respond with existing token", :webmock do expect(subject.success).to be_truthy expect(subject.result).to eq(oauth_client_token) expect { subject }.not_to change(oauth_client_token, :access_token) @@ -493,93 +439,7 @@ end end - describe '#authorization_state' do - subject { instance.authorization_state } - - context 'without access token present' do - it 'returns :failed_authorization' do - expect(subject).to eq :failed_authorization - expect(instance).to be_authorization_state_failed_authorization - end - end - - context 'with access token present', :webmock do - before do - oauth_client_token - end - - context 'with access token valid' do - context 'without other errors or exceptions' do - before do - allow(configuration).to receive(:authorization_state_check).and_return(:success) - end - - it 'returns :connected' do - expect(subject).to eq :connected - expect(instance).to be_authorization_state_connected - end - end - - context 'with some other error or exception' do - before do - allow(configuration).to receive(:authorization_state_check).and_return(:error) - end - - it 'returns :error' do - expect(subject).to eq :error - expect(instance).to be_authorization_state_error - end - end - end - - context 'with outdated access token' do - let(:new_oauth_client_token) { create(:oauth_client_token) } - let(:refresh_service_result) { ServiceResult.success } - - before do - allow(configuration).to receive(:authorization_state_check).and_return(:refresh_needed) - allow(instance).to receive(:refresh_token).and_return(refresh_service_result) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(subject).to eq :connected - expect(instance).to have_received(:refresh_token) - end - end - - context 'with invalid refresh token' do - let(:refresh_service_result) do - data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' }) - ServiceResult.failure(result: :bad_request, - errors: Storages::StorageError.new(code: :bad_request, data:)) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(subject).to eq :failed_authorization - expect(instance).to have_received(:refresh_token) - end - end - - context 'with some other error while refreshing access token' do - let(:refresh_service_result) { ServiceResult.failure } - - it 'returns :error' do - expect(subject).to eq :error - expect(instance).to have_received(:refresh_token) - end - end - end - end - - context 'with both invalid access token and refresh token', :webmock do - it 'returns :failed_authorization' do - expect(subject).to eq :failed_authorization - end - end - end - - describe '#request_with_token_refresh' do + describe "#request_with_token_refresh" do let(:yield_service_result) { ServiceResult.success } let(:refresh_service_result) { ServiceResult.success } @@ -592,18 +452,18 @@ allow(oauth_client_token).to receive(:reload) end - context 'with yield returning :success' do - it 'returns a ServiceResult with success, without refreshing the token' do + context "with yield returning :success" do + it "returns a ServiceResult with success, without refreshing the token" do expect(subject.success).to be_truthy expect(instance).not_to have_received(:refresh_token) expect(oauth_client_token).not_to have_received(:reload) end end - context 'with yield returning :error' do + context "with yield returning :error" do let(:yield_service_result) { ServiceResult.failure(result: :error) } - it 'returns a ServiceResult with success, without refreshing the token' do + it "returns a ServiceResult with success, without refreshing the token" do expect(subject.success).to be_falsy expect(subject.result).to be :error expect(instance).not_to have_received(:refresh_token) @@ -611,10 +471,10 @@ end end - context 'with yield returning :unauthorized and the refresh returning a with a success' do + context "with yield returning :unauthorized and the refresh returning a with a success" do let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) } - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_falsy expect(subject.result).to be :unauthorized expect(instance).to have_received(:refresh_token) @@ -622,15 +482,15 @@ end end - context 'with yield returning :unauthorized and the refresh returning with a :failure' do + context "with yield returning :unauthorized and the refresh returning with a :failure" do let(:yield_service_result) { ServiceResult.failure(result: :unauthorized) } let(:refresh_service_result) do - data = Storages::StorageErrorData.new(source: nil, payload: { error: 'invalid_request' }) + data = Storages::StorageErrorData.new(source: nil, payload: { error: "invalid_request" }) ServiceResult.failure(result: :error, errors: Storages::StorageError.new(code: :error, data:)) end - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_falsy expect(subject.result).to be :error expect(instance).to have_received(:refresh_token) @@ -638,7 +498,7 @@ end end - context 'with yield returning :unauthorized first time and :success the second time' do + context "with yield returning :unauthorized first time and :success the second time" do let(:yield_double_object) { Object.new } let(:yield_service_result1) { ServiceResult.failure(result: :unauthorized) } let(:yield_service_result2) { ServiceResult.success } @@ -658,7 +518,7 @@ ) end - it 'returns a ServiceResult with success, without refresh' do + it "returns a ServiceResult with success, without refresh" do expect(subject.success).to be_truthy expect(subject).to be yield_service_result2 expect(instance).to have_received(:refresh_token) diff --git a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb index 9f530e21b350..b04fa5de908a 100644 --- a/spec/services/oauth_clients/one_drive_connection_manager_spec.rb +++ b/spec/services/oauth_clients/one_drive_connection_manager_spec.rb @@ -28,19 +28,19 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'spec_helper' +require "spec_helper" RSpec.describe OAuthClients::ConnectionManager, :webmock, type: :model do let(:user) { create(:user) } - let(:storage) { create(:one_drive_storage, :with_oauth_client, tenant_id: 'consumers') } + let(:storage) { create(:one_drive_storage, :with_oauth_client, tenant_id: "consumers") } let(:token) { create(:oauth_client_token, oauth_client: storage.oauth_client, user:) } subject(:connection_manager) do described_class.new(user:, configuration: storage.oauth_configuration) end - describe '#code_to_token' do - let(:code) { 'wow.such.code.much.token' } + describe "#code_to_token" do + let(:code) { "wow.such.code.much.token" } let(:code_to_token_response) do { access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...", @@ -69,200 +69,55 @@ end before do - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 200, body: code_to_token_response, headers: { 'Content-Type' => 'application/json' }) + stub_request(:post, "https://login.microsoftonline.com/consumers/oauth2/v2.0/token") + .to_return(status: 200, body: code_to_token_response, headers: { "Content-Type" => "application/json" }) - stub_request(:get, 'https://graph.microsoft.com/v1.0/me') + stub_request(:get, "https://graph.microsoft.com/v1.0/me") .with(headers: { Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q..." }) - .to_return(status: 200, body: me_response, headers: { 'Content-Type' => 'application/json' }) + .to_return(status: 200, body: me_response, headers: { "Content-Type" => "application/json" }) end - it 'fills in the origin_user_id' do + it "fills in the origin_user_id" do expect { subject.code_to_token(code) }.to change(OAuthClientToken, :count).by(1) last_token = OAuthClientToken .where(access_token: "eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q...") .last - expect(last_token.origin_user_id).to eq('87d349ed-44d7-43e1-9a83-5f2406dee5bd') + expect(last_token.origin_user_id).to eq("87d349ed-44d7-43e1-9a83-5f2406dee5bd") end - context 'when the identification request fails' do + context "when the identification request fails" do before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me') + stub_request(:get, "https://graph.microsoft.com/v1.0/me") .with(headers: { Authorization: "Bearer eyJ0eXAiOiJKV1QiLCJhbGciOiJSUzI1NiIsIng1dCI6Ik5HVEZ2ZEstZnl0aEV1Q..." }) .to_return(status: 404) end - it 'raises an error' do + it "raises an error" do expect { subject.code_to_token(code) }.to raise_error(HTTPX::HTTPError) end end end - describe '#get_authorization_uri' do - it 'always add the necessary scopes' do - uri = connection_manager.get_authorization_uri(state: nil) - - expect(uri).to include CGI.escape(storage.oauth_configuration.scope.join(' ')) - end - - it 'adds the state if present' do - uri = connection_manager.get_authorization_uri(state: 'https://some.site.com') - expect(uri).to include "&state=https" - end - end - - describe '#get_access_token' do + describe "#get_access_token" do subject(:access_token_result) { connection_manager.get_access_token } - context 'with no OAuthClientToken present' do - it 'returns a redirection URL' do + context "with no OAuthClientToken present" do + it "returns a redirection URL" do expect(access_token_result).to be_failure - expect(access_token_result.result).to eq(connection_manager.get_authorization_uri) + expect(access_token_result.result).to eq(storage.oauth_configuration.authorization_uri) end end - context 'with an OAuthClientToken present' do + context "with an OAuthClientToken present" do before { token } - it 'returns the OAuthClientToken' do + it "returns the OAuthClientToken" do expect(access_token_result).to be_truthy expect(access_token_result.result).to be_a OAuthClientToken # The one and only... expect(access_token_result.result).to eql token end end end - - describe '#authorization_state' do - subject(:authorization_state) { connection_manager.authorization_state } - - context 'without access token present' do - it 'returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with access token present', :webmock do - before { token } - - context 'with access token valid' do - context 'without other errors or exceptions' do - before { stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 200) } - - it 'returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with some other error or exception' do - before { stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_timeout } - - it 'returns :error' do - expect(authorization_state).to eq :error - end - end - end - - context 'with outdated access token' do - shared_examples 'refresh' do |_code| - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - it 'refreshes the access token and returns :failed_authorization' do - allow(token).to receive(:updated_at).and_return(3.months.ago) - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with some other error while refreshing access token' do - it 'returns :error' do - expect(authorization_state).to eq :error - end - end - end - - context 'when Unauthorized is returned' do - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - end - - context 'with valid refresh token' do - before do - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 200, - body: { access_token: 'ThereIsNoPeaceOnlyPassion' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - before do - token.update!(updated_at: 3.months.ago) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'invalid_request', error_message: 'nope. not happening' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - - context 'with some other error while refreshing access token' do - it 'returns :error' do - token.update!(updated_at: 3.months.ago) - - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 401) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'you_error', error_message: 'it is not me, it is you' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - - expect(authorization_state).to eq :error - end - end - end - - context 'when Forbidden is returned' do - before do - stub_request(:get, 'https://graph.microsoft.com/v1.0/me').to_return(status: 403) - end - - context 'with valid refresh token' do - it 'refreshes the access token and returns :connected' do - expect(authorization_state).to eq :connected - end - end - - context 'with invalid refresh token' do - before do - token.update!(updated_at: 3.months.ago) - stub_request(:post, 'https://login.microsoftonline.com/consumers/oauth2/v2.0/token') - .to_return(status: 400, - body: { error: 'invalid_request', error_message: 'nope. not happening' }.to_json, - headers: { 'Content-Type' => 'application/json' }) - end - - it 'refreshes the access token and returns :failed_authorization' do - expect(authorization_state).to eq :failed_authorization - end - end - end - end - end - end end diff --git a/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb new file mode 100644 index 000000000000..799403ac4498 --- /dev/null +++ b/spec/services/project_custom_field_project_mappings/bulk_update_service_spec.rb @@ -0,0 +1,145 @@ +#-- 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::BulkUpdateService do + let!(:project) { create(:project) } + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Visible field', + visible: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:visible_required_project_custom_field) do + create(:project_custom_field, + name: 'Visible required field', + visible: true, + is_required: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + project_custom_field_section: section_with_invisible_fields) + end + + let(:instance) { described_class.new(user:, project:, project_custom_field_section: section_with_invisible_fields) } + + context 'with admin permissions' do + let(:user) { create(:admin) } + + it 'bulk enables/disables all (non-required) fields of the section, including invisible ones' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field, invisible_project_custom_field + ) + + expect(instance.call(action: :disable)).to be_success + + # required fields cannot be disabled, even not by admins + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with non-admin but sufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + it 'bulk enables/disables all fields of the section, excluding invisible ones' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + project.project_custom_fields << invisible_project_custom_field + + expect(instance.call(action: :disable)).to be_success + + # required fields cannot be disabled, invisible fields are not affected by non-admins + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, invisible_project_custom_field + ) + end + end + + context 'with insufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Editor', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + ] + }) + end + + it 'cannot bulk enable/disable project custom fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :enable)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(action: :disable)).to be_failure + end + end +end diff --git a/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb new file mode 100644 index 000000000000..929514c70038 --- /dev/null +++ b/spec/services/project_custom_field_project_mappings/toggle_service_spec.rb @@ -0,0 +1,217 @@ +#-- 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::ToggleService do + let!(:project) { create(:project) } + let!(:section_with_invisible_fields) { create(:project_custom_field_section, name: 'Section with invisible fields') } + + let!(:visible_project_custom_field) do + create(:project_custom_field, + name: 'Visible field', + visible: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:visible_required_project_custom_field) do + create(:project_custom_field, + name: 'Visible required field', + visible: true, + is_required: true, + project_custom_field_section: section_with_invisible_fields) + end + + let!(:invisible_project_custom_field) do + create(:project_custom_field, + name: 'Admin only field', + visible: false, + project_custom_field_section: section_with_invisible_fields) + end + + let(:instance) { described_class.new(user:) } + + context 'with admin permissions' do + let(:user) { create(:admin) } + + it 'toggles visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'toggles invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, invisible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with non-admin but sufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Admin', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + select_project_custom_fields + ] + }) + end + + it 'toggles visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field, visible_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_success + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end + + context 'with insufficient permissions' do + let(:user) do + create(:user, + firstname: 'Project', + lastname: 'Editor', + member_with_permissions: { + project => %w[ + view_work_packages + edit_project + ] + }) + end + + it 'does not toggle visible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle invisible, non-required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: invisible_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + + it 'does not toggle required fields' do + expect(project.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + + expect(instance.call(project_id: project.id, custom_field_id: visible_required_project_custom_field.id)).to be_failure + + expect(project.reload.project_custom_fields).to contain_exactly( + visible_required_project_custom_field + ) + end + end +end diff --git a/spec/services/projects/copy_service_integration_spec.rb b/spec/services/projects/copy_service_integration_spec.rb index d48b613d147f..c95a328eb85e 100644 --- a/spec/services/projects/copy_service_integration_spec.rb +++ b/spec/services/projects/copy_service_integration_spec.rb @@ -157,6 +157,8 @@ it 'copies the custom_field' do expect(subject).to be_success + expect(project_copy.project_custom_fields).to contain_exactly(user_custom_field) + cv = project_copy.custom_values.reload.find_by(custom_field: user_custom_field) expect(cv).to be_present expect(cv.value).to eq user_value.id.to_s @@ -177,6 +179,8 @@ it 'copies the custom_field' do expect(subject).to be_success + expect(project_copy.project_custom_fields).to contain_exactly(list_custom_field) + cv = project_copy.custom_values.reload.where(custom_field: list_custom_field).to_a expect(cv).to be_a Array expect(cv.count).to eq 2 @@ -184,6 +188,17 @@ end end + context 'with disabled project custom fields with default value' do + it 'is still disabled in the copy' do + create(:text_project_custom_field, default_value: 'default value') + + expect(subject).to be_success + + expect(source.project_custom_fields).to eq([]) + expect(project_copy.project_custom_fields).to match_array(source.project_custom_fields) + end + end + context 'with disabled work package custom field' do it 'is still disabled in the copy' do custom_field = create(:text_wp_custom_field) diff --git a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb index 9f91c06434d8..eea42daa4992 100644 --- a/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb +++ b/spec/services/queries/projects/project_queries/set_attributes_service_spec.rb @@ -30,6 +30,20 @@ RSpec.describe Queries::Projects::ProjectQueries::SetAttributesService, type: :model do let(:current_user) { build_stubbed(:user) } + let!(:custom_field) do + build_stubbed(:project_custom_field, id: 1) do |cf| + scope = instance_double(ActiveRecord::Relation) + + allow(ProjectCustomField) + .to receive(:visible) + .and_return(scope) + + allow(scope) + .to receive(:find_by) + .with(id: cf.id.to_s) + .and_return(cf) + end + end let(:contract_instance) do contract = instance_double(Queries::Projects::ProjectQueries::CreateContract) diff --git a/spec/support/components/projects/project_custom_fields/edit_dialog.rb b/spec/support/components/projects/project_custom_fields/edit_dialog.rb new file mode 100644 index 000000000000..3db7c11bbb03 --- /dev/null +++ b/spec/support/components/projects/project_custom_fields/edit_dialog.rb @@ -0,0 +1,113 @@ +# -- 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. +# ++ + +require 'support/components/common/modal' +require 'support/components/autocompleter/ng_select_autocomplete_helpers' + +module Components + module Projects + module ProjectCustomFields + class EditDialog < Components::Common::Modal + include Components::Autocompleter::NgSelectAutocompleteHelpers + + attr_reader :project, :project_custom_field_section, :title + + def initialize(project, project_custom_field_section) + super() + + @project = project + @project_custom_field_section = project_custom_field_section + @title = @project_custom_field_section.name + end + + def dialog_css_selector + "dialog#edit-project-custom-fields-dialog-#{@project_custom_field_section.id}" + end + + def async_content_container_css_selector + "#{dialog_css_selector} [data-qa-selector='async-dialog-content']" + end + + def within_dialog(&) + within(dialog_css_selector, &) + end + + def within_async_content(close_after_yield: false, &) + within(async_content_container_css_selector, &) + close if close_after_yield + end + + def close + within_dialog do + page.find(".close-button").click + end + end + alias_method :close_via_icon, :close + + def close_via_button + within(dialog_css_selector) do + click_link_or_button 'Cancel' + end + end + + def submit + within(dialog_css_selector) do + page.find("[data-qa-selector='save-project-attributes-button']").click + end + end + + def expect_open + expect(page).to have_css(dialog_css_selector) + end + + def expect_closed + expect(page).to have_no_css(dialog_css_selector) + end + + def expect_async_content_loaded + expect(page).to have_css(async_content_container_css_selector) + end + + ### + + def input_containers + within '.Overlay-body > .FormControl-spacingWrapper' do + page.all('.FormControl-spacingWrapper') + end + end + + def within_custom_field_input_container(custom_field, &) + # wrapping in `within_async_content` to make sure the container is properly loaded + within_async_content do + within("[data-qa-selector='project-custom-field-input-container-#{custom_field.id}']", &) + end + end + end + end + end +end diff --git a/spec/support/form_fields/primerized/autocomplete_field.rb b/spec/support/form_fields/primerized/autocomplete_field.rb new file mode 100644 index 000000000000..8e4b0bf37e01 --- /dev/null +++ b/spec/support/form_fields/primerized/autocomplete_field.rb @@ -0,0 +1,77 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class AutocompleteField < FormField + ### actions + + def select_option(*values) + values.each do |val| + field_container.find('.ng-select-container').click + expect(page).to have_css('.ng-option', text: val, visible: :all) + page.find('.ng-option', text: val, visible: :all).click + sleep 0.25 # still required? + end + end + + def deselect_option(*values) + values.each do |val| + field_container.find('.ng-select-container').click + page.find('.ng-value', text: val, visible: :all).find('.ng-value-icon').click + sleep 0.25 # still required? + end + field_container.find('.ng-arrow-wrapper').click # close dropdown + sleep 0.25 + end + + def search(text) + field_container.find('.ng-select-container input').set text + end + + def open_options + field_container.find('.ng-select-container').click + end + + def clear + field_container.find('.ng-clear-wrapper', visible: :all).click + end + + ### expectations + + def expect_selected(*values) + values.each do |val| + expect(field_container).to have_css('.ng-value', text: val) + end + end + + def expect_not_selected(*values) + values.each do |val| + expect(field_container).to have_no_css('.ng-value', text: val, wait: 1) + end + end + + def expect_blank + expect(field_container).to have_css('.ng-value', count: 0) + end + + def expect_no_option(option) + expect(page) + .to have_no_css('.ng-option', text: option, visible: :all, wait: 1) + end + + def expect_option(option) + expect(page) + .to have_css('.ng-option', text: option, visible: :visible) + end + + def expect_visible + expect(field_container).to have_css('ng-select') + end + + def expect_error(string = nil) + expect(field_container).to have_css('.FormControl-inlineValidation') + expect(field_container).to have_content(string) if string + end + end + end +end diff --git a/spec/support/form_fields/primerized/editor_form_field.rb b/spec/support/form_fields/primerized/editor_form_field.rb new file mode 100644 index 000000000000..6f6b59705642 --- /dev/null +++ b/spec/support/form_fields/primerized/editor_form_field.rb @@ -0,0 +1,45 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class EditorFormField < FormField + attr_reader :editor + + delegate :expect_value, to: :editor + + def initialize(property, selector: nil) + super + + @editor = ::Components::WysiwygEditor.new(selector) + end + + def field_container + augmented_textarea = page.find("[data-textarea-selector='\"#project_custom_field_values_#{property.id}\"']") + augmented_textarea.first(:xpath, ".//..") + end + + ## + # Set or select the given value. + # For fields of type select, will check for an option with that value. + def set_value(content) + editor.set_markdown(content) + end + + def input_element + editor.editor_element + end + + # expectations + + def expect_visible + !!editor.container + end + + def expect_error(string = nil) + sleep 2 # quick fix for stale element error + expect(field_container).to have_css('.FormControl-inlineValidation') + expect(field_container).to have_content(string) if string + end + end + end +end diff --git a/spec/support/form_fields/primerized/form_field.rb b/spec/support/form_fields/primerized/form_field.rb new file mode 100644 index 000000000000..a97c67b7aefe --- /dev/null +++ b/spec/support/form_fields/primerized/form_field.rb @@ -0,0 +1,13 @@ +module FormFields + module Primerized + class FormField < FormFields::FormField + def property_name + if property.is_a? CustomField + property.attribute_name(:kebab_case) + else + property.to_s + end + end + end + end +end diff --git a/spec/support/form_fields/primerized/input_field.rb b/spec/support/form_fields/primerized/input_field.rb new file mode 100644 index 000000000000..6b7d87db7dab --- /dev/null +++ b/spec/support/form_fields/primerized/input_field.rb @@ -0,0 +1,33 @@ +require_relative 'form_field' + +module FormFields + module Primerized + class InputField < FormField + delegate :fill_in, :check, :uncheck, to: :input_element + + def field_container + page.find(selector).first(:xpath, ".//..").first(:xpath, ".//..") + end + + def input_element + field_container + end + + def send_keys(*) + input_element.send_keys(*) + end + + # expectations + + def expect_error(string = nil) + expect(page).to have_css("#{selector}[invalid='true']") + expect(field_container).to have_content(string) if string + end + + def expect_value(value) + scroll_to_element(field_container) + expect(field_container).to have_css('input') { |el| el.value == value } + end + end + end +end diff --git a/spec/support/pages/projects/show.rb b/spec/support/pages/projects/show.rb index 8d685e8c514f..5ad63c542823 100644 --- a/spec/support/pages/projects/show.rb +++ b/spec/support/pages/projects/show.rb @@ -50,6 +50,36 @@ def within_sidebar(&) def toast_type :rails end + + def visit_page + visit path + end + + def within_async_loaded_sidebar(&) + within '#project-custom-fields-sidebar' do + expect(page).to have_css("[data-qa-selector='project-custom-fields-sidebar-async-content']") + yield + end + end + + def within_custom_field_section_container(section, &) + within("[data-qa-selector='project-custom-field-section-#{section.id}']", &) + end + + def within_custom_field_container(custom_field, &) + within("[data-qa-selector='project-custom-field-#{custom_field.id}']", &) + end + + def open_edit_dialog_for_section(section) + within_async_loaded_sidebar do + scroll_to_element(page.find("[data-qa-selector='project-custom-field-section-#{section.id}']")) + within_custom_field_section_container(section) do + page.find("[data-qa-selector='project-custom-field-section-edit-button']").click + end + end + + expect(page).to have_css("[data-qa-selector='async-dialog-content']", wait: 5) + end end end end diff --git a/spec/support/shared/with_good_job.rb b/spec/support/shared/with_good_job.rb index 1cdf84367ae7..3d72211e3033 100644 --- a/spec/support/shared/with_good_job.rb +++ b/spec/support/shared/with_good_job.rb @@ -43,7 +43,6 @@ classes.each(&:disable_test_adapter) ActiveJob::Base.queue_adapter = good_job_adapter example.run - ensure ActiveJob::Base.queue_adapter = original_adapter classes.each { |cls| cls.enable_test_adapter(original_adapter) } diff --git a/spec/support/shared/with_test_ldap.rb b/spec/support/shared/with_test_ldap.rb index caa9b02e2314..bc0916e9a2fe 100644 --- a/spec/support/shared/with_test_ldap.rb +++ b/spec/support/shared/with_test_ldap.rb @@ -25,22 +25,20 @@ # # See COPYRIGHT and LICENSE files for more details. #++ -require 'ladle' +require "ladle" -RSpec.shared_context 'with temporary LDAP' do - # rubocop:disable RSpec/InstanceVariable +RSpec.shared_context "with temporary LDAP" do before(:all) do - ldif = Rails.root.join('spec/fixtures/ldap/users.ldif') + ldif = Rails.root.join("spec/fixtures/ldap/users.ldif") @ldap_server = Ladle::Server.new(quiet: false, port: ParallelHelper.port_for_ldap.to_s, - domain: 'dc=example,dc=com', + domain: "dc=example,dc=com", ldif:).start end after(:all) do @ldap_server.stop end - # rubocop:enable RSpec/InstanceVariable # Ldap has: # three users aa729, bb459, cc414 @@ -48,12 +46,12 @@ let!(:ldap_auth_source) do create(:ldap_auth_source, port: ParallelHelper.port_for_ldap.to_s, - account: 'uid=admin,ou=system', - account_password: 'secret', - base_dn: 'ou=people,dc=example,dc=com', + account: "uid=admin,ou=system", + account_password: "secret", + base_dn: "ou=people,dc=example,dc=com", onthefly_register:, filter_string: ldap_filter, - attr_login: 'uid', + attr_login: "uid", attr_firstname:, attr_lastname:, attr_mail:, @@ -62,8 +60,8 @@ let(:onthefly_register) { false } let(:ldap_filter) { nil } - let(:attr_firstname) { 'givenName' } - let(:attr_lastname) { 'sn' } - let(:attr_mail) { 'mail' } - let(:attr_admin) { 'isAdmin' } + let(:attr_firstname) { "givenName" } + let(:attr_lastname) { "sn" } + let(:attr_mail) { "mail" } + let(:attr_admin) { "isAdmin" } end