From 9042decbd09bcc98231a28b46010a3066a1f9db1 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Mon, 13 Apr 2026 14:12:48 +0200 Subject: [PATCH] Add meetings widget to overview and home page --- .../announcement_component.html.erb | 2 +- .../blocks/{users.rb => meetings.rb} | 16 +- .../homescreen/blocks/users.html.erb | 23 --- config/initializers/homescreen.rb | 3 +- .../app/components/grids/widgets/subitems.rb | 4 - .../meetings/widgets/meetings.html.erb | 76 ++++++++++ .../components/meetings/widgets/meetings.rb | 110 ++++++++++++++ modules/meeting/config/locales/en.yml | 6 + .../meetings/widgets/meetings_spec.rb | 142 ++++++++++++++++++ ...portfolio_overview_grid_component.html.erb | 1 + .../program_overview_grid_component.html.erb | 1 + .../project_overview_grid_component.html.erb | 1 + 12 files changed, 342 insertions(+), 43 deletions(-) rename app/components/homescreen/blocks/{users.rb => meetings.rb} (81%) delete mode 100644 app/components/homescreen/blocks/users.html.erb create mode 100644 modules/meeting/app/components/meetings/widgets/meetings.html.erb create mode 100644 modules/meeting/app/components/meetings/widgets/meetings.rb create mode 100644 modules/meeting/spec/components/meetings/widgets/meetings_spec.rb diff --git a/app/components/homescreen/announcement_component.html.erb b/app/components/homescreen/announcement_component.html.erb index 43b1086e8450..72388f4d46db 100644 --- a/app/components/homescreen/announcement_component.html.erb +++ b/app/components/homescreen/announcement_component.html.erb @@ -27,4 +27,4 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<%= render(Primer::Alpha::Banner.new(scheme: :default)) { format_text announcement.text } %> +<%= render(Primer::Alpha::Banner.new(scheme: :default, mb: 3)) { format_text announcement.text } %> diff --git a/app/components/homescreen/blocks/users.rb b/app/components/homescreen/blocks/meetings.rb similarity index 81% rename from app/components/homescreen/blocks/users.rb rename to app/components/homescreen/blocks/meetings.rb index 55ebe7d798b8..1abfa96d3120 100644 --- a/app/components/homescreen/blocks/users.rb +++ b/app/components/homescreen/blocks/meetings.rb @@ -30,19 +30,9 @@ module Homescreen module Blocks - class Users < Grids::WidgetComponent - include IconsHelper - include OpenProject::ObjectLinking - include Redmine::I18n - - def initialize(*) - super - - @newest_users = User.active.newest.take(3) - end - - def title - I18n.t(:label_user_plural) + class Meetings < Grids::WidgetComponent + def call + render(::Meetings::Widgets::Meetings.new(limit: 3)) end end end diff --git a/app/components/homescreen/blocks/users.html.erb b/app/components/homescreen/blocks/users.html.erb deleted file mode 100644 index f88f3887caf7..000000000000 --- a/app/components/homescreen/blocks/users.html.erb +++ /dev/null @@ -1,23 +0,0 @@ -<%= widget_wrapper do %> -

<%= t("homescreen.additional.users") %>

- - <% unless @newest_users.empty? %> - - <% end %> - -
- <% if current_user.admin? %> - <%= link_to new_user_path, class: "button -primary" do %> - <%= op_icon("button--icon icon-add") %> - <%= t(:label_invite_user) %> - <% end %> - <% end %> -
-<% end %> diff --git a/config/initializers/homescreen.rb b/config/initializers/homescreen.rb index 70b11bffdb97..932eca9cf783 100644 --- a/config/initializers/homescreen.rb +++ b/config/initializers/homescreen.rb @@ -45,8 +45,7 @@ if: Proc.new { OpenProject::Configuration.show_community_links? } }, { - name: "users", - if: Proc.new { User.current.admin? } + name: "meetings" }, { name: "my_account", diff --git a/modules/grids/app/components/grids/widgets/subitems.rb b/modules/grids/app/components/grids/widgets/subitems.rb index 6866999d09a3..18379f90a0b2 100644 --- a/modules/grids/app/components/grids/widgets/subitems.rb +++ b/modules/grids/app/components/grids/widgets/subitems.rb @@ -57,10 +57,6 @@ def has_subitems? displayed_subitems.any? end - def wrapper_arguments - { full_width: true } - end - def can_create_sub_programs? project.portfolio? && can_create_sub_projects? end diff --git a/modules/meeting/app/components/meetings/widgets/meetings.html.erb b/modules/meeting/app/components/meetings/widgets/meetings.html.erb new file mode 100644 index 000000000000..78817dfb4d85 --- /dev/null +++ b/modules/meeting/app/components/meetings/widgets/meetings.html.erb @@ -0,0 +1,76 @@ +<%# -- copyright +OpenProject is an open source project management software. +Copyright (C) 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. + +++# %> + +<%= + widget_wrapper do |container| + if next_meetings.empty? + render(Primer::Beta::Blankslate.new(test_selector: "meetings-widget-empty")) do |component| + component.with_visual_icon(icon: :"comment-discussion") + component.with_heading(tag: :h3).with_content(t("meeting.widgets.blankslate.heading")) + component.with_description { t("meeting.widgets.blankslate.description") } + + if can_manage_meetings? + component.with_primary_action( + data: { controller: "async-dialog" }, + href: new_meetings_link, + test_selector: "meetings-widget-add-button", + scheme: :secondary + ) do |button| + button.with_leading_visual_icon(icon: :plus) + t(:label_meeting) + end + end + end + else + next_meetings.each do |item| + container.with_row do + render(Primer::Box.new(classes: "meeting-widget-item")) do + component_collection do |body| + body.with_component( + flex_layout(flex_wrap: :wrap) do |layout| + layout.with_column do + render(Primer::Beta::Link.new(font_weight: :bold, href: project_meeting_path(item.project, item))) { item.title } + end + end + ) + + body.with_component( + render(Primer::Beta::Text.new(tag: :p, color: :subtle)) { details_row_string(item) } + ) + end + end + end + end + + container.with_row do + render(Primer::Beta::Link.new(href: all_meetings_link)) { t("meeting.widgets.view_details") } + end + end + end +%> diff --git a/modules/meeting/app/components/meetings/widgets/meetings.rb b/modules/meeting/app/components/meetings/widgets/meetings.rb new file mode 100644 index 000000000000..1750e8e19d01 --- /dev/null +++ b/modules/meeting/app/components/meetings/widgets/meetings.rb @@ -0,0 +1,110 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 Widgets + class Meetings < Grids::WidgetComponent + MEETINGS_LIMIT = 5 + private_constant :MEETINGS_LIMIT + + param :project, optional: true + + option :limit, default: -> { MEETINGS_LIMIT } + + def next_meetings + @next_meetings ||= meetings.limit(limit).to_a + end + + def meetings + @meetings ||= + if project_scoped? + project.meetings.visible(current_user).upcoming + else + ::Meeting + .visible(current_user) + .upcoming + .includes(:project) + end + end + + def title + t(:label_meeting_plural) + end + + def render? + global_scoped? || project.module_enabled?("meetings") + end + + private + + def project_scoped? = project.present? + + def global_scoped? = !project_scoped? + + def can_manage_meetings? + if project_scoped? + current_user.allowed_in_project?(:create_meetings, project) + else + current_user.allowed_in_any_project?(:create_meetings) + end + end + + def details_row_string(meeting) + details = [] + details << helpers.format_time(meeting.start_time) + if meeting.duration.present? + details << meeting_duration(meeting) + end + details << "#{t(:label_project).capitalize}: #{meeting.project.name}" if global_scoped? + details.join(", ") + end + + def new_meetings_link + if global_scoped? + new_dialog_meetings_path + else + new_dialog_project_meetings_path(project) + end + end + + def all_meetings_link + if global_scoped? + meetings_path + else + project_meetings_path(project) + end + end + + def meeting_duration(meeting) + OpenProject::Common::DurationComponent.new(meeting.duration, :hours, abbreviated: true).text + end + end + end +end diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 030883a79a42..2f1bcf688253 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -433,6 +433,12 @@ en: update_failed: "Could not update participation status." meeting_not_found: "Meeting not found for the given UID." + widgets: + blankslate: + heading: "No upcoming meetings" + description: "Upcoming meetings where you are the organizer or a participant will appear here." + view_details: "View all meetings" + meeting_section: untitled_title: "Untitled section" delete_confirmation: "Deleting the section will also delete all of its agenda items. Are you sure you want to do this?" diff --git a/modules/meeting/spec/components/meetings/widgets/meetings_spec.rb b/modules/meeting/spec/components/meetings/widgets/meetings_spec.rb new file mode 100644 index 000000000000..5d6f3d78736d --- /dev/null +++ b/modules/meeting/spec/components/meetings/widgets/meetings_spec.rb @@ -0,0 +1,142 @@ +# frozen_string_literal: true + +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 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 "rails_helper" + +RSpec.describe Meetings::Widgets::Meetings, type: :component do + include Rails.application.routes.url_helpers + + def render_component(...) + render_inline(described_class.new(...)) + end + + shared_let(:project_red) { create(:project, name: "Red", enabled_module_names: [:meetings]) } + shared_let(:project_blue) { create(:project, name: "Blue", enabled_module_names: [:meetings]) } + shared_let(:author) { create(:user) } + shared_let(:admin) { create(:admin) } + + let(:project) { nil } + + let(:user) { admin } + + current_user { user } + + subject(:rendered_component) { render_component(project) } + + shared_examples "empty-state without action" do + it "renders empty blankslate without action" do + expect(rendered_component).to have_test_selector("meetings-widget-empty") + expect(rendered_component).to have_text("No upcoming meetings") + expect(rendered_component).to have_no_test_selector("meetings-widget-add-button") + end + end + + shared_examples "empty-state with action" do + it "renders empty blankslate with add action" do + expect(rendered_component).to have_test_selector("meetings-widget-empty") + expect(rendered_component).to have_text("No upcoming meetings") + expect(rendered_component).to have_test_selector("meetings-widget-add-button") + end + end + + context "for root" do + context "with no meetings" do + it_behaves_like "empty-state with action" + end + + context "with meetings" do + let!(:meeting_red) { create(:meeting, project: project_red, author:, start_time: 1.week.from_now, duration: 1) } + let!(:meeting_blue) { create(:meeting, project: project_blue, author:, start_time: 2.weeks.from_now, duration: 2) } + + it "renders meetings items from all projects", :aggregate_failures do + expect(rendered_component).to have_list_item(count: 3) + expect(rendered_component).to have_list_item(position: 2) do |item| + expect(item).to have_link href: project_meeting_path(project_blue, meeting_blue) + expect(item).to have_content("2 hrs") # Duration is formatted + expect(item).to have_content("Project: #{project_blue.name}") + end + + expect(rendered_component).to have_list_item(position: 3) do |item| + expect(item).to have_link href: meetings_path + expect(item).to have_content("View all meetings") + end + end + end + end + + context "with project" do + let(:project) { project_red } + # these meetings from another project should not be visible + let!(:other_project_meeting) { create(:meeting, project: project_blue, author:) } + + context "with no meetings in this project" do + it_behaves_like "empty-state with action" + end + + context "with meetings" do + let!(:meeting_red) { create(:meeting, project: project_red, author:, start_time: 1.week.from_now, duration: 1) } + + it "renders only this project’s meetings" do + expect(rendered_component).to have_list_item(count: 2) + expect(rendered_component).to have_list_item(position: 1) do |item| + expect(item).to have_link href: project_meeting_path(project_red, meeting_red) + expect(item).to have_content("1 hr") + expect(item).to have_no_content("Project: #{project_red.name}") # Project is not repeated + end + + expect(rendered_component).to have_list_item(position: 2) do |item| + expect(item).to have_link href: project_meetings_path(project_red) + expect(item).to have_content("View all meetings") + end + end + end + end + + context "when the project does not have the meetings module enabled" do + let(:project) { project_red } + let!(:meeting_item) { create(:meeting, project:, author:) } + + before do + project.enabled_module_names -= %w[meetings] + end + + it "renders nothing" do + expect(rendered_component.to_s).to be_empty + end + end + + context "when the user does not have permission to manage meetings" do + let(:project) { project_red } + let(:user) { create(:user) } + + # User has only view_meetings permission now + it_behaves_like "empty-state without action" + end +end diff --git a/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb index 8fa7952215ca..905c2557a074 100644 --- a/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/portfolio_overview_grid_component.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::Description, @portfolio) grid.with_widget(Grids::Widgets::ProjectStatus, @portfolio) grid.with_widget(Grids::Widgets::Subitems, @portfolio) + grid.with_widget(Meetings::Widgets::Meetings, @portfolio) grid.with_widget(Grids::Widgets::Members, @portfolio) grid.with_widget(Budgets::Widgets::BudgetTotals, @portfolio) grid.with_widget(Budgets::Widgets::BudgetByCostType, @portfolio) diff --git a/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb index 32b46789123b..c8d01625b92c 100644 --- a/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/program_overview_grid_component.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::Description, @program) grid.with_widget(Grids::Widgets::ProjectStatus, @program) grid.with_widget(Grids::Widgets::Subitems, @program) + grid.with_widget(Meetings::Widgets::Meetings, @program) grid.with_widget(Grids::Widgets::Members, @program) grid.with_widget(Budgets::Widgets::BudgetTotals, @program) grid.with_widget(Budgets::Widgets::BudgetByCostType, @program) diff --git a/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb b/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb index 1d356d8e8663..98e31cbe8010 100644 --- a/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb +++ b/modules/overviews/app/components/overviews/workspaces/project_overview_grid_component.html.erb @@ -32,6 +32,7 @@ See COPYRIGHT and LICENSE files for more details. grid.with_widget(Grids::Widgets::Description, @project) grid.with_widget(Grids::Widgets::ProjectStatus, @project) grid.with_widget(Grids::Widgets::Subitems, @project) + grid.with_widget(Meetings::Widgets::Meetings, @project) grid.with_widget(Grids::Widgets::Members, @project) grid.with_widget(Grids::Widgets::News, @project) grid.with_widget(Budgets::Widgets::BudgetTotals, @project)