diff --git a/app/views/projects/filters/_form.html.erb b/app/components/projects/filters_component.html.erb similarity index 84% rename from app/views/projects/filters/_form.html.erb rename to app/components/projects/filters_component.html.erb index 906ee00b0bbd..4b70865b0115 100644 --- a/app/views/projects/filters/_form.html.erb +++ b/app/components/projects/filters_component.html.erb @@ -1,6 +1,6 @@ <%= form_tag({}, method: :get, - class: "project-filters #{show_filters_section? ? '-expanded' : ''}", + class: "project-filters", data: { 'project-target': 'filterForm', action: 'submit->project#sendForm:prevent' @@ -12,7 +12,7 @@ data-action="project#toggleFilterForm"> <%= t(:label_filter_plural) %> <% unless EnterpriseToken.allows_to?(:custom_fields_in_projects_list)%> <%= - angular_component_tag 'op-enterprise-banner', - inputs: { - collapsible: true, - textMessage: t('ee.upsale.project_filters.description_html'), - moreInfoLink: OpenProject::Static::Links.links[:enterprise_docs][:custom_field_projects][:href], - } + helpers.angular_component_tag 'op-enterprise-banner', + inputs: { + collapsible: true, + textMessage: t('ee.upsale.project_filters.description_html'), + moreInfoLink: OpenProject::Static::Links.links[:enterprise_docs][:custom_field_projects][:href], + } %> <% end %> diff --git a/app/components/projects/filters_component.rb b/app/components/projects/filters_component.rb new file mode 100644 index 000000000000..c25894c717b2 --- /dev/null +++ b/app/components/projects/filters_component.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Projects::FiltersComponent < ApplicationComponent + options :query + + def allowed_filters + query + .available_filters + .select { |f| allowed_filter?(f) } + .sort_by(&:human_name) + end + + private + + def allowed_filter?(filter) + allowlist = [ + Queries::Projects::Filters::ActiveFilter, + Queries::Projects::Filters::TemplatedFilter, + Queries::Projects::Filters::PublicFilter, + Queries::Projects::Filters::ProjectStatusFilter, + Queries::Projects::Filters::MemberOfFilter, + Queries::Projects::Filters::CreatedAtFilter, + Queries::Projects::Filters::LatestActivityAtFilter, + Queries::Projects::Filters::NameAndIdentifierFilter, + Queries::Projects::Filters::TypeFilter + ] + allowlist << Queries::Filters::Shared::CustomFields::Base if EnterpriseToken.allows_to?(:custom_fields_in_projects_list) + + allowlist.detect { |clazz| filter.is_a? clazz } + end +end diff --git a/app/components/projects/index_page_header_component.html.erb b/app/components/projects/index_page_header_component.html.erb new file mode 100644 index 000000000000..4fc49406a67a --- /dev/null +++ b/app/components/projects/index_page_header_component.html.erb @@ -0,0 +1,100 @@ +<%= render(Primer::OpenProject::PageHeader.new) do |header| %> + <% header.with_title { t(:label_project_plural) } %> + + <% header.with_actions do %> + <% if current_user.allowed_globally?(:add_project) %> + <%= render( + Primer::Beta::Button.new( + tag: :a, + href: new_project_path, + scheme: :primary, + size: :medium, + aria: { label: I18n.t(:label_project_new) }, + mr: BUTTON_MARGIN_RIGHT, + data: { 'test-selector': 'project-new-button' } + ) + ) do |button| + button.with_leading_visual_icon(icon: :plus) + Project.model_name.human + end + %> + <% end %> + + <%= render( + Primer::Beta::IconButton.new( + icon: :filter, + size: :medium, + aria: { label: t(:label_filters_toggle) }, + mr: BUTTON_MARGIN_RIGHT, + data: { 'project-target': 'filterFormToggle', + 'action': 'project#toggleDisplayFilters', + 'test-selector': 'project-filter-toggle' } + ) + ) + %> + + <%= render( + Primer::Beta::Button.new( + tag: :a, + href: activities_path, + size: :medium, + type: :submit, + aria: { label: t(:label_overall_activity) }, + mr: BUTTON_MARGIN_RIGHT + ) + ) do + t(:label_overall_activity) + end + %> + + <%= render( + Primer::Beta::Button.new( + tag: :a, + href: gantt_portfolio_query_link, + size: :medium, + disabled: gantt_portfolio_project_ids.empty?, + type: :submit, + aria: { label: t('projects.index.open_as_gantt') }, + mr: BUTTON_MARGIN_RIGHT, + id: 'projects-index-open-as-gantt', + target: '_blank' + ) + ) do |button| + button.with_leading_visual_icon(icon: 'op-view-timeline') + button.with_trailing_visual_icon(icon: 'link-external') + button.with_tooltip(text: gantt_portfolio_title) + + t('projects.index.open_as_gantt') + end %> + + <%= render(Primer::Alpha::ActionMenu.new) do |menu| + menu.with_show_button(icon: 'op-kebab-vertical', 'aria-label': t(:label_more), data: { 'test-selector': 'project-more-dropdown-menu' }) + + if current_user.admin? + menu.with_item( + label: t('button_configure'), + href: admin_settings_projects_path, + content_arguments: { target: '_blank' } + ) do |item| + item.with_leading_visual_icon(icon: :gear) + end + end + + menu.with_item( + label: t('js.label_export'), + content_arguments: { 'data-show-dialog-id': 'project-export-dialog' } + ) do |item| + item.with_leading_visual_icon(icon: 'op-file-download') + end + end + %> + <% end %> +<% end %> + +<%= render(Primer::Alpha::Dialog.new(title: t('js.label_export'), + id: 'project-export-dialog')) do |d| + d.with_header(variant: :large) + d.with_body do + render partial: 'project_export_modal', locals: { query: query } + end +end %> diff --git a/app/components/projects/index_page_header_component.rb b/app/components/projects/index_page_header_component.rb new file mode 100644 index 000000000000..bfd7af52b0e0 --- /dev/null +++ b/app/components/projects/index_page_header_component.rb @@ -0,0 +1,61 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Projects::IndexPageHeaderComponent < ApplicationComponent + options :projects, + :current_user, + :query + + BUTTON_MARGIN_RIGHT = 2 + + def gantt_portfolio_query_link + generator = ::Projects::GanttQueryGeneratorService.new(gantt_portfolio_project_ids) + work_packages_path query_props: generator.call + end + + def gantt_portfolio_project_ids + @gantt_portfolio_project_ids ||= projects + .where(active: true) + .select(:id) + .uniq + .pluck(:id) + end + + def gantt_portfolio_title + title = t('projects.index.open_as_gantt_title') + + if current_user.admin? + title << ' ' + title << t('projects.index.open_as_gantt_title_admin') + end + + title + end +end diff --git a/app/components/projects/storage_information_component.html.erb b/app/components/projects/storage_information_component.html.erb new file mode 100644 index 000000000000..92a6c1658bce --- /dev/null +++ b/app/components/projects/storage_information_component.html.erb @@ -0,0 +1,6 @@ +

+ <%= helpers.op_icon('icon-info1') %> + <%= t(:label_projects_storage_information, + count: Project.count, + storage: number_to_human_size(Project.total_projects_size, precision: 2)) %> +

diff --git a/app/components/projects/storage_information_component.rb b/app/components/projects/storage_information_component.rb new file mode 100644 index 000000000000..e42c11bfa2f2 --- /dev/null +++ b/app/components/projects/storage_information_component.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2010-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ + +class Projects::StorageInformationComponent < ApplicationComponent + options :current_user + + def render? + current_user.admin? + end +end diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index ffb6aa4b7898..d7b2a1f0eb4b 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -33,30 +33,6 @@ def show_filters_section? params[:filters].present? && !params.key?(:hide_filters_section) end - def allowed_filters(query) - query - .available_filters - .select { |f| whitelisted_project_filter?(f) } - .sort_by(&:human_name) - end - - def whitelisted_project_filter?(filter) - whitelist = [ - Queries::Projects::Filters::ActiveFilter, - Queries::Projects::Filters::TemplatedFilter, - Queries::Projects::Filters::PublicFilter, - Queries::Projects::Filters::ProjectStatusFilter, - Queries::Projects::Filters::MemberOfFilter, - Queries::Projects::Filters::CreatedAtFilter, - Queries::Projects::Filters::LatestActivityAtFilter, - Queries::Projects::Filters::NameAndIdentifierFilter, - Queries::Projects::Filters::TypeFilter - ] - whitelist << Queries::Filters::Shared::CustomFields::Base if EnterpriseToken.allows_to?(:custom_fields_in_projects_list) - - whitelist.detect { |clazz| filter.is_a? clazz } - end - def no_projects_result_box_params if User.current.allowed_globally?(:add_project) { action_url: new_project_path, display_action: true } @@ -290,30 +266,6 @@ def allowed_parent_projects(project) .assignable_parents end - def gantt_portfolio_query_link(filtered_project_ids) - generator = ::Projects::GanttQueryGeneratorService.new(filtered_project_ids) - work_packages_path query_props: generator.call - end - - def gantt_portfolio_project_ids(project_scope) - project_scope - .where(active: true) - .select(:id) - .uniq - .pluck(:id) - end - - def gantt_portfolio_title - title = t('projects.index.open_as_gantt_title') - - if current_user.admin? - title << ' ' - title << t('projects.index.open_as_gantt_title_admin') - end - - title - end - def short_project_description(project, length = 255) unless project.description.present? return '' diff --git a/app/views/projects/_project_export_modal.html.erb b/app/views/projects/_project_export_modal.html.erb index 5595041f9253..7a6d9cf73d63 100644 --- a/app/views/projects/_project_export_modal.html.erb +++ b/app/views/projects/_project_export_modal.html.erb @@ -27,32 +27,15 @@ See COPYRIGHT and LICENSE files for more details. ++#%> - + + <% end %> + diff --git a/app/views/projects/index.html.erb b/app/views/projects/index.html.erb index 7340ae16ce4a..9a9020828102 100644 --- a/app/views/projects/index.html.erb +++ b/app/views/projects/index.html.erb @@ -29,73 +29,17 @@ See COPYRIGHT and LICENSE files for more details. <% html_title(t(:label_project_plural)) -%>
- <%= toolbar title: t(:label_project_plural), html: { class: '-with-dropdown' } do %> - <% if User.current.allowed_globally?(:add_project) %> -
  • - <%= link_to new_project_path, - { class: 'button -alt-highlight', - aria: { label: t(:label_project_new) }, - title: t(:label_project_new) } do %> - <%= op_icon('button--icon icon-add') %> - <%= Project.model_name.human %> - <% end %> -
  • - <% end %> -
  • - -
  • -
  • - <%= link_to t(:label_overall_activity), activities_path, class: 'button' %> -
  • -
  • - <% gantt_project_ids = gantt_portfolio_project_ids(@projects) %> - <%= link_to gantt_portfolio_query_link(gantt_project_ids), - disabled: gantt_project_ids.empty?, - class: "button #{gantt_project_ids.empty? ? '-disabled' : ''}", - title: gantt_portfolio_title, - target: '_blank' do %> - <%= op_icon("button--icon icon-view-timeline") %> - <%= t('projects.index.open_as_gantt') %> - <%= op_icon("button--icon icon-external-link") %> - <% end %> -
  • - - <% end %> - - <%= render partial: 'projects/filters/form', locals: { query: @query } %> + data-application-target="dynamic" + data-project-display-filters-value="<%= show_filters_section? %>"> + <%= render Projects::IndexPageHeaderComponent.new( + projects: @projects, + query: @query, + current_user: + ) %> + + <%= render Projects::FiltersComponent.new( + query: @query, + ) %> <%= render Projects::TableComponent.new( rows: @projects, @@ -103,12 +47,7 @@ See COPYRIGHT and LICENSE files for more details. orders: @orders, params: params) %> - <% if User.current.admin? %> -

    - <%= op_icon('icon-info1') %> - <%= t(:label_projects_storage_information, - count: Project.count, - storage: number_to_human_size(Project.total_projects_size, precision: 2)) %> -

    - <% end %> + <%= render Projects::StorageInformationComponent.new( + current_user: current_user + ) %>
    diff --git a/frontend/src/stimulus/controllers/dynamic/project.controller.ts b/frontend/src/stimulus/controllers/dynamic/project.controller.ts index 23316d632a6e..e69201c8f8ec 100644 --- a/frontend/src/stimulus/controllers/dynamic/project.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/project.controller.ts @@ -71,9 +71,31 @@ export default class ProjectController extends Controller { declare readonly singleDayTargets:HTMLInputElement[]; declare readonly simpleValueTargets:HTMLInputElement[]; - toggleFilterForm() { - this.filterFormToggleTarget.classList.toggle('-active'); - this.filterFormTarget.classList.toggle('-expanded'); + static values = { + displayFilters: { type: Boolean, default: false }, + }; + + declare displayFiltersValue:boolean; + + toggleDisplayFilters() { + this.displayFiltersValue = !this.displayFiltersValue; + } + + displayFiltersValueChanged() { + this.toggleButtonActive(); + this.toggleFilterFormVisible(); + } + + toggleButtonActive() { + if (this.displayFiltersValue) { + this.filterFormToggleTarget.setAttribute('aria-disabled', 'true'); + } else { + this.filterFormToggleTarget.removeAttribute('aria-disabled'); + } + } + + toggleFilterFormVisible() { + this.filterFormTarget.classList.toggle('-expanded', this.displayFiltersValue); } toggleMultiSelect({ params: { filterName } }:{ params:{ filterName:string } }) { diff --git a/spec/features/projects/projects_portfolio_spec.rb b/spec/features/projects/projects_portfolio_spec.rb index 22164b0de5e5..2519a25d8325 100644 --- a/spec/features/projects/projects_portfolio_spec.rb +++ b/spec/features/projects/projects_portfolio_spec.rb @@ -53,7 +53,7 @@ projects_page.open_filters projects_page.filter_by_active('yes') - expect(page).to have_selector('.button.-disabled', text: 'Open as Gantt view', wait: 10) + projects_page.expect_gantt_button(disabled: true) end end @@ -63,7 +63,7 @@ it 'disables the button' do visit projects_path - expect(page).to have_selector('.button.-disabled', text: 'Open as Gantt view') + projects_page.expect_gantt_button(disabled: true) end end diff --git a/spec/support/pages/projects/index.rb b/spec/support/pages/projects/index.rb index fc1eaabc484b..2cf33fcd3078 100644 --- a/spec/support/pages/projects/index.rb +++ b/spec/support/pages/projects/index.rb @@ -88,6 +88,11 @@ def expect_filter_set(filter_name) visible: :hidden) end + def expect_gantt_button(disabled: false) + expect(page).to have_selector("button#{disabled ? '[disabled]' : ''}", + text: 'Open as Gantt view') + end + def filter_by_active(value) set_filter('active', 'Active', @@ -182,15 +187,15 @@ def set_custom_field_filter(selected_filter, human_operator, values) def open_filters retry_block do - click_button('Show/hide filters') + page.find('[data-test-selector="project-filter-toggle"]').click page.find_field('Add filter', visible: true) end end def click_more_menu_item(item) page.find('[data-test-selector="project-more-dropdown-menu"]').click - page.within('.menu-drop-down-container') do - click_link(item) + page.within('.ActionListWrap') do + click(item) end end @@ -218,9 +223,7 @@ def navigate_to_new_project_page_from_global_sidebar end def navigate_to_new_project_page_from_toolbar_items - within '.toolbar-items' do - click_on 'New project' - end + find('[data-test-selector="project-new-button"]').click end private