diff --git a/app/views/augmented/_autocomplete_select_decoration.html.erb b/app/views/augmented/_autocomplete_select_decoration.html.erb
index 7f8b7887366a..030e04b84df9 100644
--- a/app/views/augmented/_autocomplete_select_decoration.html.erb
+++ b/app/views/augmented/_autocomplete_select_decoration.html.erb
@@ -1,5 +1,5 @@
- <%= content_tag :'autocomplete-select-decoration',
+ <%= content_tag :'opce-select-decoration',
{},
data: {
"multiselect": multiple,
diff --git a/config/locales/en.yml b/config/locales/en.yml
index e1b2daa56c00..cd9cbb9981ec 100644
--- a/config/locales/en.yml
+++ b/config/locales/en.yml
@@ -1639,6 +1639,7 @@ en:
label_add_related_work_packages: "Add related work packages"
label_add_subtask: "Add subtask"
label_added: "added"
+ label_added_by: "Added by %{author}"
label_added_time_by: "Added by %{author} %{age} ago"
label_additional_workflow_transitions_for_assignee: "Additional transitions allowed when the user is the assignee"
label_additional_workflow_transitions_for_author: "Additional transitions allowed when the user is the author"
diff --git a/frontend/src/app/core/setup/global-dynamic-components.const.ts b/frontend/src/app/core/setup/global-dynamic-components.const.ts
index 881ad3e99149..97164cff2f8b 100644
--- a/frontend/src/app/core/setup/global-dynamic-components.const.ts
+++ b/frontend/src/app/core/setup/global-dynamic-components.const.ts
@@ -13,8 +13,8 @@ import {
zenModeComponentSelector,
} from 'core-app/features/work-packages/components/wp-buttons/zen-mode-toggle-button/zen-mode-toggle-button.component';
import {
- OpAttachmentsComponent,
attachmentsSelector,
+ OpAttachmentsComponent,
} from 'core-app/shared/components/attachments/attachments.component';
import {
UserAutocompleterComponent,
@@ -37,10 +37,7 @@ import {
ToastsContainerComponent,
toastsContainerSelector,
} from 'core-app/shared/components/toaster/toasts-container.component';
-import {
- OpSidemenuComponent,
- sidemenuSelector,
-} from 'core-app/shared/components/sidemenu/sidemenu.component';
+import { OpSidemenuComponent, sidemenuSelector } from 'core-app/shared/components/sidemenu/sidemenu.component';
import {
CkeditorAugmentedTextareaComponent,
ckeditorAugmentedTextareaSelector,
@@ -61,10 +58,6 @@ import {
AddSectionDropdownComponent,
addSectionDropdownSelector,
} from 'core-app/shared/components/hide-section/add-section-dropdown/add-section-dropdown.component';
-import {
- AutocompleteSelectDecorationComponent,
- autocompleteSelectDecorationSelector,
-} from 'core-app/shared/components/autocompleter/autocomplete-select-decoration/autocomplete-select-decoration.component';
import {
ContentTabsComponent,
contentTabsSelector,
@@ -82,8 +75,8 @@ import {
CollapsibleSectionComponent,
} from 'core-app/shared/components/collapsible-section/collapsible-section.component';
import {
- OpHeaderProjectSelectComponent,
headerProjectSelectSelector,
+ OpHeaderProjectSelectComponent,
} from 'core-app/shared/components/header-project-select/header-project-select.component';
import {
ProjectAutocompleterComponent,
@@ -137,10 +130,7 @@ import {
quickInfoMacroSelector,
WorkPackageQuickinfoMacroComponent,
} from 'core-app/shared/components/fields/macros/work-package-quickinfo-macro.component';
-import {
- SpotSwitchComponent,
- spotSwitchSelector,
-} from 'core-app/spot/components/switch/switch.component';
+import { SpotSwitchComponent, spotSwitchSelector } from 'core-app/spot/components/switch/switch.component';
import { BackupComponent, backupSelector } from 'core-app/core/setup/globals/components/admin/backup.component';
import {
EnterpriseBaseComponent,
@@ -159,17 +149,14 @@ import {
enterprisePageSelector,
} from 'core-app/shared/components/enterprise-page/enterprise-page.component';
import {
- OpNonWorkingDaysListComponent,
nonWorkingDaysListSelector,
+ OpNonWorkingDaysListComponent,
} from 'core-app/shared/components/op-non-working-days-list/op-non-working-days-list.component';
import {
EEActiveSavedTrialComponent,
enterpriseActiveSavedTrialSelector,
} from 'core-app/features/enterprise/enterprise-active-trial/ee-active-saved-trial.component';
-import {
- NoResultsComponent,
- noResultsSelector,
-} from 'app/shared/components/no-results/no-results.component';
+import { NoResultsComponent, noResultsSelector } from 'app/shared/components/no-results/no-results.component';
import {
HomescreenNewFeaturesBlockComponent,
homescreenNewFeaturesBlockSelector,
@@ -191,10 +178,7 @@ import {
InAppNotificationBellComponent,
opInAppNotificationBellSelector,
} from 'core-app/features/in-app-notifications/bell/in-app-notification-bell.component';
-import {
- IanMenuComponent,
- ianMenuSelector,
-} from 'core-app/features/in-app-notifications/center/menu/menu.component';
+import { IanMenuComponent, ianMenuSelector } from 'core-app/features/in-app-notifications/center/menu/menu.component';
import {
opTeamPlannerSidemenuSelector,
TeamPlannerSidemenuComponent,
@@ -215,11 +199,17 @@ import {
OpBasicSingleDatePickerComponent,
opBasicSingleDatePickerSelector,
} from 'core-app/shared/components/datepicker/basic-single-date-picker/basic-single-date-picker.component';
-import { SpotDropModalPortalComponent, spotDropModalPortalComponentSelector } from 'core-app/spot/components/drop-modal/drop-modal-portal.component';
-import { StaticAttributeHelpTextComponent, staticAttributeHelpTextSelector } from 'core-app/shared/components/attribute-help-texts/static-attribute-help-text.component';
import {
- StorageLoginButtonComponent,
+ SpotDropModalPortalComponent,
+ spotDropModalPortalComponentSelector,
+} from 'core-app/spot/components/drop-modal/drop-modal-portal.component';
+import {
+ StaticAttributeHelpTextComponent,
+ staticAttributeHelpTextSelector,
+} from 'core-app/shared/components/attribute-help-texts/static-attribute-help-text.component';
+import {
opStorageLoginButtonSelector,
+ StorageLoginButtonComponent,
} from 'core-app/shared/components/storages/storage-login-button/storage-login-button.component';
import {
TimerAccountMenuComponent,
@@ -250,7 +240,6 @@ export const globalDynamicComponents:OptionalBootstrapDefinition[] = [
{ selector: hideSectionLinkSelector, cls: HideSectionLinkComponent },
{ selector: showSectionDropdownSelector, cls: ShowSectionDropdownComponent },
{ selector: addSectionDropdownSelector, cls: AddSectionDropdownComponent },
- { selector: autocompleteSelectDecorationSelector, cls: AutocompleteSelectDecorationComponent },
{ selector: contentTabsSelector, cls: ContentTabsComponent },
{ selector: globalSearchTitleSelector, cls: GlobalSearchTitleComponent },
{ selector: copyToClipboardSelector, cls: CopyToClipboardComponent },
diff --git a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
index 63268a2ba732..946adb2acaa7 100644
--- a/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
+++ b/frontend/src/app/shared/components/autocompleter/openproject-autocompleter.module.ts
@@ -64,5 +64,6 @@ export const OPENPROJECT_AUTOCOMPLETE_COMPONENTS = [
export class OpenprojectAutocompleterModule {
constructor(injector:Injector) {
registerCustomElement('opce-autocompleter', OpAutocompleterComponent, { injector });
+ registerCustomElement('opce-select-decoration', AutocompleteSelectDecorationComponent, { injector });
}
}
diff --git a/frontend/src/global_styles/openproject/_primer-adjustments.sass b/frontend/src/global_styles/openproject/_primer-adjustments.sass
index 5cfeaeb32008..292a3028aa40 100644
--- a/frontend/src/global_styles/openproject/_primer-adjustments.sass
+++ b/frontend/src/global_styles/openproject/_primer-adjustments.sass
@@ -30,12 +30,9 @@
// as the OpenProject form styles override them.
.Box
- ul:not(.op-uc-list)
+ > ul:not(.op-uc-list)
margin-left: 0
-segmented-control
- ul
- margin-left: 0
.FormControl
label
@@ -47,3 +44,6 @@ action-menu
anchored-position
ul
margin-left: 0
+
+ul.tabnav-tabs
+ margin-left: 0
diff --git a/frontend/src/stimulus/controllers/dynamic/op-turbo-op-primer-async-dialog.controller.ts b/frontend/src/stimulus/controllers/dynamic/op-turbo-op-primer-async-dialog.controller.ts
new file mode 100644
index 000000000000..59ca206674b7
--- /dev/null
+++ b/frontend/src/stimulus/controllers/dynamic/op-turbo-op-primer-async-dialog.controller.ts
@@ -0,0 +1,48 @@
+/*
+ * -- copyright
+ * OpenProject is an open source project management software.
+ * Copyright (C) 2023 the OpenProject GmbH
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License version 3.
+ *
+ * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+ * Copyright (C) 2006-2013 Jean-Philippe Lang
+ * Copyright (C) 2010-2013 the ChiliProject Team
+ *
+ * This program is free software; you can redistribute it and/or
+ * modify it under the terms of the GNU General Public License
+ * as published by the Free Software Foundation; either version 2
+ * of the License, or (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program; if not, write to the Free Software
+ * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+ *
+ * See COPYRIGHT and LICENSE files for more details.
+ * ++
+ */
+
+import { Controller } from '@hotwired/stimulus';
+import { TurboElement } from '../../../typings/turbo';
+
+export default class extends Controller {
+ static targets = ['frameElement'];
+ declare readonly frameElementTarget:HTMLInputElement&TurboElement;
+
+ private initialState:string;
+
+ connect():void {
+ this.initialState = this.frameElementTarget.innerHTML;
+ }
+
+ reinitFrame():void {
+ this.frameElementTarget.innerHTML = this.initialState;
+ this.frameElementTarget.reload();
+ }
+}
diff --git a/frontend/src/typings/turbo.d.ts b/frontend/src/typings/turbo.d.ts
new file mode 100644
index 000000000000..d598b03fcc97
--- /dev/null
+++ b/frontend/src/typings/turbo.d.ts
@@ -0,0 +1,3 @@
+export interface TurboElement {
+ reload:() => void;
+}
diff --git a/lib/primer/open_project/forms/autocompleter.html.erb b/lib/primer/open_project/forms/autocompleter.html.erb
index d045b6f68c2d..9eb7bbcf65fc 100644
--- a/lib/primer/open_project/forms/autocompleter.html.erb
+++ b/lib/primer/open_project/forms/autocompleter.html.erb
@@ -1,11 +1,22 @@
<%= render(FormControl.new(input: @input)) do %>
- <%= angular_component_tag 'opce-autocompleter',
- data: @autocomplete_options.delete(:data) { {} },
- inputs: @autocomplete_options.merge({
- classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}",
- inputName: builder.field_name(@input.name),
- inputValue: builder.object.send(@input.name),
- defaultData: 'true'
- })
- %>
+ <% if decorated_select? %>
+ <%= render partial: '/augmented/autocomplete_select_decoration',
+ locals: {
+ input_name: builder.field_name(@input.name),
+ input_id: builder.field_id(@input.name),
+ select_options: select_options.map(&:to_h),
+ multiple: @autocomplete_options.fetch(:multiple, false),
+ key: @autocomplete_options.fetch(:resource, '')
+ } %>
+ <% else %>
+ <%= angular_component_tag 'opce-autocompleter',
+ data: @autocomplete_options.delete(:data) { {} },
+ inputs: @autocomplete_options.merge(
+ classes: "ng-select--primerized #{@input.invalid? ? '-error' : ''}",
+ inputName: builder.field_name(@input.name),
+ inputValue: builder.object.send(@input.name),
+ defaultData: 'true'
+ )
+ %>
+ <% end %>
<% end %>
diff --git a/lib/primer/open_project/forms/autocompleter.rb b/lib/primer/open_project/forms/autocompleter.rb
index 12ffb4a4debc..51aedf8b1577 100644
--- a/lib/primer/open_project/forms/autocompleter.rb
+++ b/lib/primer/open_project/forms/autocompleter.rb
@@ -7,13 +7,17 @@ module Forms
class Autocompleter < Primer::Forms::BaseComponent
include AngularHelper
- delegate :builder, :form, to: :@input
+ delegate :builder, :form, :select_options, to: :@input
def initialize(input:, autocomplete_options:)
super()
@input = input
@autocomplete_options = autocomplete_options
end
+
+ def decorated_select?
+ @autocomplete_options[:decorated]
+ end
end
end
end
diff --git a/lib/primer/open_project/forms/dsl/autocompleter_input.rb b/lib/primer/open_project/forms/dsl/autocompleter_input.rb
index 6222916fced9..706037c19422 100644
--- a/lib/primer/open_project/forms/dsl/autocompleter_input.rb
+++ b/lib/primer/open_project/forms/dsl/autocompleter_input.rb
@@ -5,14 +5,39 @@ module OpenProject
module Forms
module Dsl
class AutocompleterInput < Primer::Forms::Dsl::Input
- attr_reader :name, :label, :autocomplete_options
+ attr_reader :name, :label, :autocomplete_options, :select_options
+
+ class Option
+ attr_reader :label, :value, :selected
+
+ def initialize(label:, value:, selected: false)
+ @label = label
+ @value = value
+ @selected = selected
+ end
+
+ def to_h
+ {
+ label:,
+ value:,
+ selected:
+ }
+ end
+ end
def initialize(name:, label:, autocomplete_options:, **system_arguments)
@name = name
@label = label
@autocomplete_options = autocomplete_options
+ @select_options = []
super(**system_arguments)
+
+ yield(self) if block_given?
+ end
+
+ def option(**args)
+ @select_options << Option.new(**args)
end
def to_component
diff --git a/lib/primer/open_project/forms/dsl/input_methods.rb b/lib/primer/open_project/forms/dsl/input_methods.rb
index 34bf1c7279d3..28e31ef4eacd 100644
--- a/lib/primer/open_project/forms/dsl/input_methods.rb
+++ b/lib/primer/open_project/forms/dsl/input_methods.rb
@@ -5,8 +5,8 @@ module OpenProject
module Forms
module Dsl
module InputMethods
- def autocompleter(**)
- add_input AutocompleterInput.new(builder: @builder, form: @form, **)
+ def autocompleter(**, &)
+ add_input AutocompleterInput.new(builder: @builder, form: @form, **, &)
end
def rich_text_area(**)
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 d5e16322fa25..970f002d1190 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
@@ -47,7 +47,14 @@
title = I18n.t(:label_added_time_by,
author: @meeting_agenda_item.author.name,
age: helpers.distance_of_time_in_words(Time.zone.now, @meeting_agenda_item.created_at))
- render(Users::AvatarComponent.new(user: @meeting_agenda_item.author, size: 'mini', title:, classes: 'op-principal_flex'))
+ flex_layout do |flex|
+ flex.with_column do
+ render(Primer::Beta::Text.new(color: :subtle, mr: 1)) { t("label_added_by", author: nil) }
+ end
+ flex.with_column do
+ render(Users::AvatarComponent.new(user: @meeting_agenda_item.author, size: 'mini', title:, classes: 'op-principal_flex'))
+ end
+ end
end
grid.with_area(:actions, tag: :div, justify_self: :end) do
diff --git a/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb
new file mode 100644
index 000000000000..6a0c4c18cbd9
--- /dev/null
+++ b/modules/meeting/app/components/op_turbo/op_primer/async_dialog_component.rb
@@ -0,0 +1,104 @@
+#-- 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 OpTurbo
+ module OpPrimer
+ class AsyncDialogComponent < ApplicationComponent
+ include ApplicationHelper
+ include ::OpPrimer::ComponentHelpers
+
+ def initialize(id:, src:, title:, button_icon: nil, button_text: nil, button_attributes: {}, size: :auto)
+ super
+
+ @id = id
+ @src = src
+ @title = title
+ @size = size
+ @button_icon = button_icon
+ @button_text = button_text
+ @button_attributes = button_attributes
+ end
+
+ def call
+ render(Primer::Box.new(data: stimulus_attributes)) do
+ render(Primer::Alpha::Dialog.new(
+ id: @id,
+ title: @title,
+ size: @size
+ )) do |dialog|
+ button_partial(dialog)
+ frame_partial
+ end
+ end
+ end
+
+ private
+
+ def stimulus_attributes
+ {
+ controller: 'op-turbo-op-primer-async-dialog',
+ 'application-target': 'dynamic'
+ }
+ end
+
+ def button_partial(dialog)
+ dialog.with_show_button(**merged_button_attributes) do |button|
+ button.with_leading_visual_icon(icon: @button_icon) if @button_icon
+ @button_text
+ end
+ end
+
+ def merged_button_attributes
+ stimuls_action_ref = 'click->op-turbo-op-primer-async-dialog#reinitFrame'
+
+ @button_attributes[:data] = {} if @button_attributes[:data].nil?
+ @button_attributes[:data][:action] = stimuls_action_ref
+
+ @button_attributes
+ end
+
+ def frame_partial
+ content_tag("turbo-frame",
+ id: "#{@id}-frame",
+ loading: :lazy,
+ src: @src,
+ data: { 'op-turbo-op-primer-async-dialog-target': "frameElement" }) do
+ loading_state_partial
+ end
+ end
+
+ def loading_state_partial
+ flex_layout(justify_content: :center) do |flex|
+ flex.with_column(my: 5) do
+ render(Primer::Beta::Spinner.new)
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.rb b/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.rb
new file mode 100644
index 000000000000..74805729d99a
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/add_work_package_to_meeting_form_component.rb
@@ -0,0 +1,101 @@
+#-- 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 WorkPackageMeetingsTab
+ class AddWorkPackageToMeetingFormComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpTurbo::Streamable
+ include OpPrimer::ComponentHelpers
+
+ def initialize(work_package:, meeting_agenda_item: nil, base_errors: nil)
+ super
+
+ @work_package = work_package
+ @meeting_agenda_item = meeting_agenda_item || MeetingAgendaItem.new(work_package: @work_package)
+ @base_errors = base_errors
+ end
+
+ def call
+ content_tag("turbo-frame", id: "add-work-package-to-meeting-dialog-frame") do
+ component_wrapper do
+ primer_form_with(
+ model: @meeting_agenda_item,
+ method: :post,
+ url: work_package_meeting_agenda_items_path(@work_package)
+ ) do |f|
+ component_collection do |collection|
+ collection.with_component(Primer::Alpha::Dialog::Body.new(test_selector: 'op-add-work-package-to-meeting-dialog-body')) do
+ form_content_partial(f)
+ end
+ collection.with_component(Primer::Alpha::Dialog::Footer.new) do
+ form_actions_partial
+ end
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def form_content_partial(form)
+ flex_layout(my: 3) do |flex|
+ flex.with_row do
+ base_error_partial
+ end
+ flex.with_row do
+ render(MeetingAgendaItem::MeetingForm.new(form))
+ end
+ flex.with_row(mt: 3) do
+ render(MeetingAgendaItem::Notes.new(form))
+ end
+ end
+ end
+
+ def base_error_partial
+ if @base_errors&.any?
+ render(Primer::Beta::Flash.new(mb: 3, icon: :stop, scheme: :danger)) { @base_errors.join("\n") }
+ end
+ end
+
+ def form_actions_partial
+ component_collection do |collection|
+ collection.with_component(Primer::ButtonComponent.new(
+ data: {
+ 'close-dialog-id': "add-work-package-to-meeting-dialog"
+ }
+ )) do
+ t("button_cancel")
+ end
+ collection.with_component(Primer::ButtonComponent.new(scheme: :primary, type: :submit)) do
+ t("button_save")
+ end
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/heading_component.rb b/modules/meeting/app/components/work_package_meetings_tab/heading_component.rb
new file mode 100644
index 000000000000..a0eb4532c3cd
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/heading_component.rb
@@ -0,0 +1,81 @@
+#-- 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 WorkPackageMeetingsTab
+ class HeadingComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpPrimer::ComponentHelpers
+ include OpTurbo::Streamable
+
+ def initialize(work_package:)
+ super
+
+ @work_package = work_package
+ end
+
+ def call
+ component_wrapper do
+ flex_layout(justify_content: :space_between, align_items: :center) do |flex|
+ flex.with_column do
+ info_partial
+ end
+ if allowed_to_add_to_meeting?
+ flex.with_column(ml: 3) do
+ add_to_meeting_partial
+ end
+ end
+ end
+ end
+ end
+
+ private
+
+ def allowed_to_add_to_meeting?
+ User.current.allowed_to?(:edit_meetings, @work_package.project)
+ end
+
+ def info_partial
+ render(Primer::Beta::Text.new(color: :subtle)) { t("text_add_work_package_to_meeting_description") }
+ end
+
+ def add_to_meeting_partial
+ # we need to render a dialog with size :xlarge as the RTE requires this size to be able to render the toolbar properly
+ render(OpTurbo::OpPrimer::AsyncDialogComponent.new(
+ id: "add-work-package-to-meeting-dialog",
+ src: dialog_work_package_meeting_agenda_items_path(@work_package),
+ size: :xlarge,
+ title: t("label_add_work_package_to_meeting_dialog_title"),
+ button_icon: :plus,
+ button_text: t("label_add_work_package_to_meeting_dialog_button"),
+ button_attributes: {
+ test_selector: "op-add-work-package-to-meeting-dialog-trigger"
+ }
+ ))
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/index_component.rb b/modules/meeting/app/components/work_package_meetings_tab/index_component.rb
new file mode 100644
index 000000000000..72f22eab2280
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/index_component.rb
@@ -0,0 +1,90 @@
+#-- 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 WorkPackageMeetingsTab
+ class IndexComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpPrimer::ComponentHelpers
+ include OpTurbo::Streamable
+
+ def initialize(work_package:, agenda_items_grouped_by_meeting:, upcoming_meetings_count:, past_meetings_count:,
+ direction: :upcoming)
+ super
+
+ @work_package = work_package
+ @agenda_items_grouped_by_meeting = agenda_items_grouped_by_meeting
+ @direction = direction
+ @upcoming_meetings_count = upcoming_meetings_count
+ @past_meetings_count = past_meetings_count
+ end
+
+ def call
+ content_tag("turbo-frame", id: "work-package-meetings-tab-content") do
+ frame_content_partial
+ end
+ end
+
+ private
+
+ def frame_content_partial
+ component_wrapper do
+ flex_layout(test_selector: 'op-work-package-meetings-tab-container') do |flex|
+ flex.with_row do
+ render(WorkPackageMeetingsTab::HeadingComponent.new(work_package: @work_package))
+ end
+ flex.with_row(mt: 3) do
+ tabbed_navigation_partial
+ end
+ flex.with_row do
+ render(WorkPackageMeetingsTab::ListComponent.new(
+ agenda_items_grouped_by_meeting: @agenda_items_grouped_by_meeting,
+ direction: @direction
+ ))
+ end
+ end
+ end
+ end
+
+ def tabbed_navigation_partial
+ render(Primer::Alpha::TabNav.new(label: "label")) do |component|
+ component.with_tab(selected: @direction == :upcoming,
+ href: work_package_meetings_tab_index_path(@work_package,
+ direction: :upcoming)) do |tab|
+ tab.with_text { t("label_upcoming_meetings_short") }
+ tab.with_counter(count: @upcoming_meetings_count, test_selector: 'op-upcoming-meetings-counter')
+ end
+ component.with_tab(selected: @direction == :past,
+ href: work_package_meetings_tab_index_path(@work_package,
+ direction: :past)) do |tab|
+ tab.with_text { t("label_past_meetings_short") }
+ tab.with_counter(count: @past_meetings_count, test_selector: 'op-past-meetings-counter')
+ end
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/list_component.rb b/modules/meeting/app/components/work_package_meetings_tab/list_component.rb
new file mode 100644
index 000000000000..ee54d1ffffb7
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/list_component.rb
@@ -0,0 +1,71 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2023 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+module WorkPackageMeetingsTab
+ class ListComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpPrimer::ComponentHelpers
+
+ def initialize(agenda_items_grouped_by_meeting:, direction:)
+ super
+
+ @agenda_items_grouped_by_meeting = agenda_items_grouped_by_meeting
+ @direction = direction
+ end
+
+ def call
+ if @agenda_items_grouped_by_meeting.any?
+ flex_layout do |flex|
+ @agenda_items_grouped_by_meeting.each do |meeting, meeting_agenda_items|
+ flex.with_row(mt: 3) do
+ render(WorkPackageMeetingsTab::MeetingComponent.new(
+ meeting:, meeting_agenda_items:
+ ))
+ end
+ end
+ end
+ else
+ empty_state_partial
+ end
+ end
+
+ private
+
+ def empty_state_partial
+ render(Primer::Beta::Blankslate.new(border: true)) do |component|
+ component.with_visual_icon(icon: "comment-discussion")
+ if @direction == :upcoming
+ component.with_heading(tag: :h4).with_content(t("text_work_package_has_no_upcoming_meeting_agenda_items"))
+ component.with_description { t("text_work_package_add_to_meeting_hint") }
+ else
+ component.with_heading(tag: :h4).with_content(t("text_work_package_has_no_past_meeting_agenda_items"))
+ end
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.html.erb b/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.html.erb
new file mode 100644
index 000000000000..b4b8699c630b
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.html.erb
@@ -0,0 +1,25 @@
+<%=
+ flex_layout do |flex|
+ flex.with_row do
+ render(Primer::Beta::Text.new(color: @meeting_agenda_item.notes.present? ? nil : :subtle)) do
+ if @meeting_agenda_item.notes.present?
+ ::OpenProject::TextFormatting::Renderer.format_text(@meeting_agenda_item.notes)
+ else
+ t("text_agenda_item_no_notes")
+ end
+ end
+ end
+
+ flex.with_row(mt: 3, font_size: :small, flex_layout: true, align_items: :center) do |authoring|
+ authoring.with_column(mr: 1, style: 'white-space: nowrap') do
+ render(Primer::Beta::Text.new(color: :subtle)) { t("label_added_by", author: nil) }
+ end
+ authoring.with_column(mr: 1, classes: 'ellipsis') do
+ render(Users::AvatarComponent.new(user: @meeting_agenda_item.author, size: 'mini', classes: 'op-principal_flex'))
+ end
+ authoring.with_column do
+ render(Primer::Beta::RelativeTime.new(color: :subtle, datetime: @meeting_agenda_item.created_at))
+ end
+ end
+ end
+%>
diff --git a/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.rb b/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.rb
new file mode 100644
index 000000000000..d8d8efc6bca8
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/meeting_agenda_item_component.rb
@@ -0,0 +1,40 @@
+#-- 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 WorkPackageMeetingsTab
+ class MeetingAgendaItemComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpPrimer::ComponentHelpers
+
+ def initialize(meeting_agenda_item:)
+ super
+
+ @meeting_agenda_item = meeting_agenda_item
+ end
+ end
+end
diff --git a/modules/meeting/app/components/work_package_meetings_tab/meeting_component.rb b/modules/meeting/app/components/work_package_meetings_tab/meeting_component.rb
new file mode 100644
index 000000000000..22f6a0cc1d93
--- /dev/null
+++ b/modules/meeting/app/components/work_package_meetings_tab/meeting_component.rb
@@ -0,0 +1,81 @@
+#-- 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 WorkPackageMeetingsTab
+ class MeetingComponent < ApplicationComponent
+ include ApplicationHelper
+ include OpPrimer::ComponentHelpers
+
+ def initialize(meeting:, meeting_agenda_items:)
+ super
+
+ @meeting = meeting
+ @meeting_agenda_items = meeting_agenda_items
+ end
+
+ def call
+ render(Primer::Beta::BorderBox.new(padding: :condensed,
+ test_selector: "op-meeting-container-#{@meeting.id}")) do |border_box|
+ border_box.with_header do
+ header_partial
+ end
+ @meeting_agenda_items.each do |meeting_agenda_item|
+ border_box.with_row do
+ render(WorkPackageMeetingsTab::MeetingAgendaItemComponent.new(meeting_agenda_item:))
+ end
+ end
+ end
+ end
+
+ private
+
+ def header_partial
+ flex_layout do |flex|
+ flex.with_column(mr: 1) do
+ meeting_link_partial
+ end
+ flex.with_column do
+ meeting_time_partial
+ end
+ end
+ end
+
+ def meeting_link_partial
+ render(Primer::Beta::Link.new(href: meeting_path(@meeting), target: "_blank", font_size: :normal,
+ font_weight: :bold, scheme: :primary, underline: false)) do
+ @meeting.title
+ end
+ end
+
+ def meeting_time_partial
+ render(Primer::Beta::Text.new(font_size: :normal, color: :muted)) do
+ format_time(@meeting.start_time)
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb
index 03f44a47728f..dba83d59fbaf 100644
--- a/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb
+++ b/modules/meeting/app/contracts/meeting_agenda_items/create_contract.rb
@@ -28,15 +28,46 @@
module MeetingAgendaItems
class CreateContract < BaseContract
- validate :user_allowed_to_add
+ 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 :edit_meetings
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_to?(:edit_meetings, 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_agenda_items/editable_item.rb b/modules/meeting/app/contracts/meeting_agenda_items/editable_item.rb
index 1ee147974451..5a2a0d3e6e6b 100644
--- a/modules/meeting/app/contracts/meeting_agenda_items/editable_item.rb
+++ b/modules/meeting/app/contracts/meeting_agenda_items/editable_item.rb
@@ -36,7 +36,6 @@ module EditableItem
protected
-
def validate_editable
unless model.editable?
errors.add :base, I18n.t(:text_meeting_not_editable_anymore)
diff --git a/modules/meeting/app/controllers/concerns/meetings/work_package_meetings_tab_component_streams.rb b/modules/meeting/app/controllers/concerns/meetings/work_package_meetings_tab_component_streams.rb
new file mode 100644
index 000000000000..92dd5786b018
--- /dev/null
+++ b/modules/meeting/app/controllers/concerns/meetings/work_package_meetings_tab_component_streams.rb
@@ -0,0 +1,67 @@
+#-- 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 Meetings
+ module WorkPackageMeetingsTabComponentStreams
+ extend ActiveSupport::Concern
+
+ included do
+ def update_heading_component_via_turbo_stream(work_package: @work_package)
+ update_via_turbo_stream(
+ component: WorkPackageMeetingsTab::HeadingComponent.new(
+ work_package:
+ )
+ )
+ end
+
+ def update_add_to_meeting_form_component_via_turbo_stream(meeting_agenda_item:, work_package: @work_package,
+ base_errors: nil)
+ update_via_turbo_stream(
+ component: WorkPackageMeetingsTab::AddWorkPackageToMeetingFormComponent.new(
+ meeting_agenda_item:,
+ work_package:,
+ base_errors:
+ )
+ )
+ end
+
+ def update_index_component_via_turbo_stream(direction:, agenda_items_grouped_by_meeting:,
+ upcoming_meetings_count:, past_meetings_count:, work_package: @work_package)
+ update_via_turbo_stream(
+ component: WorkPackageMeetingsTab::IndexComponent.new(
+ direction:,
+ agenda_items_grouped_by_meeting:,
+ upcoming_meetings_count:,
+ past_meetings_count:,
+ work_package:
+ )
+ )
+ end
+ end
+ end
+end
diff --git a/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb b/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb
new file mode 100644
index 000000000000..b5bca9262a83
--- /dev/null
+++ b/modules/meeting/app/controllers/work_package_meetings_tab_controller.rb
@@ -0,0 +1,124 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2023 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class WorkPackageMeetingsTabController < ApplicationController
+ include OpTurbo::ComponentStream
+ include Meetings::WorkPackageMeetingsTabComponentStreams
+
+ before_action :set_work_package
+ before_action :authorize
+
+ def index
+ direction = params[:direction]&.to_sym || :upcoming # default to upcoming
+
+ set_agenda_items(direction)
+
+ render(
+ WorkPackageMeetingsTab::IndexComponent.new(
+ direction:,
+ work_package: @work_package,
+ agenda_items_grouped_by_meeting: @agenda_items_grouped_by_meeting,
+ upcoming_meetings_count: @upcoming_meetings_count,
+ past_meetings_count: @past_meetings_count
+ ),
+ layout: false
+ )
+ end
+
+ def add_work_package_to_meeting_dialog
+ render(WorkPackageMeetingsTab::AddWorkPackageToMeetingFormComponent.new(work_package: @work_package), layout: false)
+ end
+
+ def add_work_package_to_meeting
+ call = ::MeetingAgendaItems::CreateService
+ .new(user: current_user)
+ .call(add_work_package_to_meeting_params.merge(work_package_id: @work_package.id))
+
+ meeting_agenda_item = call.result
+
+ if call.success?
+ set_agenda_items(:upcoming) # always switch back to the upcoming tab after adding the work package to a meeting
+
+ # update the whole index component as we need to update the counters in the tabbed nav as well
+ update_index_component_via_turbo_stream(
+ direction: :upcoming,
+ agenda_items_grouped_by_meeting: @agenda_items_grouped_by_meeting,
+ upcoming_meetings_count: @upcoming_meetings_count,
+ past_meetings_count: @past_meetings_count
+ )
+ # TODO: show success message?
+ else
+ # show errors in form
+ update_add_to_meeting_form_component_via_turbo_stream(meeting_agenda_item:, base_errors: call.errors[:base])
+ end
+
+ respond_with_turbo_streams
+ end
+
+ private
+
+ def set_work_package
+ @work_package = WorkPackage.find(params[:work_package_id])
+ @project = @work_package.project # required for authorization via before_action
+ end
+
+ def add_work_package_to_meeting_params
+ params.require(:meeting_agenda_item).permit(:meeting_id, :notes)
+ end
+
+ def set_agenda_items(direction)
+ upcoming_agenda_items_grouped_by_meeting = get_agenda_items_of_work_package(:upcoming).group_by(&:meeting)
+ past_agenda_items_grouped_by_meeting = get_agenda_items_of_work_package(:past).group_by(&:meeting)
+
+ @upcoming_meetings_count = upcoming_agenda_items_grouped_by_meeting.count
+ @past_meetings_count = past_agenda_items_grouped_by_meeting.count
+
+ @agenda_items_grouped_by_meeting = case direction
+ when :upcoming
+ upcoming_agenda_items_grouped_by_meeting
+ when :past
+ past_agenda_items_grouped_by_meeting
+ end
+ end
+
+ def get_agenda_items_of_work_package(direction)
+ agenda_items = MeetingAgendaItem
+ .includes(:meeting)
+ .where(work_package_id: @work_package.id)
+ .order('meetings.start_time': :asc)
+
+ case direction
+ when :past
+ agenda_items = agenda_items.where('meetings.start_time < ?', Time.zone.now)
+ when :upcoming
+ agenda_items = agenda_items.where('meetings.start_time >= ?', Time.zone.now)
+ end
+
+ agenda_items
+ end
+end
diff --git a/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb b/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb
new file mode 100644
index 000000000000..8e374cd635bb
--- /dev/null
+++ b/modules/meeting/app/forms/meeting_agenda_item/meeting_form.rb
@@ -0,0 +1,60 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2023 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+class MeetingAgendaItem::MeetingForm < ApplicationForm
+ include Redmine::I18n
+
+ form do |agenda_item_form|
+ agenda_item_form.autocompleter(
+ name: :meeting_id,
+ required: true,
+ include_blank: false,
+ label: Meeting.model_name.human,
+ caption: I18n.t("label_meeting_selection_caption"),
+ autocomplete_options: {
+ multiple: false,
+ decorated: true,
+ }
+ ) do |select|
+ MeetingAgendaItems::CreateContract
+ .assignable_meetings(User.current)
+ .where('meetings.start_time >= ?', Time.zone.now)
+ .find_each do |meeting|
+ select.option(
+ label: "#{meeting.title} #{format_date(meeting.start_time)} #{format_time(meeting.start_time, false)}",
+ value: meeting.id
+ )
+ end
+ end
+ end
+
+ def initialize(disabled: false)
+ super()
+ @disabled = disabled
+ end
+end
diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml
index 72c56d895f5a..f7b906dc4388 100644
--- a/modules/meeting/config/locales/en.yml
+++ b/modules/meeting/config/locales/en.yml
@@ -94,6 +94,8 @@ en:
label_meeting_diff: "Diff"
label_upcoming_meetings: "Upcoming meetings"
label_past_meetings: "Past meetings"
+ label_upcoming_meetings_short: "Upcoming"
+ label_past_meetings_short: "Past"
label_involvement: "Involvement"
label_upcoming_invitations: "Upcoming invitations"
label_past_invitations: "Past invitations"
@@ -176,6 +178,14 @@ en:
label_meeting_add_participants: "Add participants"
text_meeting_not_editable_anymore: "This meeting is not editable anymore."
+ text_meeting_not_present_anymore: "This meeting was deleted. Please select another meeting."
label_add_work_package_to_meeting_dialog_title: "Add work package to meeting"
label_add_work_package_to_meeting_dialog_button: "Add to meeting"
+ label_meeting_selection_caption: "It's only possible to add this work package to open, upcoming meetings."
+
+ text_add_work_package_to_meeting_description: "A work package can be added to one or multiple meetings for discussion. Any notes concerning it are also visible here."
+ text_agenda_item_no_notes: "No notes provided"
+ text_work_package_has_no_upcoming_meeting_agenda_items: "This work package is not scheduled in an upcoming meeting agenda yet."
+ text_work_package_add_to_meeting_hint: "Use the \"Add to meeting\" button to add this work package to an upcoming meeting."
+ text_work_package_has_no_past_meeting_agenda_items: "This work package was not mentioned in a past meeting."
diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb
index 48ed9a81e34c..6875ffe50d77 100644
--- a/modules/meeting/config/routes.rb
+++ b/modules/meeting/config/routes.rb
@@ -39,6 +39,7 @@
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
end
end
diff --git a/modules/meeting/frontend/module/main.ts b/modules/meeting/frontend/module/main.ts
new file mode 100644
index 000000000000..a23cc22a9dde
--- /dev/null
+++ b/modules/meeting/frontend/module/main.ts
@@ -0,0 +1,64 @@
+// -- 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,
+// 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 {
+ Injector,
+ NgModule,
+ CUSTOM_ELEMENTS_SCHEMA
+} from '@angular/core';
+import { OpSharedModule } from 'core-app/shared/shared.module';
+import { OpenprojectTabsModule } from 'core-app/shared/components/tabs/openproject-tabs.module';
+import { WorkPackageTabsService } from 'core-app/features/work-packages/components/wp-tabs/services/wp-tabs/wp-tabs.service';
+import { MeetingsTabComponent } from './meetings-tab/meetings-tab.component';
+
+export function initializeMeetingPlugin(injector:Injector) {
+ const wpTabService = injector.get(WorkPackageTabsService);
+ wpTabService.register({
+ component: MeetingsTabComponent,
+ name: "Meetings",
+ id: 'meetings',
+ displayable: (workPackage) => !!workPackage.meetings
+ });
+}
+
+@NgModule({
+ imports: [
+ OpSharedModule,
+ OpenprojectTabsModule,
+ ],
+ declarations: [
+ MeetingsTabComponent,
+ ],
+ exports: [
+ MeetingsTabComponent,
+ ],
+ schemas: [CUSTOM_ELEMENTS_SCHEMA],
+})
+export class PluginModule {
+ constructor(injector:Injector) {
+ initializeMeetingPlugin(injector);
+ }
+}
diff --git a/modules/meeting/frontend/module/meetings-tab/meetings-tab.component.ts b/modules/meeting/frontend/module/meetings-tab/meetings-tab.component.ts
new file mode 100644
index 000000000000..766025714e43
--- /dev/null
+++ b/modules/meeting/frontend/module/meetings-tab/meetings-tab.component.ts
@@ -0,0 +1,74 @@
+// -- 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.
+//++
+
+import {
+ AfterViewInit, ChangeDetectionStrategy,
+ Component, ElementRef, Input, OnInit,
+} from '@angular/core';
+import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource';
+import { TabComponent } from 'core-app/features/work-packages/components/wp-tabs/components/wp-tab-wrapper/tab';
+import { I18nService } from 'core-app/core/i18n/i18n.service';
+import { PathHelperService } from 'core-app/core/path-helper/path-helper.service';
+
+@Component({
+ selector: 'op-meetings-tab',
+ templateUrl: './meetings-tab.template.html',
+ changeDetection: ChangeDetectionStrategy.OnPush,
+})
+export class MeetingsTabComponent implements OnInit, AfterViewInit, TabComponent {
+ @Input() public workPackage:WorkPackageResource;
+ turboFrameSrc:string;
+
+ constructor(
+ private elementRef:ElementRef,
+ readonly PathHelper:PathHelperService,
+ readonly I18n:I18nService,
+ ) {}
+
+ ngOnInit():void {
+ // TODO: Should we try to restore the last selected tab via localStorage as done in following commented code?
+ //
+ // const storedSrc = localStorage.getItem(`turboFrameSrcMeetingsTabForWorkPackage${this.workPackage.id}`);
+ // this.turboFrameSrc = storedSrc ? storedSrc : `/work_packages/${this.workPackage.id}/meetings/tab`;
+
+ this.turboFrameSrc = `${this.PathHelper.staticBase}/work_packages/${this.workPackage.id}/meetings/tab`;
+ }
+
+ ngAfterViewInit():void {
+ // TODO: Should we try to restore the last selected tab via localStorage as done in following commented code?
+ //
+ // const turboFrame = this.elementRef.nativeElement.querySelector('#work-package-meetings-tab-content');
+ // if (turboFrame) {
+ // turboFrame.addEventListener('turbo:frame-load', (event: Event) => {
+ // const target = event.target as HTMLElement;
+ // const newSrc = target.getAttribute('src');
+ // localStorage.setItem(`turboFrameSrcMeetingsTabForWorkPackage${this.workPackage.id}`, newSrc||'');
+ // });
+ // }
+ }
+}
diff --git a/modules/meeting/frontend/module/meetings-tab/meetings-tab.template.html b/modules/meeting/frontend/module/meetings-tab/meetings-tab.template.html
new file mode 100644
index 000000000000..f4d967e2d23c
--- /dev/null
+++ b/modules/meeting/frontend/module/meetings-tab/meetings-tab.template.html
@@ -0,0 +1,11 @@
+
+
+
+
+
+
+
+
+
+
+
diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb
index 1c7cbe17434a..6a17dc07d9e4 100644
--- a/modules/meeting/lib/open_project/meeting/engine.rb
+++ b/modules/meeting/lib/open_project/meeting/engine.rb
@@ -27,6 +27,7 @@
#++
require 'open_project/plugins'
+require_relative 'patches/api/work_package_representer'
module OpenProject::Meeting
class Engine < ::Rails::Engine
@@ -41,7 +42,8 @@ class Engine < ::Rails::Engine
permission :view_meetings,
{ meetings: %i[index show download_ics],
meeting_agendas: %i[history show diff],
- meeting_minutes: %i[history show diff] },
+ meeting_minutes: %i[history show diff],
+ work_package_meetings_tab: %i[index] },
permissible_on: :project
permission :create_meetings,
{ meetings: %i[new create copy] },
@@ -51,7 +53,8 @@ class Engine < ::Rails::Engine
permission :edit_meetings,
{
meetings: %i[edit cancel_edit update update_title update_details update_participants],
- 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],
+ work_package_meetings_tab: %i[add_work_package_to_meeting_dialog add_work_package_to_meeting]
},
permissible_on: :project,
require: :member
@@ -65,7 +68,7 @@ class Engine < ::Rails::Engine
require: :member
permission :create_meeting_agendas,
{
- meeting_agendas: %i[update preview],
+ meeting_agendas: %i[update preview]
},
permissible_on: :project,
require: :member
@@ -146,6 +149,9 @@ class Engine < ::Rails::Engine
patches [:Project]
patch_with_namespace :BasicData, :SettingSeeder
+ extend_api_response(:v3, :work_packages, :work_package,
+ &::OpenProject::Meeting::Patches::API::WorkPackageRepresenter.extension)
+
add_api_endpoint 'API::V3::Root' do
mount ::API::V3::Meetings::MeetingContentsAPI
end
diff --git a/modules/meeting/lib/open_project/meeting/patches/api/work_package_representer.rb b/modules/meeting/lib/open_project/meeting/patches/api/work_package_representer.rb
new file mode 100644
index 000000000000..2b23dc6b1f72
--- /dev/null
+++ b/modules/meeting/lib/open_project/meeting/patches/api/work_package_representer.rb
@@ -0,0 +1,23 @@
+module OpenProject::Meeting
+ module Patches
+ module API
+ module WorkPackageRepresenter
+ module_function
+
+ def extension
+ ->(*) do
+ link :meetings,
+ cache_if: -> { current_user.allowed_to?(:view_meetings, represented.project) } do
+ next if represented.new_record?
+
+ {
+ href: "#{work_package_path(id: represented.id)}/tabs/meetings",
+ title: "meetings"
+ }
+ end
+ end
+ end
+ end
+ end
+ end
+end
diff --git a/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb b/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb
index 9df042fb6d41..a36dd6f13e14 100644
--- a/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb
+++ b/modules/meeting/spec/contracts/meeting_agenda_items/create_contract_spec.rb
@@ -35,13 +35,13 @@
include_context 'ModelContract shared context'
shared_let(:project) { create(:project) }
- shared_let(:meeting) { create(:structured_meeting, project:) }
+ let(:meeting) { create(:structured_meeting, project:) }
let(:item) { build(:meeting_agenda_item, meeting:) }
let(:contract) { described_class.new(item, user) }
context 'with permission' do
let(:user) do
- create(:user, member_in_project: project, member_with_permissions: [:edit_meetings])
+ create(:user, member_in_project: project, member_with_permissions: %i[view_meetings edit_meetings])
end
it_behaves_like 'contract is valid'
@@ -53,11 +53,19 @@
it_behaves_like 'contract is invalid', base: I18n.t(:text_meeting_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: :error_unauthorized
+ it_behaves_like 'contract is invalid', base: :does_not_exist
end
end
diff --git a/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb
new file mode 100644
index 000000000000..c2be45e3e38f
--- /dev/null
+++ b/modules/meeting/spec/features/structured_meetings/work_package_meetings_tab_spec.rb
@@ -0,0 +1,309 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2023 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require 'spec_helper'
+require_relative '../../support/pages/work_package_meetings_tab'
+
+RSpec.describe 'Open the Meetings tab', :js do
+ let(:user) do
+ create(:user,
+ member_in_project: project,
+ member_through_role: role)
+ end
+ let(:role) do
+ create(:role,
+ permissions: %i(view_work_packages
+ view_meetings
+ edit_meetings))
+ end
+ let(:project) { create(:project) }
+ let(:work_package) { create(:work_package, project:, subject: 'A test work_package') }
+ let(:meetings_tab) { Pages::MeetingsTab.new(work_package.id) }
+
+ let(:tabs) { Components::WorkPackages::Tabs.new(work_package) }
+ let(:meetings_tab_element) { find('.op-tab-row--link_selected', text: 'MEETINGS') }
+
+ shared_context "a meetings tab" do
+ before do
+ login_as(user)
+ end
+
+ it 'shows the meetings tab when the user is allowed to see it' do
+ work_package_page.visit!
+ work_package_page.switch_to_tab(tab: 'meetings')
+
+ meetings_tab.expect_tab_content_rendered
+ end
+
+ context 'when the user does not have the permissions to see the meetings tab' do
+ let(:role) do
+ create(:role,
+ permissions: %i(view_work_packages))
+ end
+
+ it 'does not show the meetings tab' do
+ work_package_page.visit!
+
+ meetings_tab.expect_tab_not_present
+ end
+ end
+
+ context 'when the meetings module is not enabled for the project' do
+ let(:project) { create(:project, disable_modules: 'meetings') }
+
+ it 'does not show the meetings tab' do
+ work_package_page.visit!
+
+ meetings_tab.expect_tab_not_present
+ end
+ end
+
+ context 'when the work_package is not referenced in an upcoming meeting' do
+ it 'shows an empty message within the upcoming meetings section' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_upcoming_counter_to_be(0)
+
+ expect(page).to have_content('This work package is not scheduled in an upcoming meeting agenda yet.')
+ end
+ end
+
+ context 'when the work_package is not referenced in a past meeting' do
+ it 'shows an empty message within the past meetings section' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_past_counter_to_be(0)
+ meetings_tab.switch_to_past_meetings_section
+
+ expect(page).to have_content('This work package was not mentioned in a past meeting.')
+ end
+ end
+
+ context 'when the work_package is already referenced in upcoming meetings' do
+ let!(:first_meeting) { create(:structured_meeting, project:) }
+ let!(:second_meeting) { create(:structured_meeting, project:) }
+
+ let!(:first_meeting_agenda_item_of_first_meeting) do
+ create(:meeting_agenda_item, meeting: first_meeting, work_package:, notes: "A very important note in first meeting!")
+ end
+ let!(:second_meeting_agenda_item_of_first_meeting) do
+ create(:meeting_agenda_item, meeting: first_meeting, work_package:,
+ notes: "Another very important note in the first meeting!")
+ end
+ let!(:meeting_agenda_item_of_second_meeting) do
+ create(:meeting_agenda_item, meeting: second_meeting, work_package:,
+ notes: "A very important note in the second meeting!")
+ end
+
+ it 'shows the meeting agenda items in the upcoming meetings section grouped by meeting' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_upcoming_counter_to_be(2)
+ meetings_tab.expect_past_counter_to_be(0)
+
+ page.within_test_selector("op-meeting-container-#{first_meeting.id}") do
+ expect(page).to have_content(first_meeting.title)
+ expect(page).to have_content(first_meeting_agenda_item_of_first_meeting.notes)
+ expect(page).to have_content(second_meeting_agenda_item_of_first_meeting.notes)
+ end
+
+ page.within_test_selector("op-meeting-container-#{second_meeting.id}") do
+ expect(page).to have_content(second_meeting.title)
+ expect(page).to have_content(meeting_agenda_item_of_second_meeting.notes)
+ end
+ end
+ end
+
+ context 'when the work_package was already referenced in past meetings' do
+ let!(:first_past_meeting) { create(:structured_meeting, project:, start_time: Date.yesterday - 10.hours) }
+ let!(:second_past_meeting) { create(:structured_meeting, project:, start_time: Date.yesterday - 10.hours) }
+
+ let!(:first_meeting_agenda_item_of_first_past_meeting) do
+ create(:meeting_agenda_item, meeting: first_past_meeting, work_package:, notes: "A very important note in first meeting!")
+ end
+ let!(:second_meeting_agenda_item_of_first_past_meeting) do
+ create(:meeting_agenda_item, meeting: first_past_meeting, work_package:,
+ notes: "Another very important note in the first meeting!")
+ end
+ let!(:meeting_agenda_item_of_second_past_meeting) do
+ create(:meeting_agenda_item, meeting: second_past_meeting, work_package:,
+ notes: "A very important note in the second meeting!")
+ end
+
+ it 'shows the meeting agenda items in the past meetings section grouped by meeting' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_upcoming_counter_to_be(0)
+ meetings_tab.expect_past_counter_to_be(2)
+
+ meetings_tab.switch_to_past_meetings_section
+
+ page.within_test_selector("op-meeting-container-#{first_past_meeting.id}") do
+ expect(page).to have_content(first_past_meeting.title)
+ expect(page).to have_content(first_meeting_agenda_item_of_first_past_meeting.notes)
+ expect(page).to have_content(second_meeting_agenda_item_of_first_past_meeting.notes)
+ end
+
+ page.within_test_selector("op-meeting-container-#{second_past_meeting.id}") do
+ expect(page).to have_content(second_past_meeting.title)
+ expect(page).to have_content(meeting_agenda_item_of_second_past_meeting.notes)
+ end
+ end
+ end
+
+ context 'when user is allowed to edit meetings' do
+ it 'shows the add to meeting button' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_add_to_meeting_button_present
+ end
+
+ it 'opens the add to meeting dialog when clicking the add to meeting button' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ meetings_tab.expect_add_to_meeting_dialog_shown
+ end
+
+ context 'when open, upcoming meetings are visible for the user' do
+ let!(:past_meeting) { create(:structured_meeting, project:, start_time: Date.yesterday - 10.hours) }
+ let!(:first_upcoming_meeting) { create(:structured_meeting, project:) }
+ let!(:second_upcoming_meeting) { create(:structured_meeting, project:) }
+ let!(:closed_upcoming_meeting) { create(:structured_meeting, project:, state: :closed) }
+
+ it 'enables the user to add the work package to multiple open, upcoming meetings' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_upcoming_counter_to_be(0)
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ meetings_tab.fill_and_submit_meeting_dialog(
+ first_upcoming_meeting,
+ 'A very important note added from the meetings tab to the first meeting!'
+ )
+
+ meetings_tab.expect_upcoming_counter_to_be(1)
+
+ page.within_test_selector("op-meeting-container-#{first_upcoming_meeting.id}") do
+ expect(page).to have_content('A very important note added from the meetings tab to the first meeting!')
+ end
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ meetings_tab.fill_and_submit_meeting_dialog(
+ second_upcoming_meeting,
+ 'A very important note added from the meetings tab to the second meeting!'
+ )
+
+ meetings_tab.expect_upcoming_counter_to_be(2)
+
+ page.within_test_selector("op-meeting-container-#{second_upcoming_meeting.id}") do
+ expect(page).to have_content('A very important note added from the meetings tab to the second meeting!')
+ end
+ end
+
+ it 'does not enable the user to select a past meeting' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ fill_in('meeting_agenda_item_meeting_id', with: past_meeting.title)
+ expect(page).not_to have_selector('.ng-option-marked', text: past_meeting.title)
+ end
+
+ it 'does not enable the user to select a closed, upcoming meeting' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ fill_in('meeting_agenda_item_meeting_id', with: closed_upcoming_meeting.title)
+ expect(page).not_to have_selector('.ng-option-marked', text: closed_upcoming_meeting.title)
+ end
+
+ it 'requires a meeting to be selected' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.open_add_to_meeting_dialog
+
+ click_button('Save')
+
+ expect(page).to have_content('Meeting can\'t be blank')
+ end
+ end
+ end
+
+ context 'when user is not allowed to edit meetings' do
+ let(:restricted_role) do
+ create(:role,
+ permissions: %i(view_work_packages
+ view_meetings)) # edit_meetings is missing
+ end
+ let(:user) do
+ create(:user,
+ member_in_project: project,
+ member_through_role: restricted_role)
+ end
+
+ it 'does not show the add to meeting button' do
+ work_package_page.visit!
+ switch_to_meetings_tab
+
+ meetings_tab.expect_add_to_meeting_button_not_present
+ end
+ end
+ end
+
+ describe 'work package full view' do
+ let(:work_package_page) { Pages::FullWorkPackage.new(work_package) }
+
+ it_behaves_like 'a meetings tab'
+ end
+
+ describe 'work package split view' do
+ let(:work_package_page) { Pages::SplitWorkPackage.new(work_package) }
+
+ it_behaves_like 'a meetings tab'
+ end
+
+ def switch_to_meetings_tab
+ work_package_page.switch_to_tab(tab: 'meetings')
+ meetings_tab.expect_tab_content_rendered # wait for the tab to be rendered
+ end
+end
diff --git a/modules/meeting/spec/support/pages/work_package_meetings_tab.rb b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb
new file mode 100644
index 000000000000..b7a1436d510b
--- /dev/null
+++ b/modules/meeting/spec/support/pages/work_package_meetings_tab.rb
@@ -0,0 +1,112 @@
+#-- copyright
+# OpenProject is an open source project management software.
+# Copyright (C) 2012-2023 the OpenProject GmbH
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License version 3.
+#
+# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows:
+# Copyright (C) 2006-2013 Jean-Philippe Lang
+# Copyright (C) 2010-2013 the ChiliProject Team
+#
+# This program is free software; you can redistribute it and/or
+# modify it under the terms of the GNU General Public License
+# as published by the Free Software Foundation; either version 2
+# of the License, or (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program; if not, write to the Free Software
+# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
+#
+# See COPYRIGHT and LICENSE files for more details.
+#++
+
+require 'rbconfig'
+require 'support/pages/page'
+
+module Pages
+ class MeetingsTab < Page
+ attr_reader :work_package_id
+
+ def initialize(work_package_id)
+ super()
+ @work_package_id = work_package_id
+ end
+
+ def path
+ "/work_packages/#{work_package_id}/tabs/meetings"
+ end
+
+ def expect_tab_not_present
+ expect(page).not_to have_selector('.op-tab-row--link', text: 'MEETINGS')
+ end
+
+ def expect_tab_content_rendered
+ expect(page).to have_test_selector('op-work-package-meetings-tab-container')
+ end
+
+ def expect_upcoming_counter_to_be(amount)
+ page.within_test_selector('op-upcoming-meetings-counter') do
+ expect(page).to have_content(amount)
+ end
+ end
+
+ def expect_past_counter_to_be(amount)
+ page.within_test_selector('op-past-meetings-counter') do
+ expect(page).to have_content(amount)
+ end
+ end
+
+ def expect_add_to_meeting_button_present
+ expect(page).to have_test_selector('op-add-work-package-to-meeting-dialog-trigger')
+ end
+
+ def expect_add_to_meeting_button_not_present
+ expect(page).not_to have_test_selector('op-add-work-package-to-meeting-dialog-trigger')
+ end
+
+ def expect_add_to_meeting_dialog_shown
+ expect(page).to have_test_selector('op-add-work-package-to-meeting-dialog-body')
+ end
+
+ def switch_to_upcoming_meetings_section
+ within container_element do
+ find('.tabnav-tab', text: 'Upcoming').click
+ end
+ end
+
+ def switch_to_past_meetings_section
+ within container_element do
+ find('.tabnav-tab', text: 'Past').click
+ end
+ end
+
+ def open_add_to_meeting_dialog
+ page.find_test_selector('op-add-work-package-to-meeting-dialog-trigger').click
+ end
+
+ def fill_and_submit_meeting_dialog(meeting, notes)
+ fill_in('meeting_agenda_item_meeting_id', with: meeting.title)
+ expect(page).to have_selector('.ng-option-marked', text: meeting.title) # wait for selection
+ page.find('.ng-option-marked').click
+ page.find('.ck-editor__editable').set(notes)
+
+ click_button('Save')
+ end
+
+ private
+
+ def container_element
+ page.find_test_selector('op-work-package-meetings-tab-container')
+ end
+
+ def osx?
+ RbConfig::CONFIG['host_os'].include?('darwin')
+ end
+ end
+end
diff --git a/spec/support/finders/test_selector.rb b/spec/support/finders/test_selector.rb
index 61ee5381a465..a1fe7c4f5382 100644
--- a/spec/support/finders/test_selector.rb
+++ b/spec/support/finders/test_selector.rb
@@ -36,8 +36,13 @@ def find_test_selector(value, **)
find(:test_id, value, **)
end
- def within_test_selector(value, **, &block)
- within(:test_id, value, **, &block)
+ def within_test_selector(value, **, &)
+ within(:test_id, value, **, &)
+ end
+
+ # expect(page).to have_test_selector('foo')
+ def have_test_selector(value)
+ have_selector(test_selector(value))
end
end