diff --git a/frontend/src/stimulus/controllers/dynamic/meeting-agenda-item-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/meeting-agenda-item-form.controller.ts index 8044ac14d65c..786c4b0a441f 100644 --- a/frontend/src/stimulus/controllers/dynamic/meeting-agenda-item-form.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/meeting-agenda-item-form.controller.ts @@ -51,12 +51,11 @@ export default class extends Controller { focusInput():void { const titleInput = this.element.querySelector('input[name="meeting_agenda_item[title]"]'); - setTimeout(() => { - this.element.scrollIntoView({ block: 'center' }); - if (titleInput) { - (titleInput as HTMLInputElement).focus(); - } - }, 100); + this.element.scrollIntoView({ block: 'center' }); + if (titleInput) { + (titleInput as HTMLInputElement).focus(); + this.setCursorAtEnd(titleInput as HTMLInputElement); + } } async cancel() { @@ -77,4 +76,11 @@ export default class extends Controller { addNotes() { this.notesInputTarget.classList.remove('d-none'); } + + setCursorAtEnd(inputElement:HTMLInputElement):void { + if (document.activeElement === inputElement) { + const valueLength = inputElement.value.length; + inputElement.setSelectionRange(valueLength, valueLength); + } + } } diff --git a/frontend/src/stimulus/controllers/dynamic/meeting-section-form.controller.ts b/frontend/src/stimulus/controllers/dynamic/meeting-section-form.controller.ts new file mode 100644 index 000000000000..9906a1f3a78e --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/meeting-section-form.controller.ts @@ -0,0 +1,74 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import * as Turbo from '@hotwired/turbo'; +import { Controller } from '@hotwired/stimulus'; + +export default class extends Controller { + static values = { + cancelUrl: String, + }; + + declare cancelUrlValue:string; + + connect():void { + this.focusInput(); + } + + focusInput():void { + const titleInput = this.element.querySelector('input[name="meeting_section[title]"]'); + + this.element.scrollIntoView({ block: 'center' }); + (titleInput as HTMLInputElement).focus(); + this.setCursorAtEnd(titleInput as HTMLInputElement); + } + + async cancel() { + const response = await fetch(this.cancelUrlValue, { + method: 'GET', + headers: { + 'X-CSRF-Token': (document.querySelector('meta[name="csrf-token"]') as HTMLMetaElement).content, + Accept: 'text/vnd.turbo-stream.html', + }, + }); + + if (response.ok) { + const text = await response.text(); + Turbo.renderStreamMessage(text); + } + } + + setCursorAtEnd(inputElement:HTMLInputElement):void { + if (document.activeElement === inputElement) { + const valueLength = inputElement.value.length; + inputElement.setSelectionRange(valueLength, valueLength); + } + } +} diff --git a/modules/meeting/app/components/_index.sass b/modules/meeting/app/components/_index.sass index 2f17a54aef68..3082e33be43d 100644 --- a/modules/meeting/app/components/_index.sass +++ b/modules/meeting/app/components/_index.sass @@ -1,3 +1,4 @@ @import "./meeting_agenda_items/item_component/show_component.sass" @import "./meeting_agenda_items/form_component.sass" +@import "./meeting_sections/header_component.sass" @import "./meetings/sidebar/state_component.sass" diff --git a/modules/meeting/app/components/meeting_agenda_items/form_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/form_component.html.erb index 8199fea62632..e0456e27b2a2 100644 --- a/modules/meeting/app/components/meeting_agenda_items/form_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/form_component.html.erb @@ -41,6 +41,9 @@ t("button_cancel") end end + flex.with_column do + render(MeetingAgendaItem::MeetingSectionForm.new(f, meeting_section: @meeting_section)) + end flex.with_column do render(MeetingAgendaItem::Submit.new(f, type: @type)) end diff --git a/modules/meeting/app/components/meeting_agenda_items/form_component.rb b/modules/meeting/app/components/meeting_agenda_items/form_component.rb index 39d90ce694a6..427f542de7f8 100644 --- a/modules/meeting/app/components/meeting_agenda_items/form_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/form_component.rb @@ -32,10 +32,12 @@ class FormComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(meeting:, meeting_agenda_item:, method:, submit_path:, cancel_path:, type: :simple, display_notes_input: nil) + def initialize(meeting:, meeting_section:, meeting_agenda_item:, method:, submit_path:, cancel_path:, type: :simple, + display_notes_input: nil) super @meeting = meeting + @meeting_section = meeting_section @meeting_agenda_item = meeting_agenda_item @method = method @submit_path = submit_path diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component.rb b/modules/meeting/app/components/meeting_agenda_items/item_component.rb index e52d1cb514b4..9cdae6d281ce 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component.rb @@ -86,6 +86,8 @@ def wrapper_arguments scheme: :default, data: { id: @meeting_agenda_item.id, + "draggable-id": @meeting_agenda_item.id, + "draggable-type": "agenda-item", "drop-url": drop_meeting_agenda_item_path(@meeting_agenda_item.meeting, @meeting_agenda_item) } } diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component/edit_component.rb b/modules/meeting/app/components/meeting_agenda_items/item_component/edit_component.rb index 7833da7e7d5f..662733396e8e 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component/edit_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component/edit_component.rb @@ -43,6 +43,7 @@ def call render(Primer::Box.new(pl: 3)) do render(MeetingAgendaItems::FormComponent.new( meeting: @meeting_agenda_item.meeting, + meeting_section: @meeting_agenda_item.meeting_section, meeting_agenda_item: @meeting_agenda_item, method: :put, submit_path: meeting_agenda_item_path(@meeting_agenda_item.meeting, @meeting_agenda_item, format: :turbo_stream), diff --git a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb index 701053c26260..99062ceb4598 100644 --- a/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/item_component/show_component.html.erb @@ -46,7 +46,7 @@ end if @meeting_agenda_item.duration_in_minutes.present? && @meeting_agenda_item.duration_in_minutes > 0 - grid.with_area(:duration, Primer::Beta::Text, color: duration_color_scheme, mr: 1) do + grid.with_area(:duration, Primer::Beta::Text, color: duration_color_scheme, mr: 1, font_size: :small) do I18n.t('datetime.distance_in_words.x_minutes_abbreviated', count: @meeting_agenda_item.duration_in_minutes) end end @@ -71,8 +71,13 @@ test_selector: 'op-meeting-agenda-actions') edit_action_item(menu) if @meeting_agenda_item.editable? add_note_action_item(menu) if @meeting_agenda_item.editable? && @meeting_agenda_item.notes.blank? + unless first? && last? + menu.with_divider + move_actions(menu) + end + menu.with_divider copy_action_item(menu) - move_actions(menu) + menu.with_divider delete_action_item(menu) end end diff --git a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb index 2a378cf8c2f7..a11caa657beb 100644 --- a/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/list_component.html.erb @@ -1,39 +1,43 @@ <%= component_wrapper(data: wrapper_data_attributes) do - # The borderBox needs to be `position: relative` because of bug #49853 - # (The action menu items float somewhere on the page as soon as you have to scroll the Box in Firefox) - render(border_box_container) do |border_box| - if @meeting.agenda_items.empty? && @form_hidden - border_box.with_body( - scheme: :default - ) do - render(Primer::Beta::Blankslate.new) do |component| - component.with_visual_icon(icon: :book) - component.with_heading(tag: :h2).with_content(t("text_meeting_empty_heading")) - component.with_description do - flex_layout do |flex| - flex.with_row(mb: 2) do - render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_1") } - end - flex.with_row do - render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_2") } - end - end - end - end - end - else - first_and_last = [@meeting.agenda_items.first, @meeting.agenda_items.last] + flex_layout(mb: 3) do |flex| + flex.with_row(classes: 'dragula-container', id: insert_target_modifier_id, data: { 'allowed-drop-type': 'section' }.merge(drop_target_config) ) do + first_and_last = [@meeting.sections.first, @meeting.sections.last] render( - MeetingAgendaItems::ItemComponent.with_collection( - @meeting.agenda_items.with_includes_to_render, - container: border_box, + MeetingSections::ShowComponent.with_collection( + @meeting.sections, first_and_last: ) ) end - border_box.with_row(p: 0, border_top: 0, id: insert_target_modifier_id) do - render(MeetingAgendaItems::NewComponent.new(meeting: @meeting, hidden: @form_hidden, type: @form_type)) + if @meeting.agenda_items.empty? && @meeting.sections.empty? + flex.with_row do + render(border_box_container) do |border_box| + if @form_hidden + border_box.with_body( + scheme: :default + ) do + render(Primer::Beta::Blankslate.new) do |component| + component.with_visual_icon(icon: :book) + component.with_heading(tag: :h2).with_content(t("text_meeting_empty_heading")) + component.with_description do + flex_layout do |flex| + flex.with_row(mb: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_1") } + end + flex.with_row do + render(Primer::Beta::Text.new(color: :subtle)) { t("text_meeting_empty_description_2") } + end + end + end + end + end + end + border_box.with_row(p: 0, border_top: 0) do + render(MeetingAgendaItems::NewComponent.new(meeting: @meeting, hidden: @form_hidden, type: @form_type)) + end + end + end end end end diff --git a/modules/meeting/app/components/meeting_agenda_items/list_component.rb b/modules/meeting/app/components/meeting_agenda_items/list_component.rb index 79bb962b064e..8b105616fe3f 100644 --- a/modules/meeting/app/components/meeting_agenda_items/list_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/list_component.rb @@ -44,9 +44,15 @@ def initialize(meeting:, form_hidden: true, form_type: :simple) def wrapper_data_attributes { - controller: "meeting-agenda-item-drag-and-drop", - "application-target": "dynamic", - "target-tag": "ul" + 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 @@ -55,7 +61,7 @@ def insert_target_modified? end def insert_target_modifier_id - "meeting-agenda-items-new-item" + "meeting-section-new-item" end end end diff --git a/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb index ce0328f583d3..f950248b5fcf 100644 --- a/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/new_button_component.html.erb @@ -1,7 +1,7 @@ <%= - component_wrapper(class: "mt-3", style: 'position: relative') do + component_wrapper(style: "position: relative") do render(Primer::Alpha::ActionMenu.new) do |component| - component.with_show_button(scheme: :primary, disabled: @disabled) do |button| + component.with_show_button(scheme: button_scheme, disabled: @disabled) do |button| button.with_leading_visual_icon(icon: :plus) t("button_add") end @@ -9,18 +9,28 @@ label: t("activerecord.models.meeting_agenda_item", count: 1), tag: :a, content_arguments: { - href: new_meeting_agenda_item_path(@meeting, type: "simple"), - data: { 'turbo-stream': true } + href: new_meeting_agenda_item_path(@meeting, type: "simple", meeting_section_id: @meeting_section&.id), + data: { "turbo-stream": true } } ) component.with_item( label: t("activerecord.models.work_package", count: 1), tag: :a, content_arguments: { - href: new_meeting_agenda_item_path(@meeting, type: "work_package"), - data: { 'turbo-stream': true } + href: new_meeting_agenda_item_path(@meeting, type: "work_package", meeting_section_id: @meeting_section&.id), + data: { "turbo-stream": true } } ) + unless @meeting_section + component.with_item( + label: "Section", + tag: :a, + content_arguments: { + href: meeting_sections_path(@meeting), + data: { "turbo-stream": true, "turbo-method": :post } + } + ) + end end end %> diff --git a/modules/meeting/app/components/meeting_agenda_items/new_button_component.rb b/modules/meeting/app/components/meeting_agenda_items/new_button_component.rb index bfc9b1af307a..06ad902fe000 100644 --- a/modules/meeting/app/components/meeting_agenda_items/new_button_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/new_button_component.rb @@ -32,16 +32,26 @@ class NewButtonComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(meeting:, meeting_agenda_item: nil, disabled: false) + def initialize(meeting:, meeting_section: nil, disabled: false) super @meeting = meeting - @meeting_agenda_item = meeting_agenda_item || MeetingAgendaItem.new(meeting:, author: User.current) + @meeting_section = meeting_section @disabled = @meeting.closed? || disabled end + private + + def wrapper_uniq_by + @meeting_section&.id + end + def render? User.current.allowed_in_project?(:manage_agendas, @meeting.project) end + + def button_scheme + @meeting_section ? :secondary : :primary + end end end diff --git a/modules/meeting/app/components/meeting_agenda_items/new_component.html.erb b/modules/meeting/app/components/meeting_agenda_items/new_component.html.erb index 75d91649eaed..8aae29ad1fe1 100644 --- a/modules/meeting/app/components/meeting_agenda_items/new_component.html.erb +++ b/modules/meeting/app/components/meeting_agenda_items/new_component.html.erb @@ -6,9 +6,10 @@ render(MeetingAgendaItems::FormComponent.new( meeting: @meeting, meeting_agenda_item: @meeting_agenda_item, + meeting_section: @meeting_section, method: :post, submit_path: meeting_agenda_items_path(@meeting, format: :turbo_stream), - cancel_path: cancel_new_meeting_agenda_items_path(@meeting), + cancel_path: cancel_new_meeting_agenda_items_path(@meeting, meeting_section_id: @meeting_section&.id), type: @type )) end diff --git a/modules/meeting/app/components/meeting_agenda_items/new_component.rb b/modules/meeting/app/components/meeting_agenda_items/new_component.rb index f20d74affa87..d1dbc6dca937 100644 --- a/modules/meeting/app/components/meeting_agenda_items/new_component.rb +++ b/modules/meeting/app/components/meeting_agenda_items/new_component.rb @@ -32,10 +32,11 @@ class NewComponent < ApplicationComponent include OpTurbo::Streamable include OpPrimer::ComponentHelpers - def initialize(meeting:, meeting_agenda_item: nil, hidden: true, type: :simple) + def initialize(meeting:, meeting_section: nil, meeting_agenda_item: nil, hidden: true, type: :simple) super @meeting = meeting + @meeting_section = meeting_section @meeting_agenda_item = meeting_agenda_item || build_agenda_item @hidden = hidden @type = type @@ -43,6 +44,10 @@ def initialize(meeting:, meeting_agenda_item: nil, hidden: true, type: :simple) private + def wrapper_uniq_by + @meeting_section&.id + end + def build_agenda_item MeetingAgendaItem.new( meeting: @meeting, diff --git a/modules/meeting/app/components/meeting_sections/header_component.html.erb b/modules/meeting/app/components/meeting_sections/header_component.html.erb new file mode 100644 index 000000000000..d421dc95986f --- /dev/null +++ b/modules/meeting/app/components/meeting_sections/header_component.html.erb @@ -0,0 +1,78 @@ +<%= + component_wrapper(class: "op-meeting-section-container", data: wrapper_data_attributes) do + if @state == :show + grid_layout('op-meeting-section', tag: :div) do |grid| + grid.with_area(:'drag-handle', tag: :div) do + if editable? + render(Primer::OpenProject::DragHandle.new(classes: 'handle')) + end + end + grid.with_area(:content, tag: :span) do + render(Primer::Beta::Text.new(font_weight: :bold, mr: 2)) do + @meeting_section.title + end + end + if @meeting_section.agenda_items_sum_duration_in_minutes > 0 + grid.with_area(:duration, Primer::Beta::Text, color: :subtle, mr: 1, font_size: :small) do + render(Primer::Beta::Text.new(font_size: :small, font_weight: :normal, color: :subtle)) do + render(OpenProject::Common::DurationComponent.new(@meeting_section.agenda_items_sum_duration_in_minutes, :minutes, abbreviated: true)) + end + end + end + grid.with_area(:actions, tag: :div, justify_self: :end) do + if editable? + render(Primer::Alpha::ActionMenu.new(data: { test_selector: "meeting-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) + unless first? && last? + menu.with_divider + move_actions(menu) + end + menu.with_divider + add_agenda_item_action(menu) + add_work_package_action(menu) + menu.with_divider + if @meeting_agenda_items.empty? + delete_action_item(menu) + else + disabled_delete_action_item(menu) + end + end + end + end + end + else + primer_form_with( + model: @meeting_section, + method: :put, + url: meeting_section_path(@meeting_section.meeting, @meeting_section), + data: { + "controller": "meeting-section-form", + "application-target": "dynamic", + "meeting-section-form-cancel-url-value": cancel_edit_meeting_section_path(@meeting_section.meeting, @meeting_section) + } + ) do |f| + flex_layout do |editable_title_form| + editable_title_form.with_column(flex: 1, mr: 2, pl: 2) do + render(MeetingSection::Title.new(f)) + end + + editable_title_form.with_column(mr: 2) do + render(MeetingSection::Submit.new(f)) + end + + editable_title_form.with_column do + render(Primer::Beta::Button.new( + scheme: :secondary, + tag: :a, + href: cancel_edit_meeting_section_path(@meeting_section.meeting, @meeting_section), + data: { 'turbo-stream': true } + )) do |_c| + t("button_cancel") + end + end + end + end + end + end +%> diff --git a/modules/meeting/app/components/meeting_sections/header_component.rb b/modules/meeting/app/components/meeting_sections/header_component.rb new file mode 100644 index 000000000000..151c9a24d01c --- /dev/null +++ b/modules/meeting/app/components/meeting_sections/header_component.rb @@ -0,0 +1,166 @@ +#-- 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 MeetingSections + class HeaderComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + def initialize(meeting_section:, state: :show, first_and_last: []) + super + + @meeting_section = meeting_section + @meeting_agenda_items = meeting_section.agenda_items + @first_and_last = first_and_last + @state = state + end + + private + + def wrapper_uniq_by + @meeting_section.id + end + + def wrapper_data_attributes + { + test_selector: "meeting-section-header-container-#{@meeting_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": @meeting_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 editable? + @meeting_section.editable? && User.current.allowed_in_project?(:manage_agendas, @meeting_section.project) + end + + def first? + @first ||= + if @first_and_last.first + @first_and_last.first == @meeting_section + else + @meeting_section.first? + end + end + + def last? + @last ||= + if @first_and_last.last + @first_and_last.last == @meeting_section + else + @meeting_section.last? + 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_meeting_section_path(@meeting_section.meeting, @meeting_section, move_to:), + form_arguments: { + method: :put, data: { "turbo-stream": true, + test_selector: "meeting-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("label_edit"), + href: edit_meeting_section_path(@meeting_section.meeting, @meeting_section), + content_arguments: { + data: { "turbo-stream": true, "test-selector": "meeting-section-edit" } + }) do |item| + item.with_leading_visual_icon(icon: :pencil) + end + end + + def add_agenda_item_action(menu) + menu.with_item( + label: t("activerecord.models.meeting_agenda_item", count: 1), + href: new_meeting_agenda_item_path(@meeting_section.meeting, type: "simple", meeting_section_id: @meeting_section&.id), + content_arguments: { + data: { "turbo-stream": true, "test-selector": "meeting-section-add-agenda-item-from-menu" } + } + ) do |item| + item.with_leading_visual_icon(icon: :plus) + end + end + + def add_work_package_action(menu) + menu.with_item( + label: t("activerecord.models.work_package", count: 1), + href: new_meeting_agenda_item_path(@meeting_section.meeting, type: "work_package", + meeting_section_id: @meeting_section&.id), + content_arguments: { + data: { "turbo-stream": true, "test-selector": "meeting-section-add-work-package-from-menu" } + } + ) do |item| + item.with_leading_visual_icon(icon: :plus) + end + end + + def delete_action_item(menu) + menu.with_item(label: t("text_destroy"), + scheme: :danger, + href: meeting_section_path(@meeting_section.meeting, @meeting_section), + form_arguments: { + method: :delete, data: { confirm: t("text_are_you_sure"), "turbo-stream": true, + test_selector: "meeting-section-delete" } + }) do |item| + item.with_leading_visual_icon(icon: :trash) + end + end + end +end diff --git a/modules/meeting/app/components/meeting_sections/header_component.sass b/modules/meeting/app/components/meeting_sections/header_component.sass new file mode 100644 index 000000000000..e855233a9e5c --- /dev/null +++ b/modules/meeting/app/components/meeting_sections/header_component.sass @@ -0,0 +1,23 @@ +@import 'helpers' + +.op-meeting-section + display: grid + grid-template-columns: 20px auto 1fr fit-content(40px) + grid-template-areas: "drag-handle content duration actions" + + &--drag-handle, + &--duration, + &--content + align-self: center + + &--actions + justify-self: end + + &--duration + white-space: nowrap + + &--content + @include text-shortener + + @media screen and (max-width: $breakpoint-lg) + grid-template-columns: 20px auto 1fr 50px diff --git a/modules/meeting/app/components/meeting_sections/show_component.html.erb b/modules/meeting/app/components/meeting_sections/show_component.html.erb new file mode 100644 index 000000000000..4fe0a73f24f3 --- /dev/null +++ b/modules/meeting/app/components/meeting_sections/show_component.html.erb @@ -0,0 +1,37 @@ +<%= + component_wrapper(class: "op-meeting-section-container", data: { test_selector: "meeting-section-container-#{@meeting_section.id}" }.merge(draggable_item_config)) do + render(Primer::Beta::BorderBox.new(mt: 3, data: drag_and_drop_target_config)) do |component| + if render_section_wrapper? + component.with_header(font_weight: :bold, pl: 0) do + render(MeetingSections::HeaderComponent.new(meeting_section: @meeting_section)) + end + end + if render_new_button_in_section? + 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(mr: 2) do + render(Primer::Beta::Text.new(color: :subtle)) { t("meeting_section.empty_text") } + end + empty_list_container.with_column do + render(MeetingAgendaItems::NewButtonComponent.new(meeting: @meeting, meeting_section: @meeting_section)) + end + end + end + else + first_and_last = [@meeting_agenda_items.first, @meeting_agenda_items.last] + @meeting_agenda_items.each do |agenda_item| + render( + MeetingAgendaItems::ItemComponent.new( + meeting_agenda_item: agenda_item, + container: component, + first_and_last: + ) + ) + end + end + component.with_row(p: 0, border_top: 0, id: insert_target_modifier_id) do + render(MeetingAgendaItems::NewComponent.new(meeting: @meeting, meeting_section: @meeting_section, hidden: @form_hidden, type: @form_type)) + end + end + end +%> diff --git a/modules/meeting/app/components/meeting_sections/show_component.rb b/modules/meeting/app/components/meeting_sections/show_component.rb new file mode 100644 index 000000000000..6cef52287b4b --- /dev/null +++ b/modules/meeting/app/components/meeting_sections/show_component.rb @@ -0,0 +1,93 @@ +#-- 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 MeetingSections + class ShowComponent < ApplicationComponent + include ApplicationHelper + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + with_collection_parameter :meeting_section + + def initialize(meeting_section:, first_and_last: [], form_hidden: true, form_type: :simple, insert_target_modified: true) + super + + @meeting = meeting_section.meeting + @meeting_section = meeting_section + @meeting_agenda_items = meeting_section.agenda_items + @first_and_last = first_and_last + @form_hidden = form_hidden + @form_type = form_type + @insert_target_modified = insert_target_modified + end + + private + + def wrapper_uniq_by + @meeting_section.id + end + + def insert_target_modified? + @insert_target_modified + end + + def insert_target_modifier_id + "meeting-agenda-items-new-item-#{@meeting_section.id}" + end + + def editable? + @meeting_section.editable? && User.current.allowed_in_project?(:manage_agendas, @meeting_section.project) + end + + def render_section_wrapper? + # true + !@meeting_section.untitled? || @meeting.sections.count > 1 + end + + def render_new_button_in_section? + @meeting_agenda_items.empty? && @form_hidden && editable? + end + + def draggable_item_config + { + "draggable-id": @meeting_section.id, + "draggable-type": "section", + "drop-url": drop_meeting_section_path(@meeting, @meeting_section) + } + 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": @meeting_section.id, # the id of the target + "target-allowed-drag-type": "agenda-item" # the type of dragged items which are allowed to be dropped in this target + } + end + end +end 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 a14094b5c16d..f95c064a5871 100644 --- a/modules/meeting/app/components/meetings/sidebar/details_component.html.erb +++ b/modules/meeting/app/components/meetings/sidebar/details_component.html.erb @@ -1,6 +1,6 @@ <%= component_wrapper do - flex_layout do |details_container| + flex_layout(mt: 3) do |details_container| details_container.with_row do flex_layout(align_items: :center, justify_content: :space_between) do |heading| heading.with_column(flex: 1) do diff --git a/modules/meeting/app/contracts/meeting_agenda_items/base_contract.rb b/modules/meeting/app/contracts/meeting_agenda_items/base_contract.rb index 414ebd2cd02f..0c353b54b5e8 100644 --- a/modules/meeting/app/contracts/meeting_agenda_items/base_contract.rb +++ b/modules/meeting/app/contracts/meeting_agenda_items/base_contract.rb @@ -36,6 +36,7 @@ def self.model attribute :meeting attribute :work_package + attribute :meeting_section attribute :position attribute :title diff --git a/modules/meeting/app/contracts/meeting_sections/base_contract.rb b/modules/meeting/app/contracts/meeting_sections/base_contract.rb new file mode 100644 index 000000000000..ffac0b4d6274 --- /dev/null +++ b/modules/meeting/app/contracts/meeting_sections/base_contract.rb @@ -0,0 +1,42 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module MeetingSections + class BaseContract < ::ModelContract + include ModifiableItem + + def self.model + MeetingSection + end + + attribute :meeting + + attribute :title + attribute :position + end +end diff --git a/modules/meeting/app/contracts/meeting_sections/create_contract.rb b/modules/meeting/app/contracts/meeting_sections/create_contract.rb new file mode 100644 index 000000000000..7486bf1c1a08 --- /dev/null +++ b/modules/meeting/app/contracts/meeting_sections/create_contract.rb @@ -0,0 +1,75 @@ +#-- 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 MeetingSections + class CreateContract < BaseContract + validate :user_allowed_to_add, :validate_meeting_existence + + def self.assignable_meetings(user) + StructuredMeeting + .open + .visible(user) + end + + ## + # Meeting agenda items can currently be only created + # through the project permission :manage_agendas + # When MeetingRole becomes available, agenda items will + # be created through meeting permissions :manage_agendas + def user_allowed_to_add + # when creating a meeting agenda item from the work package tab and not selecting a meeting + # the meeting and therefore the project is not set + # in this case we only want to show the "Meeting can't be blank" error instead of a misleading permission base error + # the error is added by the models presence validation + return unless visible? + + unless user.allowed_in_project?(:manage_agendas, model.project) + errors.add :base, :error_unauthorized + end + end + + ## + # A stale browser window might provide an already deleted meeting as an option when creating an agenda item from the + # work package tab. This would lead to an 500 server error when trying to save the agenda item. + def validate_meeting_existence + # when creating a meeting agenda item from the work package tab and not selecting a meeting + # the meeting and therefore the project is not set + # in this case we only want to show the "Meeting can't be blank" error instead of a misleading not existance error + # the error is added by the models presence validation + return if model.meeting.nil? + + errors.add :base, :does_not_exist unless visible? + end + + private + + def visible? + @visible ||= model.meeting&.visible?(user) + end + end +end diff --git a/modules/meeting/app/contracts/meeting_sections/delete_contract.rb b/modules/meeting/app/contracts/meeting_sections/delete_contract.rb new file mode 100644 index 000000000000..02dbb1d4a6e8 --- /dev/null +++ b/modules/meeting/app/contracts/meeting_sections/delete_contract.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module MeetingSections + class DeleteContract < ::DeleteContract + include ModifiableItem + + delete_permission :manage_agendas + + validate :empty_section + + def empty_section + unless model.agenda_items.empty? + errors.add :base, "Section is not empty and cannot be deleted." + end + end + end +end diff --git a/modules/meeting/app/contracts/meeting_sections/modifiable_item.rb b/modules/meeting/app/contracts/meeting_sections/modifiable_item.rb new file mode 100644 index 000000000000..b054059f142a --- /dev/null +++ b/modules/meeting/app/contracts/meeting_sections/modifiable_item.rb @@ -0,0 +1,45 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module MeetingSections + module ModifiableItem + extend ActiveSupport::Concern + + included do + validate :validate_modifiable + end + + protected + + def validate_modifiable + unless model.modifiable? + errors.add :base, I18n.t(:text_agenda_item_not_editable_anymore) + end + end + end +end diff --git a/modules/meeting/app/contracts/meeting_sections/update_contract.rb b/modules/meeting/app/contracts/meeting_sections/update_contract.rb new file mode 100644 index 000000000000..a900e4c624d4 --- /dev/null +++ b/modules/meeting/app/contracts/meeting_sections/update_contract.rb @@ -0,0 +1,43 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +module MeetingSections + class UpdateContract < BaseContract + validate :user_allowed_to_edit + + # Meeting agenda items can currently be only edited + # through the project permission :manage_agendas + # When MeetingRole becomes available, agenda items will + # be edited through meeting permissions :manage_agendas + def user_allowed_to_edit + unless user.allowed_in_project?(:manage_agendas, model.project) + errors.add :base, :error_unauthorized + end + end + end +end diff --git a/modules/meeting/app/controllers/concerns/meetings/agenda_component_streams.rb b/modules/meeting/app/controllers/concerns/meetings/agenda_component_streams.rb index c5bc4f953a9e..f1ffebcc29e0 100644 --- a/modules/meeting/app/controllers/concerns/meetings/agenda_component_streams.rb +++ b/modules/meeting/app/controllers/concerns/meetings/agenda_component_streams.rb @@ -31,10 +31,11 @@ module AgendaComponentStreams extend ActiveSupport::Concern included do - def update_header_component_via_turbo_stream(meeting: @meeting, state: :show) + def update_header_component_via_turbo_stream(project: @project, meeting: @meeting, state: :show) update_via_turbo_stream( component: Meetings::HeaderComponent.new( meeting:, + project:, state: ) ) @@ -86,12 +87,18 @@ def update_sidebar_participants_form_component_via_turbo_stream(meeting: @meetin component: Meetings::Sidebar::ParticipantsFormComponent.new( meeting: ), - status: :bad_request + status: :bad_request # TODO: why bad_request? ) end def update_show_items_via_turbo_stream(meeting: @meeting) - agenda_items = meeting.agenda_items.with_includes_to_render + meeting.sections.each do |meeting_section| + update_show_items_of_section_via_turbo_stream(meeting_section:) + end + end + + def update_show_items_of_section_via_turbo_stream(meeting_section: @meeting_section) + agenda_items = meeting_section.agenda_items.with_includes_to_render first_and_last = [agenda_items.first, agenda_items.last] agenda_items.each do |meeting_agenda_item| @@ -101,27 +108,71 @@ def update_show_items_via_turbo_stream(meeting: @meeting) end end - def update_new_component_via_turbo_stream(hidden: false, meeting_agenda_item: nil, meeting: @meeting, type: :simple) + def update_new_component_via_turbo_stream(hidden: false, meeting_section: @meeting_section, meeting_agenda_item: nil, + meeting: @meeting, type: :simple) + if meeting_section.nil? && meeting_agenda_item.nil? + meeting_section = meeting.sections.last + end + + if meeting_agenda_item.present? + meeting_section = meeting_agenda_item.meeting_section + end + update_via_turbo_stream( component: MeetingAgendaItems::NewComponent.new( hidden:, meeting:, + meeting_section:, meeting_agenda_item:, type: ) ) end - def update_new_button_via_turbo_stream(disabled: false, meeting_agenda_item: nil, meeting: @meeting) + def update_new_button_via_turbo_stream(disabled: false, meeting: @meeting, meeting_section: nil) update_via_turbo_stream( component: MeetingAgendaItems::NewButtonComponent.new( disabled:, meeting:, - meeting_agenda_item: + meeting_section: ) ) end + def render_agenda_item_form_via_turbo_stream(meeting: @meeting, meeting_section: @meeting_section, type: :simple) + if meeting.sections.empty? + render_agenda_item_form_for_empty_meeting_via_turbo_stream(meeting:, type:) + else + render_agenda_item_form_in_section_via_turbo_stream(meeting:, meeting_section:, type:) + end + + update_new_button_via_turbo_stream(disabled: true) + end + + def render_agenda_item_form_for_empty_meeting_via_turbo_stream(meeting: @meeting, type: :simple) + update_new_component_via_turbo_stream( + hidden: false, + meeting_section: nil, + type: + ) + end + + def render_agenda_item_form_in_section_via_turbo_stream(meeting: @meeting, meeting_section: @meeting_section, type: :simple) + if meeting_section.nil? + meeting_section = meeting.sections.last + end + + if meeting_section.agenda_items.empty? + update_section_via_turbo_stream(meeting_section:, form_hidden: false, form_type: type) + else + update_new_component_via_turbo_stream( + hidden: false, + meeting_section:, + type: + ) + end + end + def update_list_via_turbo_stream(meeting: @meeting, form_hidden: true, form_type: :simple) # replace needs to be called in order to mount the drag and drop handlers again # update would not do that and drag and drop would stop working after the first update @@ -151,15 +202,33 @@ def update_item_via_turbo_stream(state: :show, meeting_agenda_item: @meeting_age def add_item_via_turbo_stream(meeting_agenda_item: @meeting_agenda_item, clear_slate: false) if clear_slate update_list_via_turbo_stream(form_hidden: false, form_type: @agenda_item_type) + elsif meeting_agenda_item.meeting.agenda_items.count == 1 + update_list_via_turbo_stream(form_hidden: true) + + update_new_component_via_turbo_stream( + hidden: false, + meeting_section: meeting_agenda_item.meeting_section, + type: @agenda_item_type + ) else + update_section_header_via_turbo_stream(meeting_section: meeting_agenda_item.meeting_section) + add_before_via_turbo_stream( component: MeetingAgendaItems::ItemComponent.new( state: :show, meeting_agenda_item: ), - target_component: MeetingAgendaItems::ListComponent.new(meeting: @meeting) + target_component: MeetingSections::ShowComponent.new( + meeting_section: @meeting_agenda_item.meeting_section + ) + ) + + update_new_component_via_turbo_stream( + hidden: false, + meeting_section: meeting_agenda_item.meeting_section, + type: @agenda_item_type ) - update_new_component_via_turbo_stream(hidden: false, type: @agenda_item_type) + update_show_items_via_turbo_stream end end @@ -168,14 +237,48 @@ def remove_item_via_turbo_stream(meeting_agenda_item: @meeting_agenda_item, clea if clear_slate update_list_via_turbo_stream else - update_show_items_via_turbo_stream - remove_via_turbo_stream( - component: MeetingAgendaItems::ItemComponent.new( - state: :show, - meeting_agenda_item:, - display_notes_input: nil + update_show_items_of_section_via_turbo_stream(meeting_section: meeting_agenda_item.meeting_section) + if meeting_agenda_item.meeting_section.agenda_items.empty? + # Show the empty section state by updating the whole section if items are empty + update_section_via_turbo_stream(meeting_section: meeting_agenda_item.meeting_section) + else + remove_via_turbo_stream( + component: MeetingAgendaItems::ItemComponent.new( + state: :show, + meeting_agenda_item:, + display_notes_input: nil + ) ) - ) + end + end + end + + def move_item_within_section_via_turbo_stream(meeting_agenda_item: @meeting_agenda_item) + move_item_via_turbo_stream(meeting_agenda_item:) + + # update the displayed time slots of all other items in the section + update_show_items_of_section_via_turbo_stream(meeting_section: meeting_agenda_item.meeting_section) + end + + def move_item_to_other_section_via_turbo_stream(old_section:, current_section:, meeting_agenda_item: @meeting_agenda_item) + move_item_via_turbo_stream(meeting_agenda_item:) + + # update the old section + update_section_header_via_turbo_stream(meeting_section: old_section) + + if old_section.agenda_items.empty? + update_section_via_turbo_stream(meeting_section: old_section) + else + update_show_items_of_section_via_turbo_stream(meeting_section: old_section) + end + + # update the new section + update_section_header_via_turbo_stream(meeting_section: current_section) + + if current_section.agenda_items.count == 1 + update_section_via_turbo_stream(meeting_section: current_section) + else + update_show_items_of_section_via_turbo_stream(meeting_section: current_section) end end @@ -187,17 +290,19 @@ def move_item_via_turbo_stream(meeting_agenda_item: @meeting_agenda_item) remove_via_turbo_stream(component: remove_component) component = MeetingAgendaItems::ItemComponent.new(state: :show, meeting_agenda_item:) - target_component = - if @meeting_agenda_item.lower_item - MeetingAgendaItems::ItemComponent.new( - state: :show, - meeting_agenda_item: @meeting_agenda_item.lower_item - ) - else - MeetingAgendaItems::ListComponent.new(meeting: @meeting) - end + + target_component = if meeting_agenda_item.lower_item + MeetingAgendaItems::ItemComponent.new( + state: :show, + meeting_agenda_item: meeting_agenda_item.lower_item + ) + else + MeetingSections::ShowComponent.new( + meeting_section: meeting_agenda_item.meeting_section + ) + end + add_before_via_turbo_stream(component:, target_component:) - update_show_items_via_turbo_stream end def render_base_error_in_flash_message_via_turbo_stream(errors) @@ -206,6 +311,94 @@ def render_base_error_in_flash_message_via_turbo_stream(errors) end end + def update_section_headers_via_turbo_stream(meeting: @meeting) + meeting.sections.each do |meeting_section| + update_section_header_via_turbo_stream(meeting_section:) + end + end + + def update_section_header_via_turbo_stream(meeting_section: @meeting_section, state: :show) + update_via_turbo_stream( + component: MeetingSections::HeaderComponent.new( + meeting_section:, + state: + ) + ) + end + + def update_section_via_turbo_stream(meeting_section: @meeting_section, form_hidden: true, form_type: :simple) + update_via_turbo_stream( + component: MeetingSections::ShowComponent.new( + meeting_section:, + form_type:, + form_hidden: + ) + ) + end + + def add_section_via_turbo_stream(meeting_section: @meeting_section) + if meeting_section.meeting.sections.count <= 2 + # hide blank slate again through rerendering the list component -> count == 0 + # or show section wrapper of first untitled section -> count == 1 + update_list_via_turbo_stream + # CODE MAINTENANCE: potentially loosing edit state in last section + else + append_via_turbo_stream( + component: MeetingSections::ShowComponent.new( + meeting_section: + ), + target_component: MeetingAgendaItems::ListComponent.new( + meeting: meeting_section.meeting + ) + ) + end + end + + def remove_section_via_turbo_stream(meeting_section: @meeting_section) + if meeting_section.meeting.sections.count <= 1 + # show blank slate again through rerendering the list component -> count == 0 + # or hide section wrapper of first (potentially) untitled section -> count == 1 + update_list_via_turbo_stream + # CODE MAINTENANCE: potentially loosing edit state in last section + else + remove_via_turbo_stream( + component: MeetingSections::ShowComponent.new( + meeting_section: + ) + ) + end + end + + def move_section_via_turbo_stream(meeting_section: @meeting_section) + # Note: The `remove_component` and the `component` are pointing to the same + # component, but we still need to instantiate them separately, otherwise re-adding + # of the item will render and empty component. + remove_component = MeetingSections::ShowComponent.new(meeting_section:) + remove_via_turbo_stream(component: remove_component) + + component = MeetingSections::ShowComponent.new(meeting_section:) + + if meeting_section.lower_item + add_before_via_turbo_stream( + component:, + target_component: MeetingSections::ShowComponent.new( + meeting_section: meeting_section.lower_item, + insert_target_modified: false + # insert target is modified for agenda items in this section, but not for sections + ) + ) + else + append_via_turbo_stream( + component: MeetingSections::ShowComponent.new( + meeting_section: + ), + target_component: MeetingAgendaItems::ListComponent.new( + meeting: meeting_section.meeting + ) + ) + end + end + def update_all_via_turbo_stream update_header_component_via_turbo_stream update_sidebar_component_via_turbo_stream diff --git a/modules/meeting/app/controllers/meeting_agenda_items_controller.rb b/modules/meeting/app/controllers/meeting_agenda_items_controller.rb index dbef2a15100b..2a4c5859ae41 100644 --- a/modules/meeting/app/controllers/meeting_agenda_items_controller.rb +++ b/modules/meeting/app/controllers/meeting_agenda_items_controller.rb @@ -40,8 +40,10 @@ class MeetingAgendaItemsController < ApplicationController def new if @meeting.open? - update_new_component_via_turbo_stream(hidden: false, type: @agenda_item_type) - update_new_button_via_turbo_stream(disabled: true) + if params[:meeting_section_id].present? + meeting_section = @meeting.sections.find(params[:meeting_section_id]) + end + render_agenda_item_form_via_turbo_stream(meeting_section:, type: @agenda_item_type) else update_all_via_turbo_stream render_error_flash_message_via_turbo_stream(message: t("text_meeting_not_editable_anymore")) @@ -51,14 +53,23 @@ def new end def cancel_new - update_new_component_via_turbo_stream(hidden: true) + if params[:meeting_section_id].present? + meeting_section = @meeting.sections.find(params[:meeting_section_id]) + if meeting_section.agenda_items.empty? + update_section_via_turbo_stream(form_hidden: true, meeting_section:) + else + update_new_button_via_turbo_stream(disabled: false, meeting_section:) + end + end + + update_new_component_via_turbo_stream(hidden: true, meeting_section:) update_new_button_via_turbo_stream(disabled: false) respond_with_turbo_streams end def create - clear_slate = @meeting.agenda_items.empty? + # clear_slate = @meeting.agenda_items.empty? call = ::MeetingAgendaItems::CreateService .new(user: current_user) @@ -73,7 +84,7 @@ def create if call.success? # enable continue editing - add_item_via_turbo_stream(clear_slate:) + add_item_via_turbo_stream(clear_slate: false) update_header_component_via_turbo_stream update_sidebar_details_component_via_turbo_stream else @@ -111,6 +122,7 @@ def update if call.success? update_item_via_turbo_stream + update_section_header_via_turbo_stream(meeting_section: @meeting_agenda_item.meeting_section) update_header_component_via_turbo_stream update_sidebar_details_component_via_turbo_stream else @@ -123,6 +135,8 @@ def update end def destroy + section = @meeting_agenda_item.meeting_section + call = ::MeetingAgendaItems::DeleteService .new(user: current_user, model: @meeting_agenda_item) .call @@ -130,6 +144,7 @@ def destroy if call.success? remove_item_via_turbo_stream(clear_slate: @meeting.agenda_items.empty?) update_header_component_via_turbo_stream + update_section_header_via_turbo_stream(meeting_section: section) if section&.reload.present? update_sidebar_details_component_via_turbo_stream else generic_call_failure_response(call) @@ -139,13 +154,22 @@ def destroy end def drop - call = ::MeetingAgendaItems::UpdateService - .new(user: current_user, model: @meeting_agenda_item) - .call(position: params[:position].to_i) + call = ::MeetingAgendaItems::DropService.new( + user: current_user, meeting_agenda_item: @meeting_agenda_item + ).call( + target_id: params[:target_id], + position: params[:position] + ) if call.success? - update_show_items_via_turbo_stream - update_header_component_via_turbo_stream + if call.result[:section_changed] + move_item_to_other_section_via_turbo_stream( + old_section: call.result[:old_section], + current_section: call.result[:current_section] + ) + else + move_item_within_section_via_turbo_stream + end else generic_call_failure_response(call) end @@ -159,7 +183,7 @@ def move .call(move_to: params[:move_to]&.to_sym) if call.success? - move_item_via_turbo_stream + move_item_within_section_via_turbo_stream update_header_component_via_turbo_stream else generic_call_failure_response(call) @@ -168,17 +192,6 @@ def move respond_with_turbo_streams end - # Primer's autocomplete displays the ID of a user when selected instead of the name - # this cannot be changed at the moment as the component uses a simple text field which - # can't differentiate between a display and submit value - # thus, we can't use it - # leaving the code here for future reference - # def author_autocomplete_index - # @users = User.active.like(params[:q]).limit(10) - - # render(Authors::AutocompleteItemComponent.with_collection(@users), layout: false) - # end - private def set_meeting @@ -197,7 +210,7 @@ def set_meeting_agenda_item def meeting_agenda_item_params params .require(:meeting_agenda_item) - .permit(:title, :duration_in_minutes, :presenter_id, :notes, :work_package_id, :lock_version) + .permit(:title, :duration_in_minutes, :presenter_id, :notes, :work_package_id, :lock_version, :meeting_section_id) end def generic_call_failure_response(call) diff --git a/modules/meeting/app/controllers/meeting_sections_controller.rb b/modules/meeting/app/controllers/meeting_sections_controller.rb new file mode 100644 index 000000000000..afd8921b0d6e --- /dev/null +++ b/modules/meeting/app/controllers/meeting_sections_controller.rb @@ -0,0 +1,188 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingSectionsController < ApplicationController + include AttachableServiceCall + include OpTurbo::ComponentStream + include Meetings::AgendaComponentStreams + include ApplicationComponentStreams + + before_action :set_meeting + before_action :set_meeting_section, + except: %i[create] + before_action :authorize + + def create + call = ::MeetingSections::CreateService + .new(user: current_user) + .call( + { + meeting_id: @meeting.id, + title: t("meeting_section.default_title") + } + ) + + @meeting_section = call.result + + if call.success? + add_section_via_turbo_stream + update_section_header_via_turbo_stream(state: :edit) + update_new_button_via_turbo_stream(disabled: true) + else + render_base_error_in_flash_message_via_turbo_stream(call.errors) + end + + respond_with_turbo_streams + end + + def edit + if @meeting_section.editable? + update_section_header_via_turbo_stream(state: :edit) + else + update_all_via_turbo_stream + render_error_flash_message_via_turbo_stream(message: t("text_meeting_not_editable_anymore")) + end + + respond_with_turbo_streams + end + + def cancel_edit + if @meeting_section.has_default_title? && @meeting_section.agenda_items.empty? + # if the section has the default title and no agenda items, we can safely delete it + destroy and return + else + update_section_header_via_turbo_stream(state: :show) + update_new_button_via_turbo_stream(disabled: false) + end + + respond_with_turbo_streams + end + + def update + call = ::MeetingSections::UpdateService + .new(user: current_user, model: @meeting_section) + .call(meeting_section_params) + + if call.success? + update_section_header_via_turbo_stream(state: :show) + update_new_button_via_turbo_stream(disabled: false) + update_header_component_via_turbo_stream + update_sidebar_details_component_via_turbo_stream + else + # show errors + update_section_header_via_turbo_stream(state: :edit) + render_base_error_in_flash_message_via_turbo_stream(call.errors) + end + + respond_with_turbo_streams + end + + def destroy + call = ::MeetingSections::DeleteService + .new(user: current_user, model: @meeting_section) + .call + + if call.success? + remove_section_via_turbo_stream + # in case the destroy action was called from the cancel_edit action + # we need to update the new button state, which was disabled before + update_new_button_via_turbo_stream(disabled: false) + else + generic_call_failure_response(call) + end + + respond_with_turbo_streams + end + + def drop + call = ::MeetingSections::UpdateService + .new(user: current_user, model: @meeting_section) + .call(position: params[:position].to_i) + + if call.success? + # the DOM is already updated on the client-side through the drag + # in order to preserve an edit state within the section, we don't send a server-side rendered update + update_header_component_via_turbo_stream + # update all time slots as a section position change affects potentially all time slots + update_show_items_via_turbo_stream + else + generic_call_failure_response(call) + end + + respond_with_turbo_streams + end + + def move + call = ::MeetingSections::UpdateService + .new(user: current_user, model: @meeting_section) + .call(move_to: params[:move_to]&.to_sym) + + if call.success? + move_section_via_turbo_stream + # CODE MAINTENANCE: edit state within the moved section potentially gets lost + # unlike at the drop action, we need to send server-side rendered updates in order to reflect the new position + # thus an edit state inside the section gets lost + update_header_component_via_turbo_stream + # update all time slots as a section position change affects potentially all time slots + update_show_items_via_turbo_stream + else + generic_call_failure_response(call) + end + + respond_with_turbo_streams + end + + private + + def set_meeting + @meeting = Meeting.find(params[:meeting_id]) + @project = @meeting.project # required for authorization via before_action + end + + def set_agenda_item_type + @agenda_item_type = params[:type]&.to_sym + end + + def set_meeting_section + @meeting_section = MeetingSection.find(params[:id]) + end + + def meeting_section_params + params + .require(:meeting_section) + .permit(:title) + end + + def generic_call_failure_response(call) + # A failure might imply that the meeting was already closed and the action was triggered from a stale browser window + # updating all components resolves the stale state of that window + update_all_via_turbo_stream + # show additional base error message + render_base_error_in_flash_message_via_turbo_stream(call.errors) + end +end diff --git a/modules/meeting/app/forms/meeting_agenda_item/meeting_section_form.rb b/modules/meeting/app/forms/meeting_agenda_item/meeting_section_form.rb new file mode 100644 index 000000000000..8e2c35031cbd --- /dev/null +++ b/modules/meeting/app/forms/meeting_agenda_item/meeting_section_form.rb @@ -0,0 +1,40 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingAgendaItem::MeetingSectionForm < ApplicationForm + form do |agenda_item_form| + agenda_item_form.hidden( + name: :meeting_section_id, + value: @meeting_section&.id + ) + end + + def initialize(meeting_section: nil) + @meeting_section = meeting_section + end +end diff --git a/modules/meeting/app/forms/meeting_section/submit.rb b/modules/meeting/app/forms/meeting_section/submit.rb new file mode 100644 index 000000000000..94279486aeda --- /dev/null +++ b/modules/meeting/app/forms/meeting_section/submit.rb @@ -0,0 +1,33 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingSection::Submit < ApplicationForm + form do |meeting_section_form| + meeting_section_form.submit(name: :submit, label: I18n.t("button_save"), scheme: :primary) + end +end diff --git a/modules/meeting/app/forms/meeting_section/title.rb b/modules/meeting/app/forms/meeting_section/title.rb new file mode 100644 index 000000000000..41e71e5ccc21 --- /dev/null +++ b/modules/meeting/app/forms/meeting_section/title.rb @@ -0,0 +1,55 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingSection::Title < ApplicationForm + form do |meeting_section_form| + meeting_section_form.text_field( + name: :title, + placeholder: Meeting.human_attribute_name(:title), + label: Meeting.human_attribute_name(:title), + value: init_value(meeting_section_form), + visually_hide_label: true, + required: true, + autofocus: true, + bg: :default, + data: { + action: "keydown.esc->meeting-section-form#cancel" + } + ) + end + + private + + def init_value(meeting_section_form) + if meeting_section_form.builder.object.has_default_title? || meeting_section_form.builder.object.untitled? + "" # will be set to "Untitled" in the model if left empty + else + meeting_section_form.builder.object.title + end + end +end diff --git a/modules/meeting/app/models/meeting.rb b/modules/meeting/app/models/meeting.rb index c777a5516513..9116281ae333 100644 --- a/modules/meeting/app/models/meeting.rb +++ b/modules/meeting/app/models/meeting.rb @@ -30,26 +30,27 @@ class Meeting < ApplicationRecord include VirtualAttribute include OpenProject::Journal::AttachmentHelper - self.table_name = 'meetings' + self.table_name = "meetings" belongs_to :project - belongs_to :author, class_name: 'User' - has_one :agenda, dependent: :destroy, class_name: 'MeetingAgenda' - has_one :minutes, dependent: :destroy, class_name: 'MeetingMinutes' - has_many :contents, -> { readonly }, class_name: 'MeetingContent' + belongs_to :author, class_name: "User" + has_one :agenda, dependent: :destroy, class_name: "MeetingAgenda" + has_one :minutes, dependent: :destroy, class_name: "MeetingMinutes" + has_many :contents, -> { readonly }, class_name: "MeetingContent" has_many :participants, dependent: :destroy, - class_name: 'MeetingParticipant', + class_name: "MeetingParticipant", after_add: :send_participant_added_mail - has_many :agenda_items, dependent: :destroy, class_name: 'MeetingAgendaItem' + has_many :sections, dependent: :destroy, class_name: "MeetingSection" + has_many :agenda_items, dependent: :destroy, class_name: "MeetingAgendaItem" default_scope do order("#{Meeting.table_name}.start_time DESC") end - scope :from_tomorrow, -> { where(['start_time >= ?', Date.tomorrow.beginning_of_day]) } - scope :from_today, -> { where(['start_time >= ?', Time.zone.today.beginning_of_day]) } + scope :from_tomorrow, -> { where(["start_time >= ?", Date.tomorrow.beginning_of_day]) } + scope :from_today, -> { where(["start_time >= ?", Time.zone.today.beginning_of_day]) } scope :with_users_by_date, -> { order("#{Meeting.table_name}.title ASC") .includes({ participants: :user }, :author) @@ -109,21 +110,6 @@ class Meeting < ApplicationRecord closed: 5 } - # => {"agenda_items_7"=>{"title"=>["New agenda item edited", "New agenda item edited again"], "duration_in_minutes"=>["5", "3"], "notes"=>["Notes added as well", "Notes edited"]}} - -# => {"project_id"=>[nil, 14], -# "user_id"=>[nil, 9], -# "work_package_id"=>[nil, 48211], -# "hours"=>[nil, 1.0], -# "comments"=>[nil, "Alex"], -# "activity_id"=>[nil, 8], -# "spent_on"=>[nil, Mon, 31 Jul 2023], -# "tyear"=>[nil, 2023], -# "tmonth"=>[nil, 7], -# "tweek"=>[nil, 31], -# "costs"=>[nil, 0.0], -# "logged_by_id"=>[nil, 9]} - ## # Return the computed start_time when changed def start_time @@ -214,7 +200,7 @@ def close_agenda_and_copy_to_minutes! attachments = agenda.attachments.map { |a| [a, a.copy] } original_text = String(agenda.text) minutes = create_minutes(text: original_text, - journal_notes: I18n.t('events.meeting_minutes_created'), + journal_notes: I18n.t("events.meeting_minutes_created"), attachments: attachments.map(&:last)) # substitute attachment references in text to use the respective copied attachments @@ -233,7 +219,7 @@ def close_agenda_and_copy_to_minutes! def participants_attributes=(attrs) attrs.each do |participant| - participant['_destroy'] = true if !(participant[:attended] || participant[:invited]) + participant["_destroy"] = true if !(participant[:attended] || participant[:invited]) end self.original_participants_attributes = attrs end @@ -260,7 +246,7 @@ def set_initial_values def update_derived_fields @start_date = start_time.to_date.iso8601 - @start_time_hour = start_time.strftime('%H:%M') + @start_time_hour = start_time.strftime("%H:%M") end private @@ -319,7 +305,7 @@ def parsed_start_date ## # Enforce HH::MM time parsing for the given input string def parsed_start_time_hour - Time.strptime(@start_time_hour, '%H:%M') + Time.strptime(@start_time_hour, "%H:%M") rescue ArgumentError nil end diff --git a/modules/meeting/app/models/meeting_agenda_item.rb b/modules/meeting/app/models/meeting_agenda_item.rb index 3babdc799c92..cd52da17a98f 100644 --- a/modules/meeting/app/models/meeting_agenda_item.rb +++ b/modules/meeting/app/models/meeting_agenda_item.rb @@ -36,12 +36,13 @@ class MeetingAgendaItem < ApplicationRecord enum item_type: ITEM_TYPES belongs_to :meeting, class_name: "StructuredMeeting" + belongs_to :meeting_section, optional: false belongs_to :work_package, class_name: "::WorkPackage" has_one :project, through: :meeting belongs_to :author, class_name: "User", optional: false belongs_to :presenter, class_name: "User", optional: true - acts_as_list scope: :meeting + acts_as_list scope: :meeting_section default_scope { order(:position) } scope :with_includes_to_render, -> { includes(:author, :meeting) } @@ -61,16 +62,41 @@ class MeetingAgendaItem < ApplicationRecord numericality: { greater_than_or_equal_to: 0, less_than_or_equal_to: 1440 }, allow_nil: true + before_validation :add_to_latest_meeting_section + after_create :trigger_meeting_agenda_item_time_slots_calculation after_save :trigger_meeting_agenda_item_time_slots_calculation, if: Proc.new { |item| item.duration_in_minutes_previously_changed? || item.position_previously_changed? } after_destroy :trigger_meeting_agenda_item_time_slots_calculation + # after_destroy :delete_meeting_section_if_empty + + def add_to_latest_meeting_section + return if meeting.nil? + + if meeting_section_id.nil? + meeting_section = meeting.sections.order(position: :asc).last + + if meeting_section.nil? + meeting_section = meeting.sections.build(title: "Untitled") + end + + self.meeting_section = meeting_section + end + end def trigger_meeting_agenda_item_time_slots_calculation meeting.calculate_agenda_item_time_slots end + # def delete_meeting_section_if_empty + # # we need to delete the last existing section if the last meeting agenda item is deleted + # # as we don't render the section (including the section menu) if only one section exists + # # thus the section would silently exist in the database when the very last agenda item was deleted + # # which makes UI rendering inconsistent + # meeting_section.destroy if meeting_section.agenda_items.empty? && meeting.sections.count == 1 + # end + def linked_work_package? item_type == "work_package" && work_package.present? end diff --git a/modules/meeting/app/models/meeting_section.rb b/modules/meeting/app/models/meeting_section.rb new file mode 100644 index 000000000000..df782a9068d7 --- /dev/null +++ b/modules/meeting/app/models/meeting_section.rb @@ -0,0 +1,78 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class MeetingSection < ApplicationRecord + self.table_name = "meeting_sections" + + belongs_to :meeting + + has_many :agenda_items, dependent: :destroy, class_name: "MeetingAgendaItem" + has_one :project, through: :meeting + + validates :title, presence: true + + before_validation :set_default_title + + after_save :trigger_meeting_agenda_item_time_slots_calculation, if: Proc.new { |section| + section.position_previously_changed? + } + + acts_as_list scope: :meeting + + default_scope { order(:position) } + + def trigger_meeting_agenda_item_time_slots_calculation + meeting.calculate_agenda_item_time_slots + end + + def set_default_title + if title.blank? + self.title = I18n.t("meeting_section.untitled_title") + end + end + + def has_default_title? + title == I18n.t("meeting_section.default_title") + end + + def untitled? + title == I18n.t("meeting_section.untitled_title") + end + + def editable? + !meeting&.closed? + end + + def modifiable? + !meeting&.closed? + end + + def agenda_items_sum_duration_in_minutes + agenda_items.sum(:duration_in_minutes) + end +end diff --git a/modules/meeting/app/models/structured_meeting.rb b/modules/meeting/app/models/structured_meeting.rb index c4d42d469791..93a998a368e2 100644 --- a/modules/meeting/app/models/structured_meeting.rb +++ b/modules/meeting/app/models/structured_meeting.rb @@ -38,7 +38,7 @@ class StructuredMeeting < Meeting def calculate_agenda_item_time_slots current_time = start_time MeetingAgendaItem.transaction do - changed_items = agenda_items.order(:position).map do |top| + changed_items = agenda_items.includes(:meeting_section).reorder("meeting_sections.position", :position).map do |top| start_time = current_time current_time += top.duration_in_minutes&.minutes || 0.minutes end_time = current_time diff --git a/modules/meeting/app/services/meeting_agenda_items/drop_service.rb b/modules/meeting/app/services/meeting_agenda_items/drop_service.rb new file mode 100644 index 000000000000..29fccee1d7a4 --- /dev/null +++ b/modules/meeting/app/services/meeting_agenda_items/drop_service.rb @@ -0,0 +1,117 @@ +#-- 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 MeetingAgendaItems + class DropService < ::BaseServices::BaseCallable + include AfterPerformHook + + def initialize(user:, meeting_agenda_item:) + super() + @user = user + @meeting_agenda_item = meeting_agenda_item + @meeting = meeting_agenda_item.meeting + end + + def perform(params) + service_call = validate_permission + service_call = validate_meeting_existence if service_call.success? + service_call = validate_meeting_agenda_item_editable if service_call.success? + + service_call = perform_drop(service_call, params) if service_call.success? + + # after_perform(service_call) if service_call.success? # TODO properly integrate after_perform_hook + + service_call + end + + def validate_permission + if @user.allowed_in_project?(:manage_agendas, @meeting.project) + ServiceResult.success + else + ServiceResult.failure(errors: { base: :error_unauthorized }) + end + end + + def validate_meeting_existence + if @meeting.present? + ServiceResult.success + else + ServiceResult.failure(errors: { base: :does_not_exist }) + end + end + + def validate_meeting_agenda_item_editable + if @meeting_agenda_item.editable? + 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 = @meeting_agenda_item.meeting_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 = MeetingSection.find(new_section_id) + @meeting_agenda_item.remove_from_list + @meeting_agenda_item.update(meeting_section: current_section) + current_section + end + + def update_position(new_position) + @meeting_agenda_item.insert_at(new_position) + end + end +end diff --git a/modules/meeting/app/services/meeting_sections/create_service.rb b/modules/meeting/app/services/meeting_sections/create_service.rb new file mode 100644 index 000000000000..2024669c06b4 --- /dev/null +++ b/modules/meeting/app/services/meeting_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 MeetingSections + class CreateService < ::BaseServices::Create + end +end diff --git a/modules/meeting/app/services/meeting_sections/delete_service.rb b/modules/meeting/app/services/meeting_sections/delete_service.rb new file mode 100644 index 000000000000..160d428c99d7 --- /dev/null +++ b/modules/meeting/app/services/meeting_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 MeetingSections + class DeleteService < ::BaseServices::Delete + end +end diff --git a/modules/meeting/app/services/meeting_sections/set_attributes_service.rb b/modules/meeting/app/services/meeting_sections/set_attributes_service.rb new file mode 100644 index 000000000000..cbfbd5880143 --- /dev/null +++ b/modules/meeting/app/services/meeting_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 MeetingSections + class SetAttributesService < ::BaseServices::SetAttributes + end +end diff --git a/modules/meeting/app/services/meeting_sections/update_service.rb b/modules/meeting/app/services/meeting_sections/update_service.rb new file mode 100644 index 000000000000..025eafc8cf62 --- /dev/null +++ b/modules/meeting/app/services/meeting_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 MeetingSections + class UpdateService < ::BaseServices::Update + end +end diff --git a/modules/meeting/app/services/meetings/copy_service.rb b/modules/meeting/app/services/meetings/copy_service.rb index 2537727a1042..c20714f8de94 100644 --- a/modules/meeting/app/services/meetings/copy_service.rb +++ b/modules/meeting/app/services/meetings/copy_service.rb @@ -103,8 +103,14 @@ def update_references(attachment_source:, attachment_target:, model_source:, mod def copy_meeting_agenda(copy) if meeting.is_a?(StructuredMeeting) - meeting.agenda_items.each do |agenda_item| - copy.agenda_items << agenda_item.dup + meeting.sections.each do |section| + copy.sections << section.dup + copied_section = copy.reload.sections.last + section.agenda_items.each do |agenda_item| + copied_agenda_item = agenda_item.dup + copied_agenda_item.meeting_id = copy.id + copied_section.agenda_items << copied_agenda_item + end end else MeetingAgenda.create!( diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 2064001e8f64..f5971fb325f4 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -59,6 +59,8 @@ en: duration_in_minutes: "min" description: "Notes" presenter: "Presenter" + meeting_section: + title: "Title" errors: messages: invalid_time_format: "is not a valid time. Required format: HH:MM" @@ -140,6 +142,11 @@ en: structured_text_copy: "Copying a meeting will currently not copy the associated meeting agenda items, just the details" copied: "Copied from Meeting #%{id}" + meeting_section: + default_title: "New section" + untitled_title: "Untitled" + empty_text: "Drag items here or create a new one" + notice_successful_notification: "Notification sent successfully" notice_timezone_missing: No time zone is set and %{zone} is assumed. To choose your time zone, please click here. diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index acdad57b0da8..0044abb9ec46 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -34,15 +34,15 @@ resources :work_packages, only: %i[] do resources :meetings, only: %i[] do collection do - resources :tab, only: %i[index], controller: 'work_package_meetings_tab', as: 'meetings_tab' do + resources :tab, only: %i[index], controller: "work_package_meetings_tab", as: "meetings_tab" do get :count, on: :collection end end end resources :meeting_agenda_items, only: %i[] do collection do - get :dialog, controller: 'work_package_meetings_tab', action: :add_work_package_to_meeting_dialog - post :create, controller: 'work_package_meetings_tab', action: :add_work_package_to_meeting + get :dialog, controller: "work_package_meetings_tab", action: :add_work_package_to_meeting_dialog + post :create, controller: "work_package_meetings_tab", action: :add_work_package_to_meeting end end end @@ -59,11 +59,21 @@ post :notify get :history end - resources :agenda_items, controller: 'meeting_agenda_items' do + resources :agenda_items, controller: "meeting_agenda_items" do + collection do + get :new, action: :new, as: :new + get :cancel_new + end + member do + get :cancel_edit + put :drop + put :move + end + end + resources :sections, controller: "meeting_sections" do collection do get :new, action: :new, as: :new get :cancel_new - # get :author_autocomplete_index end member do get :cancel_edit @@ -72,7 +82,7 @@ end end - resource :agenda, controller: 'meeting_agendas', only: [:update] do + resource :agenda, controller: "meeting_agendas", only: [:update] do member do get :history get :diff @@ -82,17 +92,17 @@ end resources :versions, only: [:show], - controller: 'meeting_agendas' + controller: "meeting_agendas" end - resource :contents, controller: 'meeting_contents', only: %i[show update] do + resource :contents, controller: "meeting_contents", only: %i[show update] do member do get :history get :diff end end - resource :minutes, controller: 'meeting_minutes', only: [:update] do + resource :minutes, controller: "meeting_minutes", only: [:update] do member do get :history get :diff @@ -100,14 +110,14 @@ end resources :versions, only: [:show], - controller: 'meeting_minutes' + controller: "meeting_minutes" end member do get :copy - match '/:tab' => 'meetings#show', :constraints => { tab: /(agenda|minutes)/ }, + match "/:tab" => "meetings#show", :constraints => { tab: /(agenda|minutes)/ }, :via => :get, - :as => 'tab' + :as => "tab" end end end diff --git a/modules/meeting/db/migrate/20240405131352_create_meeting_sections.rb b/modules/meeting/db/migrate/20240405131352_create_meeting_sections.rb new file mode 100644 index 000000000000..dd4a2688759b --- /dev/null +++ b/modules/meeting/db/migrate/20240405131352_create_meeting_sections.rb @@ -0,0 +1,34 @@ +class CreateMeetingSections < ActiveRecord::Migration[7.1] + def up + create_table :meeting_sections do |t| + t.integer :position + t.string :title + t.references :meeting, null: false, foreign_key: true + + t.timestamps + end + + add_reference :meeting_agenda_items, :meeting_section + + create_and_assign_default_section + end + + def down + remove_reference :meeting_agenda_items, :meeting_section + drop_table :meeting_sections + # TODO: positions of agenda items are now not valid anymore as they have been scoped to sections + # Do we need to catch this? + end + + private + + def create_and_assign_default_section + StructuredMeeting.includes(:agenda_items).find_each do |meeting| + section = MeetingSection.create!( + meeting:, + title: "Untitled" + ) + meeting.agenda_items.update_all(meeting_section_id: section.id) + end + end +end diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index dccd322a8bf1..ca96c171ed11 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -26,8 +26,8 @@ # See COPYRIGHT and LICENSE files for more details. #++ -require 'open_project/plugins' -require_relative 'patches/api/work_package_representer' +require "open_project/plugins" +require_relative "patches/api/work_package_representer" module OpenProject::Meeting class Engine < ::Rails::Engine @@ -35,8 +35,8 @@ class Engine < ::Rails::Engine include OpenProject::Plugins::ActsAsOpEngine - register 'openproject-meeting', - author_url: 'https://www.openproject.org', + register "openproject-meeting", + author_url: "https://www.openproject.org", bundled: true do project_module :meetings do permission :view_meetings, @@ -73,7 +73,8 @@ class Engine < ::Rails::Engine require: :member permission :manage_agendas, { - meeting_agenda_items: %i[new cancel_new create edit cancel_edit update destroy drop move] + meeting_agenda_items: %i[new cancel_new create edit cancel_edit update destroy drop move], + meeting_sections: %i[new cancel_new create edit cancel_edit update destroy drop move] }, permissible_on: :project, # TODO: Change this to :meeting when MeetingRoles are available require: :member @@ -110,16 +111,16 @@ class Engine < ::Rails::Engine end menu :project_menu, - :meetings, { controller: '/meetings', action: 'index' }, + :meetings, { controller: "/meetings", action: "index" }, caption: :project_module_meetings, after: :wiki, before: :members, - icon: 'meetings' + icon: "meetings" menu :project_menu, - :meetings_query_select, { controller: '/meetings', action: 'index' }, + :meetings_query_select, { controller: "/meetings", action: "index" }, parent: :meetings, - partial: 'meetings/menu_query_select' + partial: "meetings/menu_query_select" should_render_global_menu_item = Proc.new do (User.current.logged? || !Setting.login_required?) && @@ -127,32 +128,32 @@ class Engine < ::Rails::Engine end menu :top_menu, - :meetings, { controller: '/meetings', action: 'index', project_id: nil }, + :meetings, { controller: "/meetings", action: "index", project_id: nil }, context: :modules, caption: :label_meeting_plural, last: true, - icon: 'meetings', + icon: "meetings", if: should_render_global_menu_item menu :global_menu, - :meetings, { controller: '/meetings', action: 'index', project_id: nil }, + :meetings, { controller: "/meetings", action: "index", project_id: nil }, caption: :label_meeting_plural, last: true, - icon: 'meetings', + icon: "meetings", if: should_render_global_menu_item menu :global_menu, - :meetings_query_select, { controller: '/meetings', action: 'index', project_id: nil }, + :meetings_query_select, { controller: "/meetings", action: "index", project_id: nil }, parent: :meetings, - partial: 'meetings/menu_query_select', + partial: "meetings/menu_query_select", if: should_render_global_menu_item ActiveSupport::Inflector.inflections do |inflect| - inflect.uncountable 'meeting_minutes' + inflect.uncountable "meeting_minutes" end end - activity_provider :meetings, class_name: 'Activities::MeetingActivityProvider', default: false + activity_provider :meetings, class_name: "Activities::MeetingActivityProvider", default: false patches [:Project] patch_with_namespace :BasicData, :SettingSeeder @@ -160,13 +161,13 @@ class Engine < ::Rails::Engine extend_api_response(:v3, :work_packages, :work_package, &::OpenProject::Meeting::Patches::API::WorkPackageRepresenter.extension) - add_api_endpoint 'API::V3::Root' do + add_api_endpoint "API::V3::Root" do mount ::API::V3::Meetings::MeetingsAPI mount ::API::V3::Meetings::MeetingContentsAPI end config.to_prepare do - OpenProject::ProjectLatestActivity.register on: 'Meeting' + OpenProject::ProjectLatestActivity.register on: "Meeting" PermittedParams.permit(:search, :meetings) end diff --git a/modules/meeting/spec/contracts/meeting_sections/create_contract_spec.rb b/modules/meeting/spec/contracts/meeting_sections/create_contract_spec.rb new file mode 100644 index 000000000000..5ac3b116c9dc --- /dev/null +++ b/modules/meeting/spec/contracts/meeting_sections/create_contract_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe MeetingSections::CreateContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + let(:meeting) { create(:structured_meeting, project:) } + let(:section) { build(:meeting_section, meeting:) } + let(:contract) { described_class.new(section, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => %i[view_meetings manage_agendas] }) + end + + it_behaves_like "contract is valid" + + context "when :meeting is not editable" do + before do + meeting.update_column(:state, :closed) + end + + it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore) + end + + context "when :meeting is not present anymore" do + before do + meeting.destroy + end + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :does_not_exist + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end diff --git a/modules/meeting/spec/contracts/meeting_sections/delete_contract_spec.rb b/modules/meeting/spec/contracts/meeting_sections/delete_contract_spec.rb new file mode 100644 index 000000000000..a589f91c4fb8 --- /dev/null +++ b/modules/meeting/spec/contracts/meeting_sections/delete_contract_spec.rb @@ -0,0 +1,75 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe MeetingSections::DeleteContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + shared_let(:meeting) { create(:structured_meeting, project:) } + let(:section) { create(:meeting_section, meeting:) } + let(:contract) { described_class.new(section, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => [:manage_agendas] }) + end + + it_behaves_like "contract is valid" + + context "when :meeting is not editable" do + before do + meeting.update_column(:state, :closed) + end + + it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore) + end + + context "when meeting_agenda_items are present" do + before do + create(:meeting_agenda_item, meeting_section: section) + end + + it_behaves_like "contract is invalid", base: "Section is not empty and cannot be deleted." + end + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end diff --git a/modules/meeting/spec/contracts/meeting_sections/update_contract_spec.rb b/modules/meeting/spec/contracts/meeting_sections/update_contract_spec.rb new file mode 100644 index 000000000000..46e901963cba --- /dev/null +++ b/modules/meeting/spec/contracts/meeting_sections/update_contract_spec.rb @@ -0,0 +1,67 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "spec_helper" +require "contracts/shared/model_contract_shared_context" + +RSpec.describe MeetingSections::UpdateContract do + include_context "ModelContract shared context" + + shared_let(:project) { create(:project) } + shared_let(:meeting) { create(:structured_meeting, project:) } + shared_let(:section) { create(:meeting_section, meeting:) } + let(:contract) { described_class.new(section, user) } + + context "with permission" do + let(:user) do + create(:user, member_with_permissions: { project => [:manage_agendas] }) + end + + it_behaves_like "contract is valid" + + context "when :meeting is not editable" do + before do + meeting.update_column(:state, :closed) + end + + it_behaves_like "contract is invalid", base: I18n.t(:text_agenda_item_not_editable_anymore) + end + end + + context "without permission" do + let(:user) { build_stubbed(:user) } + + it_behaves_like "contract is invalid", base: :error_unauthorized + end + + include_examples "contract reuses the model errors" do + let(:user) { build_stubbed(:user) } + end +end diff --git a/modules/meeting/spec/factories/meeting_section_factory.rb b/modules/meeting/spec/factories/meeting_section_factory.rb new file mode 100644 index 000000000000..9d1b469bdfb3 --- /dev/null +++ b/modules/meeting/spec/factories/meeting_section_factory.rb @@ -0,0 +1,35 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +FactoryBot.define do + factory :meeting_section do |m| + meeting factory: :structured_meeting + + m.sequence(:title) { |n| "Section #{n}" } + end +end diff --git a/modules/meeting/spec/features/structured_meetings/history_spec.rb b/modules/meeting/spec/features/structured_meetings/history_spec.rb index 909cc988e6fc..24ee173c99f5 100644 --- a/modules/meeting/spec/features/structured_meetings/history_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/history_spec.rb @@ -161,6 +161,10 @@ click_on "Save" end + # dynamically wait for the item to be updated successfully + # before checking the history modal, otherwise running into timing issues + show_page.expect_agenda_item(title: "Updated title") + history_page.open_history_modal history_page.expect_event( 'Agenda item "Updated title"', @@ -202,6 +206,7 @@ show_page.remove_agenda_item(second) history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: 'Agenda item "Second"') expect(item).to have_css(".op-activity-list--item-subtitle", text: "deleted by") @@ -221,6 +226,7 @@ expect(wp_item).to be_present history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: work_package.to_s.strip) expect(item).to have_css(".op-activity-list--item-subtitle", text: "added by") @@ -241,6 +247,7 @@ expect(wp_item).to be_present history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: changed_wp.to_s.strip) expect(item).to have_css(".op-activity-list--item-subtitle", text: "updated by") @@ -271,6 +278,7 @@ # Is visible for user history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: other_wp.to_s.strip) expect(item).to have_css(".op-activity-list--item-subtitle", text: "added by") @@ -281,6 +289,7 @@ show_page.visit! history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: I18n.t(:label_agenda_item_undisclosed_wp, id: other_wp.id)) expect(item).to have_css(".op-activity-list--item-subtitle", text: "added by") @@ -289,6 +298,10 @@ login_as(user) show_page.visit! show_page.remove_agenda_item wp_item + + # dynamically wait for the item to be removed successfully + show_page.expect_no_agenda_item(title: wp_item.to_s) + history_page.open_history_modal item = history_page.first_item @@ -301,6 +314,7 @@ show_page.visit! history_page.open_history_modal + item = history_page.first_item expect(item).to have_css(".op-activity-list--item-title", text: I18n.t(:label_agenda_item_undisclosed_wp, id: other_wp.id)) expect(item).to have_css(".op-activity-list--item-subtitle", text: "removed by") @@ -317,7 +331,7 @@ show_page.expect_agenda_item(title: "My agenda item") item = MeetingAgendaItem.find_by(title: "My agenda item") - show_page.cancel_add_form + show_page.cancel_add_form(item) show_page.select_action(item, "Add notes") editor.set_markdown "# Hello there" diff --git a/modules/meeting/spec/features/structured_meetings/structured_meeting_crud_spec.rb b/modules/meeting/spec/features/structured_meetings/structured_meeting_crud_spec.rb index a4985cf3f228..71b194955a88 100644 --- a/modules/meeting/spec/features/structured_meetings/structured_meeting_crud_spec.rb +++ b/modules/meeting/spec/features/structured_meetings/structured_meeting_crud_spec.rb @@ -90,15 +90,17 @@ end show_page.expect_agenda_item title: "My agenda item" - show_page.cancel_add_form - item = MeetingAgendaItem.find_by(title: "My agenda item") + show_page.cancel_add_form(item) + + # can update show_page.edit_agenda_item(item) do fill_in "Title", with: "Updated title" click_on "Save" end show_page.expect_no_agenda_item title: "My agenda item" + show_page.expect_agenda_item title: "Updated title" # Can add multiple items show_page.add_agenda_item do @@ -136,7 +138,7 @@ # Can remove show_page.remove_agenda_item first show_page.assert_agenda_order! "Updated title", "Second" - show_page.cancel_add_form + show_page.cancel_add_form(second) # Can link work packages show_page.add_agenda_item(type: WorkPackage) do @@ -164,7 +166,7 @@ end show_page.select_action(item, I18n.t(:label_sort_lowest)) - show_page.cancel_add_form + show_page.cancel_add_form(item) show_page.add_agenda_item do fill_in "Title", with: "My agenda item" @@ -223,6 +225,16 @@ click_on I18n.t(:label_icalendar_download) + # dynamically wait for download to finish, otherwise expectation is too early + seconds = 0 + while seconds < 5 + # don't use subject as it will not get reevaluated in the next iteration + break if @download_list.refresh_from(page).latest_download.to_s != "" + + sleep 1 + seconds += 1 + end + expect(subject).to end_with ".ics" end end @@ -237,9 +249,10 @@ end show_page.expect_agenda_item title: "My agenda item" - show_page.cancel_add_form - item = MeetingAgendaItem.find_by!(title: "My agenda item") + + show_page.cancel_add_form(item) + show_page.edit_agenda_item(item) do # Side effect: update the item item.update!(title: "Updated title") @@ -261,7 +274,9 @@ end show_page.expect_agenda_item title: "My agenda item" - show_page.cancel_add_form + item = MeetingAgendaItem.find_by!(title: "My agenda item") + + show_page.cancel_add_form(item) click_on("op-meetings-header-action-trigger") click_on "Copy" @@ -296,4 +311,133 @@ expect(page).to have_no_text "Private task" end end + + context "with sections" do + let!(:meeting) { create(:structured_meeting, project:, author: current_user) } + let(:show_page) { Pages::StructuredMeeting::Show.new(meeting) } + + context "when starting with empty sections" do + it "can add, edit and delete sections" do + show_page.expect_toast(message: "Successful creation") + + # create the first section + show_page.add_section do + fill_in "Title", with: "First section" + click_on "Save" + end + + show_page.expect_section(title: "First section") + + first_section = MeetingSection.find_by!(title: "First section") + + # edit the first section + show_page.edit_section(first_section) do + fill_in "Title", with: "Updated first section title" + click_on "Save" + end + + show_page.expect_no_section title: "First section" + show_page.expect_section title: "Updated first section title" + + # add a second section + show_page.add_section do + fill_in "Title", with: "Second section" + click_on "Save" + end + + show_page.expect_section(title: "Updated first section title") + show_page.expect_section(title: "Second section") + + second_section = MeetingSection.find_by!(title: "Second section") + + # remove the second section + show_page.remove_section second_section + + ## the first section is still rendered explicitly, as a name was specified + show_page.expect_section(title: "Updated first section title") + show_page.expect_no_section(title: "Second section") + + # add a section without a name + show_page.add_section do + click_on "Save" + end + + ## "Untitled" is applied automatically as a name + show_page.expect_section(title: "Updated first section title") + show_page.expect_section(title: "Untitled") + + # remove the first section + show_page.remove_section first_section + + ## the last existing section is not explicitly rendered as a section as no name was specified for this section + ## -> back to "no section mode" + show_page.expect_no_section(title: "Updated first section title") + show_page.expect_no_section(title: "Untitled") + + # add a second section again + show_page.add_section do + fill_in "Title", with: "Second section" + click_on "Save" + end + + ## the first section without a name is now explicitly rendered as "Untitled" + show_page.expect_section(title: "Untitled") + show_page.expect_section(title: "Second section") + + second_section = MeetingSection.find_by!(title: "Second section") + + # add an item to the latest section + show_page.add_agenda_item do + fill_in "Title", with: "First item" + fill_in "min", with: "25" + end + + show_page.expect_agenda_item_in_section title: "First item", section: second_section + + first_section = MeetingSection.find_by!(title: "Untitled") + + # add an item to the first section explicitly + show_page.add_agenda_item_to_section(section: first_section) do + fill_in "Title", with: "Second item" + fill_in "min", with: "30" + end + + show_page.expect_agenda_item_in_section title: "Second item", section: first_section + + # duration per section is shown + show_page.expect_section_duration(section: first_section, duration_text: "30 min") + show_page.expect_section_duration(section: second_section, duration_text: "25 min") + + item_in_first_section = MeetingAgendaItem.find_by!(title: "Second item") + item_in_second_section = MeetingAgendaItem.find_by!(title: "First item") + + show_page.edit_agenda_item(item_in_second_section) do + fill_in "min", with: "15" + click_on "Save" + end + + # duration gets updated + show_page.expect_section_duration(section: second_section, duration_text: "15 min") + + # deleting a section with agenda items is not possible + show_page.select_section_action(second_section, "Delete") # delete is disabled + ## -> no effect + show_page.expect_section(title: "Untitled") + show_page.expect_section(title: "Second section") + + show_page.click_on_section_menu(second_section) # close the menu again + + # deleting a section without agenda items is possible + show_page.remove_agenda_item(item_in_second_section) + show_page.remove_section(second_section) # empty secion gets deleted + + # only untitle secion is left -> will not be rendered explicitly as secion + show_page.expect_no_section(title: "Untitled") + show_page.expect_no_section(title: "Second section") + + # the agenda items of the "untitled" section are still visible in "no-section mode" + show_page.expect_agenda_item(title: item_in_first_section.title) + end + end + end end diff --git a/modules/meeting/spec/models/meeting_section_spec.rb b/modules/meeting/spec/models/meeting_section_spec.rb new file mode 100644 index 000000000000..328fe03e7edd --- /dev/null +++ b/modules/meeting/spec/models/meeting_section_spec.rb @@ -0,0 +1,98 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require_relative "../spec_helper" + +RSpec.describe MeetingSection do + let(:meeting_attributes) { {} } + let(:meeting) { build_stubbed(:structured_meeting, **meeting_attributes) } + let(:attributes) { {} } + let(:meeting_section) { described_class.new(meeting:, **attributes) } + + subject { meeting_section } + + describe "#title" do + let(:attributes) { { title: } } + + context "when title is missing" do + let(:title) { nil } + + it "sets a default title" do + expect(subject).to be_valid + expect(subject.title).to eq I18n.t("meeting_section.untitled_title") + end + end + + context "when title is blank" do + let(:title) { "" } + + it "sets a default title" do + expect(subject).to be_valid + expect(subject.title).to eq I18n.t("meeting_section.untitled_title") + end + end + + context "when title is present" do + let(:title) { "My section" } + + it "validates" do + expect(subject).to be_valid + expect(subject.title).to eq "My section" + end + end + end + + describe "#modifiable?" do + subject { meeting_section.modifiable? } + + let(:attributes) { {} } + + context "when meeting is closed" do + let(:meeting_attributes) { { state: :closed } } + + it { is_expected.to be false } + end + end + + describe "#agenda_items_sum_duration_in_minutes" do + subject { meeting_section.agenda_items_sum_duration_in_minutes } + + let(:meeting) { create(:structured_meeting, **meeting_attributes) } + let(:meeting_section) { create(:meeting_section, meeting:) } + + context "when there are no agenda items" do + it { is_expected.to eq 0 } + end + + context "when there are agenda items" do + let!(:agenda_item) { create(:meeting_agenda_item, meeting_section:, duration_in_minutes: 15) } + + it { is_expected.to eq 15 } + end + end +end diff --git a/modules/meeting/spec/services/meetings/copy_service_integration_spec.rb b/modules/meeting/spec/services/meetings/copy_service_integration_spec.rb index 346d25d98e87..3b6a3addbadf 100644 --- a/modules/meeting/spec/services/meetings/copy_service_integration_spec.rb +++ b/modules/meeting/spec/services/meetings/copy_service_integration_spec.rb @@ -100,7 +100,7 @@ end it "copies the agenda item" do - expect(copy.agenda_items.length) + expect(copy.reload.agenda_items.length) .to eq 1 expect(copy.agenda_items.first.notes) @@ -129,6 +129,7 @@ context "when asking to copy attachments" do let(:params) { { copy_attachments: true } } + it "copies the attachment" do expect(copy.attachments.length) .to eq 1 diff --git a/modules/meeting/spec/support/pages/structured_meeting/history.rb b/modules/meeting/spec/support/pages/structured_meeting/history.rb index a4df4a6f6911..33bcabb2ad07 100644 --- a/modules/meeting/spec/support/pages/structured_meeting/history.rb +++ b/modules/meeting/spec/support/pages/structured_meeting/history.rb @@ -37,6 +37,9 @@ def open_history_modal retry_block do click_link_or_button "op-meetings-header-action-trigger" click_link_or_button "History" + # dynamically wait for the modal to be loaded + # otherwise running into timing issues with `item = history_page.first_item` + expect(page).to have_css(".op-activity-list--item") end end @@ -66,9 +69,7 @@ def first_item def find_item(detail) detail = page.find("li.op-activity-list--item-detail", text: detail) - item = detail.ancestor(".op-activity-list--item-details").ancestor(".op-activity-list--item") - - item + detail.ancestor(".op-activity-list--item-details").ancestor(".op-activity-list--item") 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 8b3b2a6ae18e..3370db6ed294 100644 --- a/modules/meeting/spec/support/pages/structured_meeting/show.rb +++ b/modules/meeting/spec/support/pages/structured_meeting/show.rb @@ -48,13 +48,24 @@ def add_agenda_item(type: MeetingAgendaItem, save: true, &) end end - def cancel_add_form - page.within("#meeting-agenda-items-new-component") do + def cancel_add_form(item) + page.within("#meeting-agenda-items-new-component-#{item.meeting_section_id}") do click_on I18n.t(:button_cancel) expect(page).to have_no_link I18n.t(:button_cancel) end end + def add_agenda_item_to_section(section:, type: MeetingAgendaItem, save: true, &) + select_section_action(section, type.model_name.human) + + within("#meeting-sections-show-component-#{section.id}") do + in_agenda_form do + yield + click_on("Save") if save + end + end + end + def cancel_edit_form(item) in_edit_form(item) do click_on I18n.t(:button_cancel) @@ -91,6 +102,12 @@ def expect_agenda_item(title:) expect(page).to have_test_selector("op-meeting-agenda-title", text: title) end + def expect_agenda_item_in_section(title:, section:) + within("#meeting-sections-show-component-#{section.id}") do + expect_agenda_item(title:) + end + end + def expect_agenda_link(item) if item.is_a?(WorkPackage) expect(page).to have_css("[id^='meeting-agenda-items-item-component-']", text: item.subject) @@ -125,6 +142,23 @@ def select_action(item, action) end end + def select_section_action(section, action) + retry_block do + click_on_section_menu(section) + page.find(".Overlay") + end + + page.within(".Overlay") do + click_on action + end + end + + def click_on_section_menu(section) + page.within_test_selector("meeting-section-header-container-#{section.id}") do + page.find_test_selector("meeting-section-action-menu").click + end + end + def edit_agenda_item(item, &) select_action item, "Edit" expect_item_edit_form(item) @@ -196,5 +230,46 @@ def close_dialog def meeting_details_container find_by_id("meetings-sidebar-details-component") end + + def in_latest_section_form(&) + page.within(all(".op-meeting-section-container").last, &) + end + + def add_section(&) + page.within("#meeting-agenda-items-new-button-component") do + click_on I18n.t(:button_add) + click_on "Section" + # wait for the disabled button, indicating the turbo streams are applied + expect(page).to have_css("#meeting-agenda-items-new-button-component button[disabled='disabled']") + end + + in_latest_section_form(&) + end + + def expect_section(title:) + expect(page).to have_css(".op-meeting-section-container", text: title) + end + + def expect_no_section(title:) + expect(page).to have_no_css(".op-meeting-section-container", text: title) + end + + def expect_section_duration(section:, duration_text:) + page.within_test_selector("meeting-section-header-container-#{section.id}") do + expect(page).to have_text(duration_text) + end + end + + def edit_section(section, &) + select_section_action(section, "Edit") + + page.within_test_selector("meeting-section-header-container-#{section.id}", &) + end + + def remove_section(section) + accept_confirm do + select_section_action(section, "Delete") + end + end end end