From 36d390c8aeaeadccdeb9343eed9c678eb8954926 Mon Sep 17 00:00:00 2001 From: Henriette Darge Date: Fri, 9 May 2025 13:16:55 +0200 Subject: [PATCH 001/216] Show tree view next to hierachy CF --- .../hierarchy/tree_view_component.html.erb | 37 +++++++++ .../hierarchy/tree_view_component.rb | 77 +++++++++++++++++++ .../hierarchy/items/index.html.erb | 12 ++- .../hierarchy_custom_field_spec.rb | 42 +++++++--- spec/support/components/tree_view.rb | 57 ++++++++++++++ 5 files changed, 215 insertions(+), 10 deletions(-) create mode 100644 app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb create mode 100644 app/components/admin/custom_fields/hierarchy/tree_view_component.rb create mode 100644 spec/support/components/tree_view.rb diff --git a/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb b/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb new file mode 100644 index 00000000000..13751bf8a3e --- /dev/null +++ b/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb @@ -0,0 +1,37 @@ +<%#-- 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. + +++#%> +<%= render(Primer::Beta::Heading.new(tag: :h3, font_size: 4, mb: 2)) { @custom_field.name } %> + +<%= + render(Primer::OpenProject::TreeView.new(node_variant: :anchor)) do |tree_view| + hierarchy_items.each do |item| + add_sub_tree(tree_view, item) + end + end +%> diff --git a/app/components/admin/custom_fields/hierarchy/tree_view_component.rb b/app/components/admin/custom_fields/hierarchy/tree_view_component.rb new file mode 100644 index 00000000000..88d8c4b0c74 --- /dev/null +++ b/app/components/admin/custom_fields/hierarchy/tree_view_component.rb @@ -0,0 +1,77 @@ +# 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 Admin + module CustomFields + module Hierarchy + class TreeViewComponent < ApplicationComponent + def initialize(custom_field:, active_item:) + super + + @custom_field = custom_field + @active_item = active_item + @hierarchy_service = ::CustomFields::Hierarchy::HierarchicalItemService.new + end + + def hierarchy_items + @custom_field.hierarchy_root.children + end + + def add_sub_tree(tree, item) + if item.children.empty? + tree.with_leaf(**item_options(item)) + else + tree.with_sub_tree(expanded: expanded?(item), **item_options(item)) do |sub_tree| + item.children.each do |sub_item| + add_sub_tree(sub_tree, sub_item) + end + end + end + end + + def item_options(item) + { + label: item.label, + current: current?(item), + href: custom_field_item_path(@custom_field, item) + } + end + + def current?(item) + item.id == @active_item.id + end + + def expanded?(item) + @hierarchy_service.descendant_of?(item: @active_item, parent: item).success? + end + end + end + end +end diff --git a/app/views/admin/custom_fields/hierarchy/items/index.html.erb b/app/views/admin/custom_fields/hierarchy/items/index.html.erb index b9b6d5af06f..9d2f8e5f76e 100644 --- a/app/views/admin/custom_fields/hierarchy/items/index.html.erb +++ b/app/views/admin/custom_fields/hierarchy/items/index.html.erb @@ -31,4 +31,14 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Admin::CustomFields::EditFormHeaderComponent.new(custom_field: @custom_field, selected: :items)) %> -<%= render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item)) %> +<%= + render(Primer::Alpha::Layout.new(stacking_breakpoint: :md)) do |content| + content.with_main do + render(Admin::CustomFields::Hierarchy::ItemsComponent.new(item: @active_item)) + end + + content.with_sidebar(row_placement: :start, col_placement: :start, border: true, border_radius: 2, p: 3) do + render(Admin::CustomFields::Hierarchy::TreeViewComponent.new(custom_field: @custom_field, active_item: @active_item)) + end + end +%> diff --git a/spec/features/custom_fields/hierarchy_custom_field_spec.rb b/spec/features/custom_fields/hierarchy_custom_field_spec.rb index 3a9997d6332..319a1685c3d 100644 --- a/spec/features/custom_fields/hierarchy_custom_field_spec.rb +++ b/spec/features/custom_fields/hierarchy_custom_field_spec.rb @@ -160,19 +160,21 @@ RSpec.describe "custom fields of type hierarchy", :js do context "when navigating the hierarchy" do let(:service) { CustomFields::Hierarchy::HierarchicalItemService.new } - let(:custom_field) { create(:wp_custom_field, field_format: "hierarchy", hierarchy_root: nil) } + let(:custom_field) { create(:wp_custom_field, name: "Hogwarts", field_format: "hierarchy", hierarchy_root: nil) } let!(:root) { service.generate_root(custom_field).value! } - let!(:luke) { service.insert_item(parent: root, label: "Luke", short: "LS").value! } - let!(:r2d2) { service.insert_item(parent: luke, label: "R2-D2", short: "R2").value! } - let!(:mouse) { service.insert_item(parent: r2d2, label: "Mouse Droid", short: "MD").value! } - let!(:c3po) { service.insert_item(parent: luke, label: "C-3PO", short: "3PO").value! } - let!(:mara) { service.insert_item(parent: root, label: "Mara", short: "MJ").value! } + let!(:ravenclaw) { service.insert_item(parent: root, label: "Ravenclaw").value! } + let!(:slytherin) { service.insert_item(parent: root, label: "Slytherin").value! } + let!(:hufflepuff) { service.insert_item(parent: root, label: "Hufflepuff").value! } + let!(:gryffindor) { service.insert_item(parent: root, label: "Gryffindor").value! } + let!(:luna) { service.insert_item(parent: ravenclaw, label: "Luna Lovegood").value! } + let!(:harry) { service.insert_item(parent: gryffindor, label: "Harry Potter").value! } + let!(:hermione) { service.insert_item(parent: gryffindor, label: "Hermione Granger").value! } + let(:tree_view) { Components::TreeView.new } before do custom_field.reload hierarchy_page.add_custom_field_state(custom_field) - - visit custom_field_item_path(root.custom_field_id, luke) + visit custom_field_item_path(root.custom_field_id, gryffindor) end it "can navigate and keep the tab selection (regression #63921)" do @@ -181,7 +183,29 @@ RSpec.describe "custom fields of type hierarchy", :js do hierarchy_page.expect_tab "Items" # Navigating to an item will keep the tab nav selection - page.find_test_selector("op-custom-fields--hierarchy-item", text: "C-3PO").click + page.find_test_selector("op-custom-fields--hierarchy-item", text: "Hermione Granger").click + hierarchy_page.expect_tab "Items" + end + + it "can use the TreeView for navigation" do + expect(page).to have_test_selector("op-custom-fields--hierarchy-item", count: 2) + + # Expect the current item to be selected + tree_view.should_have_active_item("Gryffindor") + + # All other nodes are collapsed initially + tree_view.should_have_collapsed_node("Ravenclaw") + + # Navigate to another item + tree_view.open_node "Ravenclaw" + tree_view.click_node "Luna Lovegood" + + # Expect tree and page to update + tree_view.should_have_active_item("Luna Lovegood") + tree_view.should_have_open_node("Ravenclaw") + tree_view.should_have_collapsed_node("Gryffindor") + + expect(page).to have_test_selector("op-custom-fields--hierarchy-item", count: 0) hierarchy_page.expect_tab "Items" end end diff --git a/spec/support/components/tree_view.rb b/spec/support/components/tree_view.rb new file mode 100644 index 00000000000..341c3eced25 --- /dev/null +++ b/spec/support/components/tree_view.rb @@ -0,0 +1,57 @@ +# 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 Components + class TreeView + include Capybara::DSL + include Capybara::RSpecMatchers + include RSpec::Matchers + + def should_have_active_item(name) + expect(page).to have_css(".TreeViewItemContent", text: name, aria: { current: true }) + end + + def should_have_collapsed_node(name) + expect(page).to have_css(".TreeViewItemContent", text: name, aria: { expanded: false }) + end + + def should_have_open_node(name) + expect(page).to have_css(".TreeViewItemContent", text: name, aria: { expanded: true }) + end + + def open_node(name) + page.find(".TreeViewItemContent", text: name).sibling(".TreeViewItemToggle").click + end + + def click_node(name) + page.find(".TreeViewItemContent", text: name).click + end + end +end From 542cfd64b402f3fbeffe626dc73b1291c6bf64d5 Mon Sep 17 00:00:00 2001 From: Cameron Dutro Date: Wed, 28 May 2025 14:05:12 -0700 Subject: [PATCH 002/216] Improve database performance when fetching custom field hierarchy --- .../hierarchy/tree_view_component.html.erb | 4 +--- .../hierarchy/tree_view_component.rb | 22 +++++++++---------- 2 files changed, 11 insertions(+), 15 deletions(-) diff --git a/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb b/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb index 13751bf8a3e..ed3a1fff18c 100644 --- a/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb +++ b/app/components/admin/custom_fields/hierarchy/tree_view_component.html.erb @@ -30,8 +30,6 @@ See COPYRIGHT and LICENSE files for more details. <%= render(Primer::OpenProject::TreeView.new(node_variant: :anchor)) do |tree_view| - hierarchy_items.each do |item| - add_sub_tree(tree_view, item) - end + add_sub_tree(tree_view, hierarchy_items) end %> diff --git a/app/components/admin/custom_fields/hierarchy/tree_view_component.rb b/app/components/admin/custom_fields/hierarchy/tree_view_component.rb index 88d8c4b0c74..c29fac1f2f3 100644 --- a/app/components/admin/custom_fields/hierarchy/tree_view_component.rb +++ b/app/components/admin/custom_fields/hierarchy/tree_view_component.rb @@ -41,16 +41,18 @@ module Admin end def hierarchy_items - @custom_field.hierarchy_root.children + @custom_field.hierarchy_root.hash_tree.first[1] end - def add_sub_tree(tree, item) - if item.children.empty? - tree.with_leaf(**item_options(item)) - else - tree.with_sub_tree(expanded: expanded?(item), **item_options(item)) do |sub_tree| - item.children.each do |sub_item| - add_sub_tree(sub_tree, sub_item) + def add_sub_tree(tree, hierarchy_hash) + hierarchy_hash.each do |item, child_hash| + if child_hash.empty? + tree.with_leaf(**item_options(item)) + else + expanded = child_hash.any? { |child, _| current?(child) } + + tree.with_sub_tree(expanded: expanded, **item_options(item)) do |sub_tree| + add_sub_tree(sub_tree, child_hash) end end end @@ -67,10 +69,6 @@ module Admin def current?(item) item.id == @active_item.id end - - def expanded?(item) - @hierarchy_service.descendant_of?(item: @active_item, parent: item).success? - end end end end From 3e13f5c3ab225b3e18f81bd2b3abc1d41f529f88 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Wed, 28 May 2025 20:43:34 +0300 Subject: [PATCH 003/216] Reminders: Offer quick-set options like 'tomorrow' or 'next week' with smart defaults https://community.openproject.org/work_packages/60357 --- .../reminder/modal_body_component.rb | 30 +++++++-- .../work_packages/reminders_controller.rb | 3 +- config/locales/js-en.yml | 10 ++- .../wp-reminder-button.html | 20 ++++-- .../wp-reminder-context-menu.directive.ts | 65 +++++++++++++++++++ .../wp-reminder-modal/reminder.types.ts | 11 ++++ .../wp-reminder-modal/wp-reminder.modal.ts | 5 +- .../openproject-work-packages.module.ts | 4 ++ spec/features/work_packages/reminders_spec.rb | 36 +++++----- .../pages/work_packages/full_work_package.rb | 10 ++- 10 files changed, 162 insertions(+), 32 deletions(-) create mode 100644 frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts create mode 100644 frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.types.ts diff --git a/app/components/work_packages/reminder/modal_body_component.rb b/app/components/work_packages/reminder/modal_body_component.rb index b723e462197..b6bc3dee361 100644 --- a/app/components/work_packages/reminder/modal_body_component.rb +++ b/app/components/work_packages/reminder/modal_body_component.rb @@ -37,14 +37,15 @@ module WorkPackages FORM_ID = "reminder-form" - attr_reader :remindable, :reminder, :errors + attr_reader :remindable, :reminder, :errors, :preset - def initialize(remindable:, reminder:, errors: nil) + def initialize(remindable:, reminder:, errors: nil, preset: nil) super @remindable = remindable @reminder = reminder @errors = errors + @preset = preset end class << self @@ -82,11 +83,32 @@ module WorkPackages end def remind_at_date_initial_value - format_time_as_date(@reminder.remind_at, format: "%Y-%m-%d") + return time_as_date(@reminder.remind_at) if @reminder.remind_at + return calculate_preset_date if @preset + + nil end def remind_at_time_initial_value - format_time(@reminder.remind_at, include_date: false, format: "%H:%M") + return format_time(@reminder.remind_at, include_date: false, format: "%H:%M") if @reminder.remind_at + + "09:00" + end + + private + + def calculate_preset_date + case @preset + when "tomorrow" then time_as_date(1.day.from_now) + when "three_days" then time_as_date(3.days.from_now) + when "week" then time_as_date(7.days.from_now) + when "month" then time_as_date(1.month.from_now) + when "custom" then nil + end + end + + def time_as_date(time) + format_time_as_date(time, format: "%Y-%m-%d") end end end diff --git a/app/controllers/work_packages/reminders_controller.rb b/app/controllers/work_packages/reminders_controller.rb index 43fe55baa37..2caec8c52fb 100644 --- a/app/controllers/work_packages/reminders_controller.rb +++ b/app/controllers/work_packages/reminders_controller.rb @@ -40,7 +40,8 @@ class WorkPackages::RemindersController < ApplicationController def modal_body render modal_component_class.new( remindable: @work_package, - reminder: @reminder + reminder: @reminder, + preset: params[:preset] ) end diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index f59a95bcad7..983c668e608 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1115,11 +1115,17 @@ en: duplicate_query_title: "Name of this view already exists. Change anyway?" text_no_results: "No matching views were found." reminders: - button_label: "Set reminder" + button_label: "Set a reminder" title: - new: "Set reminder" + new: "Set a reminder" edit: "Edit reminder" subtitle: "You will receive a notification for this work package at the chosen time." + presets: + tomorrow: "Tomorrow" + three_days: "In 3 days" + week: "In a week" + month: "In a month" + custom: "Custom date/time" scheduling: is_parent: "The dates of this work package are automatically deduced from its children. Activate 'Manual scheduling' to set the dates." is_switched_from_manual_to_automatic: "The dates of this work package may need to be recalculated after switching from manual to automatic scheduling due to relationships with other work packages." diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.html b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.html index f7efdd27ff0..8b3a86ad89a 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.html +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.html @@ -1,13 +1,21 @@ + + + + diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts new file mode 100644 index 00000000000..f843ad77bfb --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts @@ -0,0 +1,65 @@ +import { Directive, ElementRef, Input, OnInit } from '@angular/core'; +import { OPContextMenuService } from 'core-app/shared/components/op-context-menu/op-context-menu.service'; +import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive'; +import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { OpModalService } from 'core-app/shared/components/modal/modal.service'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { WorkPackageReminderModalComponent } from 'core-app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal'; +import { ReminderPreset, REMINDER_PRESET_OPTIONS } from 'core-app/features/work-packages/components/wp-reminder-modal/reminder.types'; + +@Directive({ + // eslint-disable-next-line @angular-eslint/directive-selector + selector: '[wpReminderContextMenu]', +}) +export class WorkPackageReminderContextMenuDirective extends OpContextMenuTrigger implements OnInit { + // eslint-disable-next-line @angular-eslint/no-input-rename + @Input('wpReminderContextMenu-workPackage') workPackage:WorkPackageResource; + + protected items:OpContextMenuItem[] = []; + + constructor( + readonly elementRef:ElementRef, + readonly opContextMenu:OPContextMenuService, + readonly I18n:I18nService, + readonly opModalService:OpModalService, + ) { + super(elementRef, opContextMenu); + } + + ngOnInit() { + this.buildItems(); + } + + public get locals() { + return { + items: this.items, + contextMenuId: 'reminder-dropdown-menu', + label: this.I18n.t('js.work_packages.reminders.set_reminder'), + }; + } + + private buildItems() { + this.items = REMINDER_PRESET_OPTIONS.map((preset) => ({ + disabled: false, + linkText: this.I18n.t(`js.work_packages.reminders.presets.${preset}`), + onClick: () => { + this.openModal(preset); + return true; + }, + })); + } + + private openModal(preset:ReminderPreset):void { + this.opModalService.show( + WorkPackageReminderModalComponent, + 'global', + { + workPackage: this.workPackage, + preset, + }, + false, + true, + ); + } +} diff --git a/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.types.ts b/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.types.ts new file mode 100644 index 00000000000..849cb65a556 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.types.ts @@ -0,0 +1,11 @@ +export enum ReminderPreset { + TOMORROW = 'tomorrow', + THREE_DAYS = 'three_days', + WEEK = 'week', + MONTH = 'month', + CUSTOM = 'custom', +} + +export type ReminderPresetValue = `${ReminderPreset}`; + +export const REMINDER_PRESET_OPTIONS = Object.values(ReminderPreset) as ReminderPreset[]; diff --git a/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.ts b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.ts index b11f1c1bd05..062e7ed78c0 100644 --- a/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.ts +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.ts @@ -15,6 +15,7 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-packag import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ActionsService } from 'core-app/core/state/actions/actions.service'; import { reminderModalUpdated } from 'core-app/features/work-packages/components/wp-reminder-modal/reminder.actions'; +import { ReminderPreset } from 'core-app/features/work-packages/components/wp-reminder-modal/reminder.types'; import { Observable } from 'rxjs'; import { map } from 'rxjs/operators'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; @@ -33,6 +34,7 @@ export class WorkPackageReminderModalComponent extends OpModalComponent implemen private workPackage:WorkPackageResource; public frameSrc:string; + private preset:ReminderPreset | undefined; text = { new_title: this.I18n.t('js.work_packages.reminders.title.new'), @@ -57,12 +59,13 @@ export class WorkPackageReminderModalComponent extends OpModalComponent implemen super(locals, cdRef, elementRef); this.workPackage = this.locals.workPackage as WorkPackageResource; + this.preset = this.locals.preset as ReminderPreset | undefined; this.title$ = this .isEditMode() .pipe( map((isEditMode) => (isEditMode ? this.text.edit_title : this.text.new_title)), ); - this.frameSrc = this.pathHelper.workPackageReminderModalBodyPath(this.workPackage.id as string); + this.frameSrc = this.pathHelper.workPackageReminderModalBodyPath(this.workPackage.id as string) + (this.preset ? `?preset=${this.preset}` : ''); } ngOnInit() { diff --git a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts index 063fb637276..1e8e1ee101e 100644 --- a/frontend/src/app/features/work-packages/openproject-work-packages.module.ts +++ b/frontend/src/app/features/work-packages/openproject-work-packages.module.ts @@ -405,6 +405,9 @@ import { } from 'core-app/features/work-packages/routing/wp-split-view/wp-split-view-entry.component'; import { OpWpDatePickerModalComponent } from 'core-app/shared/components/datepicker/wp-date-picker-modal/wp-date-picker.modal'; import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openproject-enterprise.module'; +import { + WorkPackageReminderContextMenuDirective, +} from 'core-app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive'; @NgModule({ imports: [ @@ -596,6 +599,7 @@ import { OpenprojectEnterpriseModule } from 'core-app/features/enterprise/openpr WorkPackageSplitViewToolbarComponent, WorkPackageWatcherButtonComponent, WorkPackageReminderButtonComponent, + WorkPackageReminderContextMenuDirective, WorkPackageShareButtonComponent, WorkPackageSubjectComponent, diff --git a/spec/features/work_packages/reminders_spec.rb b/spec/features/work_packages/reminders_spec.rb index 0a12d740d27..6c5ce1b0633 100644 --- a/spec/features/work_packages/reminders_spec.rb +++ b/spec/features/work_packages/reminders_spec.rb @@ -67,11 +67,11 @@ RSpec.describe "Work package reminder modal", date = time.to_date work_package_page.visit! - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu wait_for_network_idle within ".spot-modal" do expect(page) - .to have_css(".spot-modal--header-title", text: "Set reminder") + .to have_css(".spot-modal--header-title", text: "Set a reminder") expect(page) .to have_css(".spot-modal--subheader", text: "You will receive a notification for this work package at the chosen time.") @@ -176,12 +176,12 @@ RSpec.describe "Work package reminder modal", work_package_page.dismiss_flash! work_package_page.expect_reminder_button_alarm_not_set_icon - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu wait_for_network_idle within ".spot-modal" do # Now it should be the create reminder modal - expect(page).to have_css(".spot-modal--header-title", text: "Set reminder") + expect(page).to have_css(".spot-modal--header-title", text: "Set a reminder") end end @@ -200,11 +200,11 @@ RSpec.describe "Work package reminder modal", today = Time.current.in_time_zone(current_user.time_zone).to_date work_package_page.visit! - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu wait_for_network_idle within ".spot-modal" do expect(page) - .to have_css(".spot-modal--header-title", text: "Set reminder") + .to have_css(".spot-modal--header-title", text: "Set a reminder") expect(page) .to have_css(".spot-modal--subheader", text: "You will receive a notification for this work package at the chosen time.") @@ -226,7 +226,6 @@ RSpec.describe "Work package reminder modal", wait_for_network_idle expect(page).to have_css(".FormControl-inlineValidation", text: "Time must be in the future.") - expect(page).to have_no_css(".FormControl-inlineValidation", text: "Date must be in the future.", wait: 0) # 30 minutes from now fill_in "Date", with: today @@ -243,12 +242,12 @@ RSpec.describe "Work package reminder modal", it "renders a required error on the date or time field when either isn't set" do work_package_page.visit! - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu wait_for_network_idle within ".spot-modal" do expect(page) - .to have_css(".spot-modal--header-title", text: "Set reminder") + .to have_css(".spot-modal--header-title", text: "Set a reminder") expect(page) .to have_css(".spot-modal--subheader", text: "You will receive a notification for this work package at the chosen time.") @@ -258,10 +257,11 @@ RSpec.describe "Work package reminder modal", wait_for_network_idle expect(page).to have_css(".FormControl-inlineValidation", text: "Date can't be blank") - expect(page).to have_css(".FormControl-inlineValidation", text: "Time can't be blank") + expect(page).to have_field("Time", with: "09:00") - # Fill in the date but not the time + # Fill in the date and unset the time fill_in "Date", with: 1.week.from_now.to_date + fill_in "Time", with: "" click_link_or_button "Set reminder" wait_for_network_idle @@ -317,6 +317,8 @@ RSpec.describe "Work package reminder modal", specify "clicking on the reminder button opens the edit reminder modal" do work_package_page.visit! + work_package_page.expect_reminder_button_alarm_set_icon + work_package_page.click_reminder_button wait_for_network_idle within ".spot-modal" do @@ -342,16 +344,16 @@ RSpec.describe "Work package reminder modal", specify "clicking on the reminder button opens the create reminder modal" do work_package_page.visit! - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu(menu_item: "Tomorrow") wait_for_network_idle within ".spot-modal" do expect(page) - .to have_css(".spot-modal--header-title", text: "Set reminder") + .to have_css(".spot-modal--header-title", text: "Set a reminder") expect(page) .to have_css(".spot-modal--subheader", text: "You will receive a notification for this work package at the chosen time.") - expect(page).to have_field("Date") - expect(page).to have_field("Time") + expect(page).to have_field("Date", with: 1.day.from_now.to_date) + expect(page).to have_field("Time", with: "09:00") expect(page).to have_field("Note") expect(page).to have_button("Set reminder") end @@ -360,11 +362,11 @@ RSpec.describe "Work package reminder modal", it "has a Primer close button that closes the Angular spot modal" do work_package_page.visit! - work_package_page.click_reminder_button + work_package_page.click_reminder_button_with_context_menu wait_for_network_idle within ".spot-modal" do expect(page) - .to have_css(".spot-modal--header-title", text: "Set reminder") + .to have_css(".spot-modal--header-title", text: "Set a reminder") find_test_selector("op-reminder-modal-close-button").click end diff --git a/spec/support/pages/work_packages/full_work_package.rb b/spec/support/pages/work_packages/full_work_package.rb index c6e7b7e77fb..003dfcf86f6 100644 --- a/spec/support/pages/work_packages/full_work_package.rb +++ b/spec/support/pages/work_packages/full_work_package.rb @@ -33,7 +33,7 @@ require "support/pages/work_packages/abstract_work_package" module Pages class FullWorkPackage < Pages::AbstractWorkPackage def ensure_loaded - find(".work-packages--details--subject", match: :first) + first(".work-packages--details--subject") end def toolbar @@ -77,6 +77,14 @@ module Pages expect(page).not_to have_test_selector("op-wp-reminder-button") end + def click_reminder_button_with_context_menu(menu_item: "Custom date/time") + click_reminder_button + + within "#reminder-dropdown-menu" do + click_link_or_button menu_item + end + end + def click_reminder_button within toolbar do # The request to the capabilities endpoint determines From 8de6f2d67839daf96538b05113fb9f2f3faeac15 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 29 May 2025 13:07:53 +0300 Subject: [PATCH 004/216] a11ly: assert aria label is correctly provided --- config/locales/js-en.yml | 2 +- .../wp-reminder-context-menu.directive.ts | 2 +- spec/features/work_packages/reminders_spec.rb | 8 +++++++- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 983c668e608..98f7ebf34fa 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1115,7 +1115,7 @@ en: duplicate_query_title: "Name of this view already exists. Change anyway?" text_no_results: "No matching views were found." reminders: - button_label: "Set a reminder" + button_label: "Set reminder" title: new: "Set a reminder" edit: "Edit reminder" diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts index f843ad77bfb..08e9609c400 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts @@ -35,7 +35,7 @@ export class WorkPackageReminderContextMenuDirective extends OpContextMenuTrigge return { items: this.items, contextMenuId: 'reminder-dropdown-menu', - label: this.I18n.t('js.work_packages.reminders.set_reminder'), + label: this.I18n.t('js.work_packages.reminders.title.new'), }; } diff --git a/spec/features/work_packages/reminders_spec.rb b/spec/features/work_packages/reminders_spec.rb index 6c5ce1b0633..b628b088ce1 100644 --- a/spec/features/work_packages/reminders_spec.rb +++ b/spec/features/work_packages/reminders_spec.rb @@ -344,7 +344,13 @@ RSpec.describe "Work package reminder modal", specify "clicking on the reminder button opens the create reminder modal" do work_package_page.visit! - work_package_page.click_reminder_button_with_context_menu(menu_item: "Tomorrow") + work_package_page.click_reminder_button + + within "#reminder-dropdown-menu" do + expect(page).to have_css(".dropdown-menu", aria: { label: "Set a reminder" }) + click_link_or_button "Tomorrow" + end + wait_for_network_idle within ".spot-modal" do expect(page) From 69fd55dd5ee8b055f182a1b70ec6cd3e34556170 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 29 May 2025 14:07:48 +0300 Subject: [PATCH 005/216] Add unit tests for reminder modal body component --- .../reminder/modal_body_component.rb | 4 +- .../reminder/modal_body_component_spec.rb | 95 +++++++++++++++++++ 2 files changed, 97 insertions(+), 2 deletions(-) create mode 100644 spec/components/work_packages/reminder/modal_body_component_spec.rb diff --git a/app/components/work_packages/reminder/modal_body_component.rb b/app/components/work_packages/reminder/modal_body_component.rb index b6bc3dee361..13675204e21 100644 --- a/app/components/work_packages/reminder/modal_body_component.rb +++ b/app/components/work_packages/reminder/modal_body_component.rb @@ -56,9 +56,9 @@ module WorkPackages def submit_path if @reminder.persisted? - work_package_reminder_path(@remindable, @reminder) + url_helpers.work_package_reminder_path(@remindable, @reminder) else - work_package_reminders_path(@remindable) + url_helpers.work_package_reminders_path(@remindable) end end diff --git a/spec/components/work_packages/reminder/modal_body_component_spec.rb b/spec/components/work_packages/reminder/modal_body_component_spec.rb new file mode 100644 index 00000000000..d9d84136e0a --- /dev/null +++ b/spec/components/work_packages/reminder/modal_body_component_spec.rb @@ -0,0 +1,95 @@ +# 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 "spec_helper" + +RSpec.describe WorkPackages::Reminder::ModalBodyComponent, type: :component do + let(:remindable) { build_stubbed(:work_package) } + let(:reminder) { build_stubbed(:reminder) } + let(:preset) { nil } + + subject(:component) { described_class.new(remindable:, reminder:, preset:) } + + before do + render_inline(component) + end + + context "when the reminder is persisted" do + it "renders the component with the reminder data" do + expect(page).to have_field("Date", with: reminder.remind_at.in_time_zone(User.current.time_zone).to_date) + expect(page).to have_field("Time", with: reminder.remind_at.in_time_zone(User.current.time_zone).strftime("%H:%M")) + expect(page).to have_field("Note", with: reminder.note) + + expect(page).to have_button("Save") + end + end + + context "when the reminder is not persisted" do + let(:reminder) { Reminder.new } + + it "renders the component with the default data" do + expect(page).to have_field("Date") + expect(page.find_field("Date").value).to be_nil + + expect(page).to have_field("Time", with: "09:00") + expect(page).to have_field("Note") + + expect(page).to have_button("Set reminder") + end + end + + describe "Date presets" do + let(:reminder) { Reminder.new } + + shared_examples "Date field with preset value" do |preset_key, preset_value| + context "when the preset is #{preset_key}" do + let(:preset) { preset_key } + + it "renders the Date field with value #{preset_key}: #{preset_value.to_date}" do + expect(page).to have_field("Date", with: preset_value.to_date) + end + end + end + + it_behaves_like "Date field with preset value", "tomorrow", 1.day.from_now + it_behaves_like "Date field with preset value", "three_days", 3.days.from_now + it_behaves_like "Date field with preset value", "week", 7.days.from_now + it_behaves_like "Date field with preset value", "month", 1.month.from_now + + context "when the preset is custom" do + let(:preset) { "custom" } + + it "renders the Date field without a value" do + date_field = page.find_field("Date") + expect(date_field.value).to be_nil + end + end + end +end From 293dbca7e544495ae30696f8e21645724ec17a4b Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Thu, 29 May 2025 17:09:29 +0300 Subject: [PATCH 006/216] Add context menu headline Hide from screen readers, as there is already an aria-label on the context menu dropdown element --- .../wp-reminder-context-menu.directive.ts | 1 + .../components/op-context-menu/op-context-menu.html | 8 ++++++++ .../components/op-context-menu/op-context-menu.types.ts | 1 + spec/features/work_packages/reminders_spec.rb | 2 ++ 4 files changed, 12 insertions(+) diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts index 08e9609c400..fb3cc74decb 100644 --- a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-context-menu.directive.ts @@ -36,6 +36,7 @@ export class WorkPackageReminderContextMenuDirective extends OpContextMenuTrigge items: this.items, contextMenuId: 'reminder-dropdown-menu', label: this.I18n.t('js.work_packages.reminders.title.new'), + visibleLabel: true, }; } diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu.html b/frontend/src/app/shared/components/op-context-menu/op-context-menu.html index cf3951d5454..93fb879b3aa 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu.html +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu.html @@ -5,6 +5,14 @@ role="menu" [attr.aria-label]="locals.label" [ngClass]="{'-empty': items.length === 0 }"> +
  • + + +
  • diff --git a/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts b/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts index 17f42c82b6d..7c0a6176379 100644 --- a/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts +++ b/frontend/src/app/shared/components/op-context-menu/op-context-menu.types.ts @@ -20,6 +20,7 @@ export interface OpContextMenuLocalsMap { showAnchorRight?:boolean; contextMenuId?:string; label?:string; + visibleLabel?:boolean; /* eslint-disable @typescript-eslint/no-explicit-any */ [key:string]:any; } diff --git a/spec/features/work_packages/reminders_spec.rb b/spec/features/work_packages/reminders_spec.rb index b628b088ce1..05534d3f7e9 100644 --- a/spec/features/work_packages/reminders_spec.rb +++ b/spec/features/work_packages/reminders_spec.rb @@ -348,6 +348,8 @@ RSpec.describe "Work package reminder modal", within "#reminder-dropdown-menu" do expect(page).to have_css(".dropdown-menu", aria: { label: "Set a reminder" }) + expect(page).to have_css(".op-menu--headline", text: "SET A REMINDER", aria: { hidden: true }) + click_link_or_button "Tomorrow" end From e10e2d63549d735847d0f155d5a658fe16e1a449 Mon Sep 17 00:00:00 2001 From: Maya Berdygylyjova Date: Mon, 2 Jun 2025 10:08:29 +0200 Subject: [PATCH 007/216] fix-documentation-link (#19067) --- docs/user-guide/activity/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/user-guide/activity/README.md b/docs/user-guide/activity/README.md index a9cc73a4929..24e42ae47ed 100644 --- a/docs/user-guide/activity/README.md +++ b/docs/user-guide/activity/README.md @@ -57,7 +57,7 @@ You can: ### Internal comments (Enterprise add-on) > [!NOTE] -> Internal comments are an Enterprise add-on and can only be used with [Enterprise cloud](../../enterprise-guide/enterprise-cloud-guide/) or [Enterprise on-premises](../../enterprise-guide/enterprise-cloud-guide/). An upgrade from the free Community edition is easily possible. +> Internal comments are an Enterprise add-on and can only be used with [Enterprise cloud](../../enterprise-guide/enterprise-cloud-guide/) or [Enterprise on-premises](../../enterprise-guide/enterprise-on-premises-guide/). An upgrade from the free Community edition is easily possible. Projects may include external clients or suppliers, who can be invited to a project or individual work package with restricted roles. To keep sensitive discussions (for example rates, negotiations, or financial and contextual details) confined to the core team, internal comments can be used. These comments are only visible to authorized users and are not accessible to external participants. This allows teams to manage sensitive information directly within work packages and avoid using external tools, maintaining a single source of truth. From 82a7fd29cfe5ec82cd6df7af74d94808d5ed72b3 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 30 May 2025 12:11:40 +0300 Subject: [PATCH 008/216] [#59473] Set Reminders API https://community.openproject.org/work_packages/59473 From e98c3a53716f464f2f73a04f4a8fad41ca308b2c Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 30 May 2025 17:19:31 +0300 Subject: [PATCH 009/216] Structure reminder GET utility --- lib/api/v3/reminders/reminders_api.rb | 8 +++- .../api/v3/reminders/reminders_api_spec.rb | 47 ++++++++++--------- 2 files changed, 31 insertions(+), 24 deletions(-) diff --git a/lib/api/v3/reminders/reminders_api.rb b/lib/api/v3/reminders/reminders_api.rb index 359445d93a3..e187d5967d1 100644 --- a/lib/api/v3/reminders/reminders_api.rb +++ b/lib/api/v3/reminders/reminders_api.rb @@ -35,9 +35,13 @@ module API authorize_in_project(:view_work_packages, project: @work_package.project) end + helpers do + def reminders + @work_package.reminders.upcoming_and_visible_to(User.current) + end + end + get do - reminders = @work_package.reminders - .upcoming_and_visible_to(User.current) ReminderCollectionRepresenter.new(reminders, self_link: api_v3_paths.work_package_reminders(@work_package.id), current_user: User.current) diff --git a/spec/requests/api/v3/reminders/reminders_api_spec.rb b/spec/requests/api/v3/reminders/reminders_api_spec.rb index 2b82989e611..5be86a3cc29 100644 --- a/spec/requests/api/v3/reminders/reminders_api_spec.rb +++ b/spec/requests/api/v3/reminders/reminders_api_spec.rb @@ -31,6 +31,8 @@ require "spec_helper" RSpec.describe API::V3::Reminders::RemindersAPI do + include API::V3::Utilities::PathHelper + let!(:project) { create(:project) } let!(:role_with_permissions) { create(:project_role, permissions: %i[view_work_packages]) } @@ -96,31 +98,32 @@ RSpec.describe API::V3::Reminders::RemindersAPI do .result end - let(:href) { "/api/v3/work_packages/#{work_package.id}/reminders" } - let(:request) { get href } - let(:result) do - request - JSON.parse last_response.body - end - let(:subjects) { reminders.pluck("id") } - - def reminders - result["_embedded"]["elements"] - end - - context "with no permissions" do - current_user { other_user_without_permissions } - - it "responds with unprocessable entity" do - expect(result["errorIdentifier"]).to eq("urn:openproject-org:api:v3:errors:NotFound") + describe "GET /api/v3/work_packages/:work_package_id/reminders" do + let(:request) { get api_v3_paths.work_package_reminders(work_package.id) } + let(:result) do + request + JSON.parse last_response.body end - end + let(:subjects) { reminders.pluck("id") } - context "with permissions" do - current_user { user_with_permissions } + def reminders + result["_embedded"]["elements"] + end - it "returns the future reminders for the current user in the given work package" do - expect(subjects).to contain_exactly(user_with_permissions_future_reminder.id) + context "with no permissions" do + current_user { other_user_without_permissions } + + it "responds with error not found" do + expect(result["errorIdentifier"]).to eq("urn:openproject-org:api:v3:errors:NotFound") + end + end + + context "with permissions" do + current_user { user_with_permissions } + + it "returns the future reminders for the current user in the given work package" do + expect(subjects).to contain_exactly(user_with_permissions_future_reminder.id) + end end end end From d060182bf2cdf6228217b9e247cf8170d406fb81 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 30 May 2025 17:20:21 +0300 Subject: [PATCH 010/216] Add `POST /api/v3/work_packages/:work_package_id/reminders` --- config/locales/en.yml | 2 + .../reminders/reminder_payload_representer.rb | 35 ++++++++++++ lib/api/v3/reminders/reminder_representer.rb | 4 ++ lib/api/v3/reminders/reminders_api.rb | 15 +++++ .../api/v3/reminders/reminders_api_spec.rb | 57 +++++++++++++++++++ 5 files changed, 113 insertions(+) create mode 100644 lib/api/v3/reminders/reminder_payload_representer.rb diff --git a/config/locales/en.yml b/config/locales/en.yml index 1c5ca416213..b696c8cfbd3 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4579,6 +4579,8 @@ en: code_500_missing_enterprise_token: "The request can not be handled due to invalid or missing Enterprise token." bad_request: emoji_reactions_activity_type_not_supported: "This activity type does not support emoji reactions." + multiple_reminders_not_allowed: |- + You can only set one reminder at a time for a work package. Please delete or update the existing reminder. not_found: work_package: "The work package you are looking for cannot be found or has been deleted." expected: diff --git a/lib/api/v3/reminders/reminder_payload_representer.rb b/lib/api/v3/reminders/reminder_payload_representer.rb new file mode 100644 index 00000000000..7fa888e79f5 --- /dev/null +++ b/lib/api/v3/reminders/reminder_payload_representer.rb @@ -0,0 +1,35 @@ +# 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 API::V3::Reminders + class ReminderPayloadRepresenter < ReminderRepresenter + include ::API::Utilities::PayloadRepresenter + end +end diff --git a/lib/api/v3/reminders/reminder_representer.rb b/lib/api/v3/reminders/reminder_representer.rb index 88e78173cd5..3e991c509bd 100644 --- a/lib/api/v3/reminders/reminder_representer.rb +++ b/lib/api/v3/reminders/reminder_representer.rb @@ -42,6 +42,10 @@ module API associated_resource :creator, v3_path: :user, representer: ::API::V3::Users::UserRepresenter + + def _type + "Reminder" + end end end end diff --git a/lib/api/v3/reminders/reminders_api.rb b/lib/api/v3/reminders/reminders_api.rb index e187d5967d1..25d3f00799d 100644 --- a/lib/api/v3/reminders/reminders_api.rb +++ b/lib/api/v3/reminders/reminders_api.rb @@ -39,6 +39,14 @@ module API def reminders @work_package.reminders.upcoming_and_visible_to(User.current) end + + def restrict_multiple_reminders + if reminders.any? + raise ::API::Errors::BadRequest.new( + I18n.t("api_v3.errors.bad_request.multiple_reminders_not_allowed") + ) + end + end end get do @@ -46,6 +54,13 @@ module API self_link: api_v3_paths.work_package_reminders(@work_package.id), current_user: User.current) end + + post(&API::V3::Utilities::Endpoints::Create.new(model: Reminder, + before_hook: ->(request:) { request.restrict_multiple_reminders }, + params_modifier: ->(params) do + params.merge(remindable: @work_package, + creator: User.current) + end).mount) end end end diff --git a/spec/requests/api/v3/reminders/reminders_api_spec.rb b/spec/requests/api/v3/reminders/reminders_api_spec.rb index 5be86a3cc29..58775a10555 100644 --- a/spec/requests/api/v3/reminders/reminders_api_spec.rb +++ b/spec/requests/api/v3/reminders/reminders_api_spec.rb @@ -126,4 +126,61 @@ RSpec.describe API::V3::Reminders::RemindersAPI do end end end + + describe "POST /api/v3/work_packages/:work_package_id/reminders", :freeze_time do + let(:path) { api_v3_paths.work_package_reminders(work_package.id) } + let(:headers) { { "CONTENT_TYPE" => "application/json" } } + let(:remind_at) { 1.day.from_now } + let(:note) { "Remind me to do something" } + let(:params) { { remindAt: remind_at, note: } } + + def make_request + post path, params.to_json, headers + end + + context "with permissions" do + current_user { user_with_permissions } + + before do + Reminder.destroy_all + make_request + end + + it_behaves_like "successful response", 201, "Reminder" do + it "returns reminder attributes" do + expect(last_response.body) + .to be_json_eql(API::V3::Utilities::DateTimeFormatter.format_datetime(remind_at).to_json) + .at_path("remindAt") + + expect(last_response.body) + .to be_json_eql(note.to_json) + .at_path("note") + + expect(last_response.body) + .to be_json_eql({ "href" => "/api/v3/users/#{current_user.id}", "title" => current_user.name }.to_json) + .at_path("_links/creator") + end + end + end + + context "with an existing reminder" do + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "error response", + 400, "BadRequest", + "You can only set one reminder at a time for a work package. Please delete or update the existing reminder." + end + + context "with no permissions" do + current_user { other_user_without_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The work package you are looking for cannot be found or has been deleted." + end + end end From e5cd639d2229a6f9fbf2dac9f95750c2ea5867c4 Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Fri, 30 May 2025 18:25:46 +0300 Subject: [PATCH 011/216] Add `PATCH /api/v3/work_packages/:work_package_id/reminders/:reminder_id` --- config/locales/en.yml | 1 + lib/api/v3/reminders/reminders_api.rb | 8 +++ lib/api/v3/utilities/path_helper.rb | 4 ++ .../api/v3/reminders/reminders_api_spec.rb | 71 +++++++++++++++++++ 4 files changed, 84 insertions(+) diff --git a/config/locales/en.yml b/config/locales/en.yml index b696c8cfbd3..33d90556ca8 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -4583,6 +4583,7 @@ en: You can only set one reminder at a time for a work package. Please delete or update the existing reminder. not_found: work_package: "The work package you are looking for cannot be found or has been deleted." + reminder: "The reminder you are looking for cannot be found or has been deleted." expected: date: "YYYY-MM-DD (ISO 8601 date only)" datetime: "YYYY-MM-DDThh:mm:ss[.lll][+hh:mm] (any compatible ISO 8601 datetime)" diff --git a/lib/api/v3/reminders/reminders_api.rb b/lib/api/v3/reminders/reminders_api.rb index 25d3f00799d..139cfe7e71e 100644 --- a/lib/api/v3/reminders/reminders_api.rb +++ b/lib/api/v3/reminders/reminders_api.rb @@ -61,6 +61,14 @@ module API params.merge(remindable: @work_package, creator: User.current) end).mount) + + route_param :reminder_id, type: Integer, desc: "Reminder ID" do + after_validation do + @reminder = reminders.find(declared_params[:reminder_id]) + end + + patch(&API::V3::Utilities::Endpoints::Update.new(model: Reminder).mount) + end end end end diff --git a/lib/api/v3/utilities/path_helper.rb b/lib/api/v3/utilities/path_helper.rb index a66cc351dcc..dcad91b7d48 100644 --- a/lib/api/v3/utilities/path_helper.rb +++ b/lib/api/v3/utilities/path_helper.rb @@ -476,6 +476,10 @@ module API index :shares show :share + def self.work_package_reminder(work_package_id, reminder_id) + "#{work_package_reminders(work_package_id)}/#{reminder_id}" + end + def self.work_package_reminders(id) "#{work_package(id)}/reminders" end diff --git a/spec/requests/api/v3/reminders/reminders_api_spec.rb b/spec/requests/api/v3/reminders/reminders_api_spec.rb index 58775a10555..77f1e4d0cc0 100644 --- a/spec/requests/api/v3/reminders/reminders_api_spec.rb +++ b/spec/requests/api/v3/reminders/reminders_api_spec.rb @@ -183,4 +183,75 @@ RSpec.describe API::V3::Reminders::RemindersAPI do "The work package you are looking for cannot be found or has been deleted." end end + + describe "PATCH /api/v3/work_packages/:work_package_id/reminders/:reminder_id" do + let(:headers) { { "CONTENT_TYPE" => "application/json" } } + + let(:reminder) { create(:reminder, remindable: work_package, creator: user_with_permissions) } + let(:other_user_reminder) { create(:reminder, remindable: work_package, creator: other_user_without_permissions) } + + let(:completed_reminder) do + create(:reminder, :completed, remindable: work_package, creator: user_with_permissions) + end + + def make_request + patch path, params.to_json, headers + end + + context "with permissions updating own reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, reminder.id) } + let(:params) { { note: "UPDATED reminder note!" } } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "successful response", 200, "Reminder" do + it "returns updated reminder attributes" do + expect(last_response.body) + .to be_json_eql("UPDATED reminder note!".to_json) + .at_path("note") + end + end + end + + context "with permissions updating completed reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, completed_reminder.id) } + let(:params) { { note: "UPDATED reminder note!" } } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The reminder you are looking for cannot be found or has been deleted." + end + + context "with permissions updating other user's reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, other_user_reminder.id) } + let(:params) { { note: "CANNOT update!" } } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The reminder you are looking for cannot be found or has been deleted." + end + + context "with no permissions updating own reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, other_user_reminder.id) } + let(:params) { { note: "UPDATED reminder note!" } } + + current_user { other_user_without_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The work package you are looking for cannot be found or has been deleted." + end + end end From 0b6934d3192f3fda54e694a8fb57fff3264c8c8a Mon Sep 17 00:00:00 2001 From: Kabiru Mwenja Date: Mon, 2 Jun 2025 11:07:42 +0300 Subject: [PATCH 012/216] Add `DELETE /api/v3/work_packages/:work_package_id/reminders/:reminder_id` --- lib/api/v3/reminders/reminders_api.rb | 1 + .../api/v3/reminders/reminders_api_spec.rb | 61 +++++++++++++++++++ 2 files changed, 62 insertions(+) diff --git a/lib/api/v3/reminders/reminders_api.rb b/lib/api/v3/reminders/reminders_api.rb index 139cfe7e71e..4484f735587 100644 --- a/lib/api/v3/reminders/reminders_api.rb +++ b/lib/api/v3/reminders/reminders_api.rb @@ -68,6 +68,7 @@ module API end patch(&API::V3::Utilities::Endpoints::Update.new(model: Reminder).mount) + delete(&API::V3::Utilities::Endpoints::Delete.new(model: Reminder).mount) end end end diff --git a/spec/requests/api/v3/reminders/reminders_api_spec.rb b/spec/requests/api/v3/reminders/reminders_api_spec.rb index 77f1e4d0cc0..4fcfc663b7e 100644 --- a/spec/requests/api/v3/reminders/reminders_api_spec.rb +++ b/spec/requests/api/v3/reminders/reminders_api_spec.rb @@ -254,4 +254,65 @@ RSpec.describe API::V3::Reminders::RemindersAPI do "The work package you are looking for cannot be found or has been deleted." end end + + describe "DELETE /api/v3/work_packages/:work_package_id/reminders/:reminder_id" do + let(:headers) { { "CONTENT_TYPE" => "application/json" } } + + let(:reminder) { create(:reminder, remindable: work_package, creator: user_with_permissions) } + let(:other_user_reminder) { create(:reminder, remindable: work_package, creator: other_user_without_permissions) } + + let(:completed_reminder) do + create(:reminder, :completed, remindable: work_package, creator: user_with_permissions) + end + + def make_request + delete path, headers + end + + context "with permissions deleting own reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, reminder.id) } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "successful no content response" + end + + context "with permissions deleting completed reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, completed_reminder.id) } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The reminder you are looking for cannot be found or has been deleted." + end + + context "with permissions deleting other user's reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, other_user_reminder.id) } + + current_user { user_with_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The reminder you are looking for cannot be found or has been deleted." + end + + context "with no permissions deleting own reminder" do + let(:path) { api_v3_paths.work_package_reminder(work_package.id, other_user_reminder.id) } + + current_user { other_user_without_permissions } + + before { make_request } + + it_behaves_like "error response", + 404, "NotFound", + "The work package you are looking for cannot be found or has been deleted." + end + end end From 76ac879a310adbf17b00273d3a2c364ffb37df73 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Niels=20Lindenthal=20=F0=9F=87=AA=F0=9F=87=BA=20?= =?UTF-8?q?=F0=9F=87=BA=F0=9F=87=A6?= Date: Mon, 2 Jun 2025 11:12:29 +0200 Subject: [PATCH 013/216] Polish use case description for test management (#19066) wordings and fine-tunings --- docs/use-cases/test-management/README.md | 11 ++++++++--- .../test-case-configuration-example.png | Bin 193607 -> 56616 bytes 2 files changed, 8 insertions(+), 3 deletions(-) diff --git a/docs/use-cases/test-management/README.md b/docs/use-cases/test-management/README.md index 45a88c25020..7f2c5504867 100644 --- a/docs/use-cases/test-management/README.md +++ b/docs/use-cases/test-management/README.md @@ -15,18 +15,22 @@ keywords: test plan, test case, test case, test management ## Purpose -This guide describes how OpenProject can be used for lightweight test management by modeling test plans, test cases, and test runs using built-in features such as work package types and project templates. It enables teams to manage and execute tests within the same environment they already use for project and requirements tracking. +This guide describes how OpenProject can be used for lightweight test management by modeling test plans, test cases, and test runs using built-in features such as work package types, workflows, custom fields and project templates. It enables teams to manage and execute tests within the same environment they already use for project and requirements tracking. ## Scope and assumptions This use case is intended for teams that: -- Already use OpenProject for project planning and requirements tracking +- Already use OpenProject for project planning and requirements management - Want to include manual or semi-automated testing workflows - Need traceability between requirements, test cases, executions, and defects This setup is not a replacement for full-scale test automation or advanced lab management tools, but rather a structured and integrated approach to test tracking. +> [!NOTE] +> +> At OpenProject, automated testing is a key part of our development process. It helps us detect bugs early and avoid regressions, especially as the codebase grows. We use continuous integration and deployment (CI/CD) to ensure that changes are tested and released quickly and reliably. You can see our automated test runs on [GitHub Actions](https://github.com/opf/openproject/actions). In addition to automated tests, we also perform smoke tests and exploratory testing to cover areas that are harder to automate and to validate the overall user experience. + ## Structure and terminology ![Test management entities](test-management-entities.png) @@ -49,7 +53,7 @@ This setup is not a replacement for full-scale test automation or advanced lab m - Each `Test Case` has one or more Test Runs (child work packages). - A `Test Run` is specific to one software version. - `Test Plans` are realized as individual projects per version, created from a reusable template. -- `Bugs` can be linked to failed `Test Runs`. +- `Bugs` can be linked to failed Test Runs. This structure supports traceability from requirements to execution and defect reporting. @@ -148,3 +152,4 @@ Define custom fields that describe the test object such as: - Test environment (type `hierarchy`) - Tester (custom field type `user`) + diff --git a/docs/use-cases/test-management/test-case-configuration-example.png b/docs/use-cases/test-management/test-case-configuration-example.png index f6e2523d54872a615abb4df78aa36676b5e4e13e..03b7175d1429ec57f26a3554002d060fe53cf67d 100644 GIT binary patch literal 56616 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