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 %>