diff --git a/modules/meeting/app/components/_index.sass b/modules/meeting/app/components/_index.sass index 2f17a54aef68..7655e791146e 100644 --- a/modules/meeting/app/components/_index.sass +++ b/modules/meeting/app/components/_index.sass @@ -1,3 +1,5 @@ @import "./meeting_agenda_items/item_component/show_component.sass" @import "./meeting_agenda_items/form_component.sass" @import "./meetings/sidebar/state_component.sass" +@import "./meetings/sidebar/participants_component.sass" +@import "./meetings/sidebar/details_component.sass" diff --git a/modules/meeting/app/components/meetings/sidebar/details_component.html.erb b/modules/meeting/app/components/meetings/sidebar/details_component.html.erb index 86da51680f2f..8bcb54ec4b24 100644 --- a/modules/meeting/app/components/meetings/sidebar/details_component.html.erb +++ b/modules/meeting/app/components/meetings/sidebar/details_component.html.erb @@ -85,6 +85,34 @@ end end end + + details.with_row(mt: 2, classes: 'meeting-detail-participants') do + render_meeting_attribute_row(:people) do + flex_layout do |duration| + duration.with_column(mr: 2) do + render(Primer::Beta::Text.new) do + Meeting.human_attribute_name( + :participant, + count: @meeting.invited_or_attended_participants.count + ) + end + end + + duration.with_column(mr: 2) do + render(OpTurbo::OpPrimer::AsyncDialogComponent.new( + id: "edit-participants-dialog", + src: participants_dialog_meeting_path(@meeting), + size: :medium_portrait, + title: Meeting.human_attribute_name(:participants), + button_text: t("label_meeting_show_all_participants"), + button_attributes: { + scheme: :link, + font_weight: :normal + })) + end + end + end + end end end end diff --git a/modules/meeting/app/components/meetings/sidebar/details_component.sass b/modules/meeting/app/components/meetings/sidebar/details_component.sass new file mode 100644 index 000000000000..b1df95165ae1 --- /dev/null +++ b/modules/meeting/app/components/meetings/sidebar/details_component.sass @@ -0,0 +1,11 @@ +.meeting-detail-participants + visibility: collapse + // The modal is rendered inside the mobile participant list element and it can be + // triggered from both mobile and desktop views. The mobile participant list is hidden + // when the desktop view is active, but the modal inside it should still be visible. + .Overlay-backdrop--center + visibility: visible + +@media screen and (max-width: $breakpoint-sm) + .meeting-detail-participants + visibility: visible diff --git a/modules/meeting/app/components/meetings/sidebar/participants_component.html.erb b/modules/meeting/app/components/meetings/sidebar/participants_component.html.erb index 13482b57ae98..a7885364b8f9 100644 --- a/modules/meeting/app/components/meetings/sidebar/participants_component.html.erb +++ b/modules/meeting/app/components/meetings/sidebar/participants_component.html.erb @@ -17,13 +17,12 @@ if @meeting.editable? heading.with_column do - render(Primer::Alpha::Dialog.new( - id: "edit-participants-dialog", title: Meeting.human_attribute_name(:participants), - size: :medium_portrait - )) do |dialog| - dialog.with_show_button(icon: :gear, 'aria-label': t("label_meeting_manage_participants"), scheme: :invisible) - render(Meetings::Sidebar::ParticipantsFormComponent.new(meeting: @meeting)) - end + render(Primer::Beta::IconButton.new( + icon: :gear, + scheme: :invisible, + 'aria-label': t("label_meeting_manage_participants"), + data: { 'show-dialog-id': "edit-participants-dialog" } + )) end end end diff --git a/modules/meeting/app/components/meetings/sidebar/participants_component.sass b/modules/meeting/app/components/meetings/sidebar/participants_component.sass new file mode 100644 index 000000000000..f12dd12b4c24 --- /dev/null +++ b/modules/meeting/app/components/meetings/sidebar/participants_component.sass @@ -0,0 +1,6 @@ +@import 'helpers' + +@media screen and (max-width: $breakpoint-sm) + #meetings-sidebar-component + .BorderGrid-row:nth-child(3) + display: none diff --git a/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb b/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb index dbd369a60c78..b1643d62d8cc 100644 --- a/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb +++ b/modules/meeting/app/components/meetings/sidebar/participants_form_component.html.erb @@ -1,86 +1,100 @@ <%= - component_wrapper do - primer_form_with( - model: @meeting, - method: :put, - url: update_participants_meeting_path(@meeting) - ) do |f| - component_collection do |collection| - collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 460px;", my: 3)) do - flex_layout(mt: 3) do |form_container| - form_container.with_row do - flex_layout(justify_content: :flex_end) do |header| - header.with_column(style: "width: 90px;", text_align: :center) do - render(Primer::Beta::Text.new(font_weight: :emphasized)) { t("description_invite").capitalize } - end + content_tag("turbo-frame", id: "edit-participants-dialog-frame") do + component_wrapper do + primer_form_with( + model: @meeting, + method: :put, + url: update_participants_meeting_path(@meeting) + ) do |f| + component_collection do |collection| + collection.with_component(Primer::Alpha::Dialog::Body.new(style: "max-height: 460px;", my: 3)) do + flex_layout(mt: 3) do |form_container| + form_container.with_row do + flex_layout(justify_content: :flex_end) do |header| + header.with_column(style: "width: 90px;", text_align: :center) do + render(Primer::Beta::Text.new(font_weight: :emphasized)) { t("description_invite").capitalize } + end - header.with_column(style: "width: 90px;", text_align: :center) do - render(Primer::Beta::Text.new(font_weight: :emphasized)) { t("description_attended").capitalize } + header.with_column(style: "width: 90px;", text_align: :center) do + render(Primer::Beta::Text.new(font_weight: :emphasized)) { t("description_attended").capitalize } + end end end - end - form_container.with_row do - flex_layout(my: 3) do |form_content| - @meeting.all_changeable_participants.sort.each do |user| - form_content.with_row do - hidden_field_tag "meeting[participants_attributes][][user_id]", user.id - end - form_content.with_row(mb: 2, pb: 1, border: :bottom) do - if @meeting.participants.present? && participant = @meeting.participants.detect { |p| p.user_id == user.id } - flex_layout do |existing_participant_container| - existing_participant_container.with_row do - hidden_field_tag "meeting[participants_attributes][][id]", participant.id - end + form_container.with_row do + flex_layout(my: 3) do |form_content| + @meeting.all_changeable_participants.sort.each do |user| + form_content.with_row do + hidden_field_tag "meeting[participants_attributes][][user_id]", user.id + end + form_content.with_row(mb: 2, pb: 1, border: :bottom) do + if @meeting.participants.present? && participant = @meeting.participants.detect { |p| p.user_id == user.id } + flex_layout do |existing_participant_container| + existing_participant_container.with_row do + hidden_field_tag "meeting[participants_attributes][][id]", participant.id + end - existing_participant_container.with_row do - flex_layout(align_items: :center) do |existing_participant| - existing_participant.with_column(flex: 1, classes: 'ellipsis') do - render(Users::AvatarComponent.new( - user: participant.user, - classes: 'op-principal_flex' - )) - end - existing_participant.with_column(style: "width: 90px;", text_align: :center) do - styled_check_box_tag "meeting[participants_attributes][][invited]", 1, participant.invited?, - id: "checkbox_invited_#{participant.user.id}" - # Primer checkboxes currently not working in this context as they render an additional hidden input tag - # messing up the nested attributes mapping when posting the data to the server - # - # render(Primer::Alpha::CheckBox.new( - # name: "meeting[participants_attributes][][invited]", - # checked: participant.invited?, - # id: "checkbox_invited_#{participant.user.id}", - # visually_hide_label: true, - # label: "Invited", - # scheme: :boolean, - # unchecked_value: "" - # )) - end - existing_participant.with_column(style: "width: 90px;", text_align: :center) do - styled_check_box_tag "meeting[participants_attributes][][attended]", 1, participant.attended?, - id: "checkbox_attended_#{participant.user.id}" + existing_participant_container.with_row do + flex_layout(align_items: :center) do |existing_participant| + existing_participant.with_column(flex: 1, classes: 'ellipsis') do + render(Users::AvatarComponent.new( + user: participant.user, + classes: 'op-principal_flex' + )) + end + existing_participant.with_column(style: "width: 90px;", text_align: :center) do + styled_check_box_tag "meeting[participants_attributes][][invited]", + value = 1, + checked = participant.invited?, + id: "checkbox_invited_#{participant.user.id}", + disabled: !@meeting.editable? + # Primer checkboxes currently not working in this context as they render an additional hidden input tag + # messing up the nested attributes mapping when posting the data to the server + # + # render(Primer::Alpha::CheckBox.new( + # name: "meeting[participants_attributes][][invited]", + # checked: participant.invited?, + # id: "checkbox_invited_#{participant.user.id}", + # visually_hide_label: true, + # label: "Invited", + # scheme: :boolean, + # unchecked_value: "" + # )) + end + existing_participant.with_column(style: "width: 90px;", text_align: :center) do + styled_check_box_tag "meeting[participants_attributes][][attended]", + value = 1, + checked = participant.attended?, + id: "checkbox_attended_#{participant.user.id}", + disabled: !@meeting.editable? + end end end end - end - else - flex_layout(align_items: :center) do |new_participant| - new_participant.with_column(flex: 1, classes: 'ellipsis') do - render(Users::AvatarComponent.new( - user:, - classes: 'op-principal_flex' - )) - end + else + flex_layout(align_items: :center) do |new_participant| + new_participant.with_column(flex: 1, classes: 'ellipsis') do + render(Users::AvatarComponent.new( + user:, + classes: 'op-principal_flex' + )) + end - new_participant.with_column(style: "width: 90px;", text_align: :center) do - styled_check_box_tag "meeting[participants_attributes][][invited]", value = "1", checked = false, - id: "checkbox_invited_#{user.id}" - end + new_participant.with_column(style: "width: 90px;", text_align: :center) do + styled_check_box_tag "meeting[participants_attributes][][invited]", + value = "1", + checked = false, + id: "checkbox_invited_#{user.id}", + disabled: !@meeting.editable? + end - new_participant.with_column(style: "width: 90px;", text_align: :center) do - styled_check_box_tag "meeting[participants_attributes][][attended]", value = "1", checked = false, - id: "checkbox_attended_#{user.id}" + new_participant.with_column(style: "width: 90px;", text_align: :center) do + styled_check_box_tag "meeting[participants_attributes][][attended]", + value = "1", + checked = false, + id: "checkbox_attended_#{user.id}", + disabled: !@meeting.editable? + end end end end @@ -89,16 +103,17 @@ end end end - end - collection.with_component(Primer::Alpha::Dialog::Footer.new(show_divider: true)) do - component_collection do |footer| - footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "edit-participants-dialog" })) do - t("button_cancel") - end - - footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do - t("button_save") + if @meeting.editable? + collection.with_component(Primer::Alpha::Dialog::Footer.new(show_divider: true)) do + component_collection do |footer| + footer.with_component(Primer::ButtonComponent.new(data: { 'close-dialog-id': "edit-participants-dialog" })) do + t("button_cancel") + end + footer.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do + t("button_save") + end + end end end end diff --git a/modules/meeting/app/components/meetings/sidebar/participants_form_component.rb b/modules/meeting/app/components/meetings/sidebar/participants_form_component.rb index 4b884f58f8d5..1f41edb8a509 100644 --- a/modules/meeting/app/components/meetings/sidebar/participants_form_component.rb +++ b/modules/meeting/app/components/meetings/sidebar/participants_form_component.rb @@ -40,7 +40,7 @@ def initialize(meeting:) end def render? - User.current.allowed_in_project?(:edit_meetings, @meeting.project) + User.current.allowed_in_project?(:view_meetings, @meeting.project) end end end diff --git a/modules/meeting/app/components/meetings/sidebar/state_component.sass b/modules/meeting/app/components/meetings/sidebar/state_component.sass index 341b9bb00b73..bc7fe8fa417b 100644 --- a/modules/meeting/app/components/meetings/sidebar/state_component.sass +++ b/modules/meeting/app/components/meetings/sidebar/state_component.sass @@ -4,4 +4,4 @@ .op-meeting-sidebar-state flex-direction: row !important justify-content: space-between - align-items: center \ No newline at end of file + align-items: center diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 386c36167fb9..4eafe00791d6 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -141,6 +141,10 @@ def update end end + def participants_dialog + render(Meetings::Sidebar::ParticipantsFormComponent.new(meeting: @meeting), layout: false) + end + def update_participants @meeting.participants_attributes = @converted_params.delete(:participants_attributes) @meeting.save @@ -148,6 +152,7 @@ def update_participants if @meeting.errors.any? update_sidebar_participants_form_component_via_turbo_stream else + update_sidebar_details_component_via_turbo_stream update_sidebar_participants_component_via_turbo_stream end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index ab3485306e00..3a7341ee61a2 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -44,6 +44,9 @@ en: duration: "Duration" notes: "Notes" participants: "Participants" + participant: + one: "1 Participant" + other: "%{count} Participants" participants_attended: "Attendees" participants_invited: "Invitees" project: "Project" @@ -189,6 +192,7 @@ en: label_meeting_manage_participants: "Manage participants" label_meeting_no_participants: "No participants" label_meeting_show_hide_participants: "Show/hide %{count} more" + label_meeting_show_all_participants: "Show all" label_meeting_add_participants: "Add participants" text_meeting_not_editable_anymore: "This meeting is not editable anymore." diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 0993ecbd6208..13b826b70b7c 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -53,6 +53,7 @@ get :download_ics put :update_title put :update_details + get :participants_dialog put :update_participants put :change_state post :notify diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 45e45d3d41a4..bf131f4a956f 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -40,7 +40,7 @@ class Engine < ::Rails::Engine bundled: true do project_module :meetings do permission :view_meetings, - { meetings: %i[index show download_ics], + { meetings: %i[index show download_ics participants_dialog], meeting_agendas: %i[history show diff], meeting_minutes: %i[history show diff], work_package_meetings_tab: %i[index count] }, diff --git a/modules/meeting/spec/features/structured_meetings/mobile_structure_meeting_spec.rb b/modules/meeting/spec/features/structured_meetings/mobile_structure_meeting_spec.rb new file mode 100644 index 000000000000..83bb05b8ba1d --- /dev/null +++ b/modules/meeting/spec/features/structured_meetings/mobile_structure_meeting_spec.rb @@ -0,0 +1,116 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require 'spec_helper' + +require_relative '../../support/pages/structured_meeting//mobile/show' + +RSpec.describe 'Structured meetings CRUD', + :js, + :with_cuprite do + include Components::Autocompleter::NgSelectAutocompleteHelpers + + shared_let(:project) { create(:project, enabled_module_names: %w[meetings work_package_tracking]) } + shared_let(:user) do + create(:user, + lastname: 'First', + member_with_permissions: { project => %i[view_meetings create_meetings edit_meetings delete_meetings manage_agendas + close_meeting_agendas view_work_packages] }).tap do |u| + u.pref[:time_zone] = 'utc' + + u.save! + end + end + shared_let(:other_user) do + create(:user, + lastname: 'Second', + member_with_permissions: { project => %i[view_meetings view_work_packages] }) + end + shared_let(:no_member_user) do + create(:user, + lastname: 'Third') + end + + shared_let(:meeting) do + create(:structured_meeting, project:, author: user) + end + + let(:current_user) { user } + let(:show_page) { Pages::StructuredMeeting::Mobile::Show.new(StructuredMeeting.order(id: :asc).last) } + + include_context 'with mobile screen size' + + before do + login_as current_user + show_page.visit! + end + + it 'can edit participants of a structured meeting' do + expect(page).to have_current_path(show_page.path) + show_page.expect_participants(count: 1) + + show_page.open_participant_form + show_page.in_participant_form do + show_page.expect_participant(user, invited: true, attended: false) + show_page.expect_participant(other_user, invited: false, attended: false) + show_page.expect_available_participants(count: 2) + expect(page).to have_button('Save') + + check(id: "checkbox_invited_#{other_user.id}") + click_button('Save') + end + + show_page.expect_participants(count: 2) + + # when meeting is closed, can view but not edit + show_page.close_meeting + + show_page.open_participant_form + show_page.in_participant_form do + show_page.expect_participant(user, invited: true, attended: false, editable: false) + show_page.expect_participant(other_user, invited: true, attended: false, editable: false) + show_page.expect_available_participants(count: 2) + expect(page).not_to have_button('Save') + end + show_page.close_dialog + + show_page.reopen_meeting + + # other_use can view, but not edit + login_as other_user + show_page.visit! + + show_page.open_participant_form + show_page.in_participant_form do + show_page.expect_participant(user, invited: true, attended: false, editable: false) + show_page.expect_participant(other_user, invited: true, attended: false, editable: false) + show_page.expect_available_participants(count: 2) + expect(page).not_to have_button('Save') + end + end +end diff --git a/modules/meeting/spec/support/pages/structured_meeting/mobile/show.rb b/modules/meeting/spec/support/pages/structured_meeting/mobile/show.rb new file mode 100644 index 000000000000..54ef6d3a6675 --- /dev/null +++ b/modules/meeting/spec/support/pages/structured_meeting/mobile/show.rb @@ -0,0 +1,47 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative '../show' + +module Pages::StructuredMeeting::Mobile + class Show < ::Pages::StructuredMeeting::Show + def expect_participants(count: 1) + within(meeting_details_container) do + expect(page).to have_text(Meeting.human_attribute_name(:participant, count:)) + expect(page).to have_button("Show all") + end + end + + def open_participant_form + within(meeting_details_container) do + click_button "Show all" + end + expect(page).to have_css('#meetings-sidebar-participants-form-component') + end + end +end diff --git a/modules/meeting/spec/support/pages/structured_meeting/show.rb b/modules/meeting/spec/support/pages/structured_meeting/show.rb index 1d74f0e8aaa4..c42025ca0d18 100644 --- a/modules/meeting/spec/support/pages/structured_meeting/show.rb +++ b/modules/meeting/spec/support/pages/structured_meeting/show.rb @@ -149,5 +149,41 @@ def clear_item_edit_work_package_title ng_select_clear page.find(".op-meeting-agenda-item-form--title") expect(page).to have_css(".ng-input ", value: nil) end + + def in_participant_form(&) + page.within('#meetings-sidebar-participants-form-component form', &) + end + + def expect_participant(participant, invited: false, attended: false, editable: true) + expect(page).to have_text(participant.name) + expect(page).to have_field(id: "checkbox_invited_#{participant.id}", checked: invited, disabled: !editable) + expect(page).to have_field(id: "checkbox_attended_#{participant.id}", checked: attended, disabled: !editable) + end + + def invite_participant(participant) + check(id: "checkbox_invited_#{participant.id}") + end + + def expect_available_participants(count:) + expect(page).to have_link(class: 'op-principal--name', count:) + end + + def close_meeting + click_button('Close meeting') + expect(page).to have_button('Reopen meeting') + end + + def reopen_meeting + click_button('Reopen meeting') + expect(page).to have_button('Close meeting') + end + + def close_dialog + click_button(class: 'Overlay-closeButton') + end + + def meeting_details_container + find_by_id('meetings-sidebar-details-component') + end end end