diff --git a/app/components/_index.sass b/app/components/_index.sass index 45a89e33bc3f..97caf1e6b6ee 100644 --- a/app/components/_index.sass +++ b/app/components/_index.sass @@ -6,6 +6,7 @@ @import "work_packages/activities_tab/journals/item_component/add_reactions" @import "work_packages/activities_tab/journals/item_component/reactions" @import "shares/modal_body_component" +@import "work_packages/reminder/modal_body_component" @import "shares/invite_user_form_component" @import "work_packages/details/tab_component" @import "work_packages/progress/modal_body_component" diff --git a/app/components/work_packages/reminder/modal_body_component.html.erb b/app/components/work_packages/reminder/modal_body_component.html.erb new file mode 100644 index 000000000000..35a8f23fce65 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.html.erb @@ -0,0 +1,60 @@ +<%= component_wrapper(tag: "turbo-frame") do %> + <%= primer_form_with( + model: reminder, + url: submit_path, + id: FORM_ID + ) do |f| %> + <%= flex_layout(classes: "reminder-modal-body--form-flex-container") do |form_flex_container| %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::RemindAtDate.new(f, initial_value: remind_at_date_initial_value)) %> + <% end %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::RemindAtTime.new(f, initial_value: remind_at_time_initial_value)) %> + <% end %> + <%= form_flex_container.with_row do %> + <%= render(WorkPackages::Reminder::Note.new(f)) %> + <% end %> + <% if errors.present? %> + <%= form_flex_container.with_row(flex_layout: true) do |errors_container| %> + <% errors.full_messages.each do |error_message| %> + <%= errors_container.with_row(flex_layout: true) do |errors_row| %> + <%= errors_row.with_column(mr: 2) do %> + <%= render(Primer::Beta::Octicon.new(icon: :'alert-fill', color: :danger)) %> + <% end %> + <%= errors_row.with_column do %> + <%= render(Primer::Beta::Text.new(color: :danger)) do %> + <%= error_message %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% end %> + <% if reminder.persisted? %> + <%= form_flex_container.with_row(flex_layout: true, + justify_content: :space_between) do |actions_row| %> + <%= actions_row.with_column do %> + <%= render(Primer::Beta::Button.new( + scheme: :danger, + type: :submit, + formaction: work_package_reminder_path(remindable, reminder), + formmethod: :delete + )) { I18n.t(:button_delete_reminder) } %> + <% end %> + <%= actions_row.with_column do %> + <%= render(Primer::Beta::Button.new(scheme: :primary, + type: :submit)) { I18n.t(:button_save) } %> + <% end %> + <% end %> + <% else%> + <%= form_flex_container.with_row(flex_layout: true, + justify_content: :flex_end) do |actions_row| %> + <%= actions_row.with_column do %> + <%= render(Primer::Beta::Button.new(scheme: :primary, + type: :submit)) { I18n.t(:button_set_reminder) } %> + <% end %> + <% end %> + <% end%> + <% end %> + <% end %> +<% end %> diff --git a/app/components/work_packages/reminder/modal_body_component.rb b/app/components/work_packages/reminder/modal_body_component.rb new file mode 100644 index 000000000000..e8115ea2a539 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.rb @@ -0,0 +1,81 @@ +# 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 WorkPackages + module Reminder + class ModalBodyComponent < ApplicationComponent + include ApplicationHelper + include OpTurbo::Streamable + include OpPrimer::ComponentHelpers + + FORM_ID = "reminder-form" + + attr_reader :remindable, :reminder, :errors + + def initialize(remindable:, reminder:, errors: nil) + super + + @remindable = remindable + @reminder = reminder + @errors = errors + end + + class << self + def wrapper_key + "reminder_modal_body" + end + end + + def submit_path + if @reminder.persisted? + work_package_reminder_path(@remindable, @reminder) + else + work_package_reminders_path(@remindable) + end + end + + def submit_button_text + if @reminder.persisted? + I18n.t(:button_save) + else + I18n.t(:button_set_reminder) + end + end + + def remind_at_date_initial_value + format_time_as_date(@reminder.remind_at, format: "%Y-%m-%d") + end + + def remind_at_time_initial_value + format_time(@reminder.remind_at, include_date: false, format: "%H:%M") + end + end + end +end diff --git a/app/components/work_packages/reminder/modal_body_component.sass b/app/components/work_packages/reminder/modal_body_component.sass new file mode 100644 index 000000000000..3383e0b00196 --- /dev/null +++ b/app/components/work_packages/reminder/modal_body_component.sass @@ -0,0 +1,3 @@ +.reminder-modal-body + &--form-flex-container + gap: 1rem diff --git a/app/contracts/reminders/base_contract.rb b/app/contracts/reminders/base_contract.rb index b539f96f6361..738d0c8f2732 100644 --- a/app/contracts/reminders/base_contract.rb +++ b/app/contracts/reminders/base_contract.rb @@ -40,6 +40,7 @@ class BaseContract < ::ModelContract validate :validate_acting_user validate :validate_remindable_exists validate :validate_manage_reminders_permissions + validate :validate_remind_at_present validate :validate_remind_at_is_in_future validate :validate_note_length @@ -59,6 +60,10 @@ def validate_remindable_exists errors.add :remindable, :not_found if model.remindable.blank? end + def validate_remind_at_present + errors.add :remind_at, :blank if model.remind_at.blank? + end + def validate_remind_at_is_in_future if model.remind_at.present? && model.remind_at < Time.current errors.add :remind_at, :datetime_must_be_in_future diff --git a/app/controllers/work_packages/reminders_controller.rb b/app/controllers/work_packages/reminders_controller.rb new file mode 100644 index 000000000000..7708416a5357 --- /dev/null +++ b/app/controllers/work_packages/reminders_controller.rb @@ -0,0 +1,206 @@ +# 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. +# ++ + +class WorkPackages::RemindersController < ApplicationController + include OpTurbo::ComponentStream + layout false + before_action :find_work_package + before_action :build_or_find_reminder, only: %i[modal_body create] + before_action :find_reminder, only: %i[update destroy] + + before_action :authorize + + def modal_body + render modal_component_class.new( + remindable: @work_package, + reminder: @reminder + ) + end + + def create + service_result = Reminders::CreateService.new(user: current_user) + .call(reminder_params) + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_creation_message"), + scheme: :success + ) + respond_with_turbo_streams + else + prepare_errors_from_result(service_result) + + replace_via_turbo_stream( + component: modal_component_class.new( + remindable: @work_package, + reminder: @reminder, + errors: @errors + ) + ) + + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def update + service_result = Reminders::UpdateService.new(user: current_user, + model: @reminder) + .call(reminder_params) + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_update_message"), + scheme: :success + ) + respond_with_turbo_streams + else + prepare_errors_from_result(service_result) + + replace_via_turbo_stream( + component: modal_component_class.new( + remindable: @work_package, + reminder: @reminder, + errors: @errors + ) + ) + + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + def destroy + service_result = Reminders::DeleteService.new(user: current_user, + model: @reminder) + .call + + if service_result.success? + update_flash_message_via_turbo_stream( + message: I18n.t("work_package.reminders.success_deletion_message"), + scheme: :success + ) + respond_with_turbo_streams + else + update_flash_message_via_turbo_stream( + message: service_result.errors.full_messages, + scheme: :danger + ) + respond_with_turbo_streams(status: :unprocessable_entity) + end + end + + private + + def modal_component_class + WorkPackages::Reminder::ModalBodyComponent + end + + def find_work_package + @work_package = WorkPackage.visible.find(params[:work_package_id]) + end + + # At the form level, we split the date and time into two form fields. + # In order to be a bit more informative of which field is causing + # the remind_at attribute to be in the past/invalid, we need to + # remap the error attribute to the appropriate field. + def prepare_errors_from_result(service_result) + # We set the reminder here for "create" case + # as the record comes from the service. + @reminder = service_result.result + @errors = service_result.errors + + case @errors.find { |error| error.attribute == :remind_at }&.type + when :blank + handle_blank_error + when :datetime_must_be_in_future + handle_future_error + end + + @errors.delete(:remind_at) + end + + def handle_blank_error + @errors.add(:remind_at_date, :blank) if remind_at_date.blank? + @errors.add(:remind_at_time, :blank) if remind_at_time.blank? + end + + def handle_future_error + @errors.add(:remind_at_date, :datetime_must_be_in_future) if @reminder.remind_at.to_date < today_in_user_time_zone + @errors.add(:remind_at_time, :datetime_must_be_in_future) if @reminder.remind_at < now_in_user_time_zone + end + + def now_in_user_time_zone + @now_in_user_time_zone ||= Time.current + .in_time_zone(User.current.time_zone) + end + + def today_in_user_time_zone + @today_in_user_time_zone ||= now_in_user_time_zone.to_date + end + + # We assume for now that there is only one reminder per work package + def build_or_find_reminder + @reminder = @work_package.reminders + .upcoming_and_visible_to(User.current) + .last || @work_package.reminders.build + end + + def find_reminder + @reminder = @work_package.reminders + .upcoming_and_visible_to(User.current) + .find(params[:id]) + end + + def reminder_params + params.require(:reminder) + .permit(%i[remind_at_date remind_at_time note]) + .tap do |initial_params| + date = initial_params.delete(:remind_at_date) + time = initial_params.delete(:remind_at_time) + + initial_params[:remind_at] = build_remind_at_from_params(date, time) + initial_params[:remindable] = @work_package + initial_params[:creator] = User.current + end + end + + def build_remind_at_from_params(remind_at_date, remind_at_time) + if remind_at_date.present? && remind_at_time.present? + DateTime.parse("#{remind_at_date} #{User.current.time_zone.parse(remind_at_time)}") + end + end + + def remind_at_date + params[:reminder][:remind_at_date] + end + + def remind_at_time + params[:reminder][:remind_at_time] + end +end diff --git a/app/forms/work_packages/reminder/note.rb b/app/forms/work_packages/reminder/note.rb new file mode 100644 index 000000000000..35d278583718 --- /dev/null +++ b/app/forms/work_packages/reminder/note.rb @@ -0,0 +1,45 @@ +#-- 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. +#++ + +class WorkPackages::Reminder::Note < ApplicationForm + form do |reminder_form| + reminder_form.text_field( + name: :note, + required: false, + label: WorkPackage.human_attribute_name(:note), + placeholder: @placeholder, + visually_hide_label: false + ) + end + + def initialize + super + + @placeholder = I18n.t("work_package.reminders.note_placeholder") + end +end diff --git a/app/forms/work_packages/reminder/remind_at_date.rb b/app/forms/work_packages/reminder/remind_at_date.rb new file mode 100644 index 000000000000..68c0e01f2b4f --- /dev/null +++ b/app/forms/work_packages/reminder/remind_at_date.rb @@ -0,0 +1,48 @@ +#-- 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. +#++ + +class WorkPackages::Reminder::RemindAtDate < ApplicationForm + form do |reminder_form| + reminder_form.text_field( + name: :remind_at_date, + type: "date", + value: @initial_value, + placeholder: I18n.t(:label_date), + label: I18n.t(:label_date), + leading_visual: { icon: :calendar }, + required: true, + autofocus: false + ) + end + + def initialize(initial_value: DateTime.now.strftime("%Y-%m-%d")) + super() + + @initial_value = initial_value + end +end diff --git a/app/forms/work_packages/reminder/remind_at_time.rb b/app/forms/work_packages/reminder/remind_at_time.rb new file mode 100644 index 000000000000..221b9ab27618 --- /dev/null +++ b/app/forms/work_packages/reminder/remind_at_time.rb @@ -0,0 +1,51 @@ +#-- 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. +#++ + +class WorkPackages::Reminder::RemindAtTime < ApplicationForm + include Redmine::I18n + + form do |reminder_form| + reminder_form.text_field( + name: :remind_at_time, + type: "time", + value: @initial_value, + placeholder: I18n.t(:label_time), + label: I18n.t(:label_time), + leading_visual: { icon: :clock }, + required: true, + autofocus: false, + caption: formatted_time_zone_offset + ) + end + + def initialize(initial_value: DateTime.now.strftime("%H:%M")) + super() + + @initial_value = initial_value + end +end diff --git a/app/models/reminder.rb b/app/models/reminder.rb index 1228420fc19d..56cef70a4b1c 100644 --- a/app/models/reminder.rb +++ b/app/models/reminder.rb @@ -33,6 +33,21 @@ class Reminder < ApplicationRecord has_many :reminder_notifications, dependent: :destroy has_many :notifications, through: :reminder_notifications + # Currently, reminders are personal, meaning + # they are only visible to the user who created them. + def self.visible(user) + where(creator: user) + end + + def self.upcoming_and_visible_to(user) + visible_reminders = visible(user) + reminder_notifications_for_reminders = ReminderNotification.where(reminder: visible_reminders) + + visible_reminders + .where(completed_at: nil) + .where.not(id: reminder_notifications_for_reminders.select(:reminder_id)) + end + def unread_notifications? unread_notifications.exists? end diff --git a/config/initializers/permissions.rb b/config/initializers/permissions.rb index cfb6f057f713..92d3a3c74469 100644 --- a/config/initializers/permissions.rb +++ b/config/initializers/permissions.rb @@ -214,8 +214,11 @@ require: :loggedin map.permission :manage_own_reminders, - {}, + { + "work_packages/reminders": %i[modal_body create update destroy] + }, permissible_on: :project, + contract_actions: { work_package_reminders: %i[modal_body] }, require: :member end diff --git a/config/locales/en.yml b/config/locales/en.yml index 3d2a4e308d20..33f066d35cf5 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -928,6 +928,9 @@ en: lag: "Lag" from: "Work package" to: "Related work package" + reminder: + remind_at_date: "Date" + remind_at_time: "Time" repository: url: "URL" role: @@ -1594,6 +1597,7 @@ en: button_create: "Create" button_create_and_continue: "Create and continue" button_delete: "Delete" + button_delete_reminder: "Delete reminder" button_decline: "Decline" button_delete_watcher: "Delete watcher %{name}" button_download: "Download" @@ -1623,6 +1627,7 @@ en: button_save_as: "Save as" button_apply_changes: "Apply changes" button_save_back: "Save and back" + button_set_reminder: "Set reminder" button_show: "Show" button_sort: "Sort" button_submit: "Submit" @@ -2743,6 +2748,7 @@ en: label_this_month: "this month" label_this_week: "this week" label_this_year: "this year" + label_time: "Time" label_time_entry_plural: "Spent time" label_time_entry_activity_plural: "Spent time activities" label_title: "Title" @@ -4030,6 +4036,12 @@ en: edit_description: "Can view, comment and edit this work package." view: "View" view_description: "Can view this work package." + reminders: + label_remind_at: "Date" + note_placeholder: "Why are you setting this reminder?" + success_creation_message: "Reminder set successfully. You will receive a notification for this work package at the chosen time." + success_update_message: "Reminder updated successfully." + success_deletion_message: "Reminder deleted successfully." sharing: count: zero: "0 users" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 9ed3ae60a345..44a9b6921c3a 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -1127,6 +1127,11 @@ en: not_found: "There is no such view" duplicate_query_title: "Name of this view already exists. Change anyway?" text_no_results: "No matching views were found." + reminders: + title: + new: "Set reminder" + edit: "Edit reminder" + subtitle: "You will receive a notification for this work package at the chosen 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/config/routes.rb b/config/routes.rb index 158ed8a6a293..0c0c8d70ae21 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -610,6 +610,12 @@ resources :relations_tab, only: %i[index], controller: "work_package_relations_tab" resources :relations, only: %i[new create edit update destroy], controller: "work_package_relations" + resources :reminders, + controller: "work_packages/reminders", + only: %i[create update destroy] do + get :modal_body, on: :collection + end + get "/export_dialog" => "work_packages#export_dialog", on: :collection, as: "export_dialog" get :show_conflict_flash_message, on: :collection # we don't need a specific work package for this diff --git a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-package-paths.ts b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-package-paths.ts index bb325734f769..99a8eeee5ad8 100644 --- a/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-package-paths.ts +++ b/frontend/src/app/core/apiv3/endpoints/work_packages/api-v3-work-package-paths.ts @@ -46,6 +46,9 @@ export class ApiV3WorkPackagePaths extends ApiV3Resource { // /api/v3/(?:projectPath)/work_packages/(:workPackageId)/available_watchers public readonly available_watchers = this.subResource('available_watchers'); + // /api/v3/(?:projectPath)/work_packages/(:workPackageId)/reminders + public readonly reminders = this.subResource('reminders'); + // /api/v3/(?:projectPath)/work_packages/(:workPackageId)/available_projects public readonly available_projects = this.subResource('available_projects'); diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index c6228adeb946..667384921ef1 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -276,6 +276,10 @@ export class PathHelperService { return this.workPackageDetailsPath(projectIdentifier, workPackageId, 'copy'); } + public workPackageReminderModalBodyPath(workPackageId:string|number) { + return `${this.workPackagePath(workPackageId)}/reminders/modal_body`; + } + public workPackageSharePath(workPackageId:string|number) { return `${this.workPackagePath(workPackageId)}/shares`; } diff --git a/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.component.ts b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.component.ts new file mode 100644 index 000000000000..8a469179e5ad --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.component.ts @@ -0,0 +1,103 @@ +//-- 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. +//++ + +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +import { ChangeDetectionStrategy, ChangeDetectorRef, Component, Input, OnInit } from '@angular/core'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; +import { OpModalService } from 'core-app/shared/components/modal/modal.service'; +import { + WorkPackageReminderModalComponent, +} from 'core-app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal'; +import { ApiV3FilterBuilder } from 'core-app/shared/helpers/api-v3/api-v3-filter-builder'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { filter, map, startWith, switchMap } from 'rxjs/operators'; +import { merge, Observable, of } from 'rxjs'; +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 { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; +import { notificationCountChanged } from 'core-app/core/state/in-app-notifications/in-app-notifications.actions'; +import { IanBellService } from 'core-app/features/in-app-notifications/bell/state/ian-bell.service'; + +@Component({ + // eslint-disable-next-line @angular-eslint/component-selector + selector: 'wp-reminder-button', + templateUrl: './wp-reminder-button.html', + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkPackageReminderButtonComponent extends UntilDestroyedMixin implements OnInit { + @Input() public workPackage:WorkPackageResource; + + reminderCount$:Observable; + + constructor( + readonly I18n:I18nService, + readonly opModalService:OpModalService, + readonly cdRef:ChangeDetectorRef, + readonly apiV3Service:ApiV3Service, + readonly actions$:ActionsService, + readonly storeService:IanBellService, + ) { + super(); + } + + ngOnInit() { + this.reminderCount$ = merge( + this + .actions$ + .ofType(reminderModalUpdated) + .pipe( + map((action) => action.workPackageId), + filter((id) => id === this.workPackage.id?.toString()), + startWith(null), + switchMap(() => this.countReminders()), + ), + this.storeService.unread$ + .pipe( + startWith(0), + switchMap(() => this.countReminders()), + ), + ); + } + + openModal():void { + this.opModalService.show(WorkPackageReminderModalComponent, 'global', { workPackage: this.workPackage }, false, true); + } + + private countReminders():Observable { + return this + .apiV3Service + .work_packages + .id(this.workPackage.id as string) + .reminders + .get() + .pipe( + map((collection:CollectionResource) => { return collection.total; }), + ); + } +} 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 new file mode 100644 index 000000000000..9fe2f3eda797 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.html @@ -0,0 +1,18 @@ + diff --git a/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.actions.ts b/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.actions.ts new file mode 100644 index 000000000000..88fe361c0617 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/reminder.actions.ts @@ -0,0 +1,6 @@ +import { action, props } from 'ts-action'; + +export const reminderModalUpdated = action( + '[Reminder] Reminder modal closed or updated', + props<{ workPackageId: string }>(), +); diff --git a/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.html b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.html new file mode 100644 index 000000000000..17eee0ce970c --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.html @@ -0,0 +1,46 @@ +
+
+
+
+ + +
+

+

+ +
+ + + + + + + + +
+
diff --git a/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.sass b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.sass new file mode 100644 index 000000000000..dcc140b3d546 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.sass @@ -0,0 +1,2 @@ +.op-reminder-dialog-modal + min-height: 350px 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 new file mode 100644 index 000000000000..f00fd8bcf573 --- /dev/null +++ b/frontend/src/app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal.ts @@ -0,0 +1,113 @@ +import { + ChangeDetectionStrategy, + ChangeDetectorRef, + Component, + ElementRef, + Inject, + OnInit, + ViewChild, AfterViewInit, OnDestroy, +} from '@angular/core'; +import { OpModalLocalsMap } from 'core-app/shared/components/modal/modal.types'; +import { OpModalComponent } from 'core-app/shared/components/modal/modal.component'; +import { OpModalLocalsToken } from 'core-app/shared/components/modal/modal.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; +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 { Observable, of } from 'rxjs'; +import { map } from 'rxjs/operators'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { CollectionResource } from 'core-app/features/hal/resources/collection-resource'; + +@Component({ + templateUrl: './wp-reminder.modal.html', + styleUrls: ['./wp-reminder.modal.sass'], + changeDetection: ChangeDetectionStrategy.OnPush, +}) +export class WorkPackageReminderModalComponent extends OpModalComponent implements OnInit, AfterViewInit, OnDestroy { + @ViewChild('frameElement') frameElement:ElementRef; + + // Hide close button so it's not duplicated in primer (WP#51699) + showCloseButton = false; + + private workPackage:WorkPackageResource; + public frameSrc:string; + + text = { + new_title: this.I18n.t('js.work_packages.reminders.title.new'), + edit_title: this.I18n.t('js.work_packages.reminders.title.edit'), + subtitle: this.I18n.t('js.work_packages.reminders.subtitle'), + button_close: this.I18n.t('js.button_close'), + }; + + public title$:Observable; + + constructor( + @Inject(OpModalLocalsToken) public locals:OpModalLocalsMap, + readonly cdRef:ChangeDetectorRef, + readonly I18n:I18nService, + readonly elementRef:ElementRef, + readonly pathHelper:PathHelperService, + readonly actions$:ActionsService, + readonly apiV3Service:ApiV3Service, + ) { + super(locals, cdRef, elementRef); + + this.workPackage = this.locals.workPackage as WorkPackageResource; + 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); + } + + ngOnInit() { + super.ngOnInit(); + } + + ngAfterViewInit() { + // Use event delegation on a parent element that won't be re-rendered + this.elementRef.nativeElement.addEventListener('turbo:submit-end', this.turboSubmitEndListener.bind(this)); + } + + ngOnDestroy() { + super.ngOnDestroy(); + + this.elementRef.nativeElement.removeEventListener('turbo:submit-end', this.turboSubmitEndListener.bind(this)); + } + + onClose():boolean { + this.actions$.dispatch(reminderModalUpdated({ workPackageId: this.workPackage.id as string })); + + return super.onClose(); + } + + private turboSubmitEndListener(event:CustomEvent) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment + const { fetchResponse } = event.detail; + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + if (fetchResponse.succeeded) { + this.closeMe(); + this.onClose(); + } + } + + /** + * Check if there is already a reminder for the work package + * so we can determine if we are in edit or new mode + */ + private isEditMode():Observable { + return this + .apiV3Service + .work_packages + .id(this.workPackage.id as string) + .reminders + .get() + .pipe( + map((collection:CollectionResource) => { return collection.total > 0; }), + ); + } +} 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 03f4398e73d5..77144ba3947e 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 @@ -407,6 +407,12 @@ import { } from 'core-app/features/work-packages/components/wp-timer-button/wp-timer-button.component'; import { OpenprojectTimeEntriesModule } from 'core-app/shared/components/time_entries/openproject-time-entries.module'; import { RecentItemsService } from 'core-app/core/recent-items.service'; +import { + WorkPackageReminderButtonComponent, +} from 'core-app/features/work-packages/components/wp-buttons/wp-reminder-button/wp-reminder-button.component'; +import { + WorkPackageReminderModalComponent, +} from 'core-app/features/work-packages/components/wp-reminder-modal/wp-reminder.modal'; import { WorkPackageShareButtonComponent, } from 'core-app/features/work-packages/components/wp-buttons/wp-share-button/wp-share-button.component'; @@ -612,6 +618,7 @@ import { WorkPackageBreadcrumbComponent, WorkPackageSplitViewToolbarComponent, WorkPackageWatcherButtonComponent, + WorkPackageReminderButtonComponent, WorkPackageShareButtonComponent, WorkPackageSubjectComponent, @@ -632,6 +639,7 @@ import { SaveQueryModalComponent, WpDestroyModalComponent, WorkPackageShareModalComponent, + WorkPackageReminderModalComponent, // CustomActions WpCustomActionComponent, diff --git a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts index a6fa95583355..7d9891d01f5d 100644 --- a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts +++ b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.component.ts @@ -29,6 +29,7 @@ import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { StateService } from '@uirouter/core'; import { + ChangeDetectionStrategy, Component, HostListener, Injector, @@ -55,16 +56,19 @@ import { CurrentUserService } from 'core-app/core/current-user/current-user.serv CommentService, { provide: HalResourceNotificationService, useExisting: WorkPackageNotificationService }, ], + changeDetection: ChangeDetectionStrategy.OnPush, }) export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase implements OnInit { // Watcher properties public isWatched:boolean; - public displayWatchButton = false; + public displayReminderButton$:false|Observable = false; + + public displayShareButton$:false|Observable = false; public displayTimerButton = false; - public displayShareButton$:false|Observable = false; + public displayWatchButton = false; public watchers:any; @@ -117,6 +121,7 @@ export class WorkPackagesFullViewComponent extends WorkPackageSingleViewBase imp this.displayWatchButton = Object.prototype.hasOwnProperty.call(wp, 'unwatch') || Object.prototype.hasOwnProperty.call(wp, 'watch'); this.displayTimerButton = Object.prototype.hasOwnProperty.call(wp, 'logTime'); this.displayShareButton$ = this.currentUserService.hasCapabilities$('work_package_shares/index', wp.project.id); + this.displayReminderButton$ = this.currentUserService.hasCapabilities$('work_package_reminders/modal_body', wp.project.id); // watchers if (wp.watchers) { diff --git a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html index a364194a4c85..421f48de11a6 100644 --- a/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html +++ b/frontend/src/app/features/work-packages/routing/wp-full-view/wp-full-view.html @@ -42,6 +42,10 @@ [showText]="false"> +
  • + + +
  • role_that_allows_managing_own_reminders }, + preferences: { time_zone: time_zone.tzinfo.canonical_zone.name }) + end + let!(:user_without_permissions) do + create(:user, + member_with_roles: { project => role_that_does_not_allow_managing_own_reminders }) + end + + let(:work_package_page) { Pages::FullWorkPackage.new(work_package) } + let(:center) { Pages::Notifications::Center.new } + + context "with permissions to manage own reminders" do + current_user { user_with_permissions } + + it "renders the reminder button when visiting the work package page" do + work_package_page.visit! + work_package_page.expect_reminder_button + end + + specify "can create a reminder, subsequently update it and delete it" do + date = Date.current + 2.weeks + time = "05:00".to_time + + work_package_page.visit! + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Set reminder") + expect(page) + .to have_css(".spot-modal--subheader", + text: "You will receive a notification for this work package at the chosen time.") + fill_in "Date", with: date + fill_in "Time", with: time + fill_in "Note", with: "Never forget!" + + click_link_or_button "Set reminder" + end + + work_package_page.expect_and_dismiss_flash(type: :success, + message: I18n.t("work_package.reminders.success_creation_message")) + work_package_page.expect_reminder_button_with_count(1) + + expect(Reminder.last) + .to have_attributes( + remindable: work_package, + creator: user_with_permissions, + remind_at: DateTime.parse("#{date} #{time}"), + note: "Never forget!" + ) + + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Edit 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", with: date) + expect(page).to have_field("Time", with: "05:00") + expect(page).to have_field("Note", with: "Never forget!") + expect(page).to have_button("Save") + + fill_in "Note", with: "Remember to never forget!" + click_link_or_button "Save" + end + + work_package_page.expect_and_dismiss_flash(type: :success, + message: I18n.t("work_package.reminders.success_update_message")) + expect(Reminder.last) + .to have_attributes( + remindable: work_package, + creator: user_with_permissions, + remind_at: DateTime.parse("#{date} #{time}"), + note: "Remember to never forget!" + ) + + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + click_link_or_button "Delete reminder" + end + + work_package_page.expect_and_dismiss_flash(type: :success, + message: I18n.t("work_package.reminders.success_deletion_message")) + work_package_page.expect_reminder_button_without_count + expect(Reminder.upcoming_and_visible_to(user_with_permissions).count).to eq(0) + end + + describe "validations" do + it "renders errors on the date field or time field when the reminder is in the past" do + two_am = "02:00".to_time + thirty_minutes_ago = 30.minutes.ago + .in_time_zone(current_user.time_zone) + .strftime("%H:%M") + .to_time + thirty_minutes_from_now = 30.minutes.from_now + .in_time_zone(current_user.time_zone) + .strftime("%H:%M") + .to_time + yesterday = Time.current.in_time_zone(current_user.time_zone).yesterday.to_date + today = Time.current.in_time_zone(current_user.time_zone).to_date + + work_package_page.visit! + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Set reminder") + expect(page) + .to have_css(".spot-modal--subheader", + text: "You will receive a notification for this work package at the chosen time.") + + # Yesterday 02:00 + fill_in "Date", with: yesterday + fill_in "Time", with: two_am + fill_in "Note", with: "Never forget!" + click_link_or_button "Set reminder" + + wait_for_network_idle + expect(page).to have_css(".FormControl-inlineValidation", text: "Date must be in the future.") + expect(page).to have_css(".FormControl-inlineValidation", text: "Time must be in the future.") + + # 30 minutes ago + fill_in "Date", with: today + fill_in "Time", with: thirty_minutes_ago + click_link_or_button "Set reminder" + + 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 + fill_in "Time", with: thirty_minutes_from_now + click_link_or_button "Set reminder" + + wait_for_network_idle + end + + work_package_page.expect_and_dismiss_flash(type: :success, + message: I18n.t("work_package.reminders.success_creation_message")) + work_package_page.expect_reminder_button_with_count(1) + end + + 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 + wait_for_network_idle + + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Set reminder") + expect(page) + .to have_css(".spot-modal--subheader", + text: "You will receive a notification for this work package at the chosen time.") + + # Click the Set reminder button without filling in the date or time + click_link_or_button "Set reminder" + + 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") + + # Fill in the date but not the time + fill_in "Date", with: 1.week.from_now.to_date + click_link_or_button "Set reminder" + + wait_for_network_idle + # The error message is only on the time field + expect(page).to have_css(".FormControl-inlineValidation", text: "Time can't be blank") + expect(page).to have_no_css(".FormControl-inlineValidation", text: "Date can't be blank", wait: 0) + + # Fill in the time but not the date + fill_in "Date", with: "" + fill_in "Time", with: "05:00".to_time + click_link_or_button "Set reminder" + + wait_for_network_idle + expect(page).to have_css(".FormControl-inlineValidation", text: "Date can't be blank.") + expect(page).to have_no_css(".FormControl-inlineValidation", text: "Time can't be blank.", wait: 0) + end + end + + it "removes the reminder count without a refresh when the notification is fired", + with_settings: { notifications_polling_interval: 1_000 } do + # Set to remind far in the future to avoid flakiness + # and job triggered on demand later in the spec + Reminders::CreateService.new(user: current_user).call( + remindable: work_package, + remind_at: 20.seconds.from_now, + creator: current_user, + note: "Just fired" + ) + + work_package_page.visit! + work_package_page.expect_reminder_button_with_count(1) + center.expect_bell_count(0) + + perform_enqueued_jobs + + center.expect_bell_count(1) + work_package_page.expect_reminder_button_without_count + end + end + + context "with a reminder" do + let!(:reminder) do + create(:reminder, + remindable: work_package, + creator: current_user) + end + + it "renders the reminder button with the correct count" do + work_package_page.visit! + work_package_page.expect_reminder_button + work_package_page.expect_reminder_button_with_count(1) + end + + specify "clicking on the reminder button opens the edit reminder modal" do + work_package_page.visit! + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Edit 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", with: reminder.remind_at.in_time_zone(current_user.time_zone).to_date) + expect(page).to have_field("Time", with: reminder.remind_at.in_time_zone(current_user.time_zone).strftime("%H:%M")) + expect(page).to have_field("Note", with: reminder.note) + expect(page).to have_button("Save") + end + end + end + + context "without a reminder" do + it "renders the reminder button without a count" do + work_package_page.visit! + work_package_page.expect_reminder_button + work_package_page.expect_reminder_button_without_count + end + + specify "clicking on the reminder button opens the create reminder modal" do + work_package_page.visit! + work_package_page.click_reminder_button + wait_for_network_idle + within ".spot-modal" do + expect(page) + .to have_css(".spot-modal--header-title", text: "Set 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("Note") + expect(page).to have_button("Set reminder") + end + end + end + end + + context "without permissions to manage own reminders" do + current_user { user_without_permissions } + + it "does not render the reminder button when visiting the work package page" do + work_package_page.visit! + work_package_page.expect_no_reminder_button + end + end +end diff --git a/spec/support/pages/work_packages/full_work_package.rb b/spec/support/pages/work_packages/full_work_package.rb index 8fd650b0c975..a748d0531610 100644 --- a/spec/support/pages/work_packages/full_work_package.rb +++ b/spec/support/pages/work_packages/full_work_package.rb @@ -56,6 +56,37 @@ def expect_share_button_count(count) end end + def expect_reminder_button + expect(page).to have_test_selector("op-wp-reminder-button") + end + + def expect_reminder_button_with_count(count) + page.within_test_selector("op-wp-reminder-button") do + expect(page).to have_css(".badge", text: count, wait: 10) + end + end + + def expect_reminder_button_without_count + expect(page).to have_test_selector("op-wp-reminder-button") + expect(page).to have_no_css(".badge") + end + + def expect_no_reminder_button + expect(page).not_to have_test_selector("op-wp-reminder-button") + end + + def click_reminder_button + within toolbar do + # The request to the capabilities endpoint determines + # whether the "Reminder" button is rendered or not. + # Instead of waiting for an idle network (which may + # include waiting for other network requests unrelated to + # reminders), waiting for the button to be present makes + # the spec a bit faster. + find_test_selector("op-wp-reminder-button", wait: 10).click + end + end + def wait_for_activity_tab expect(page).to have_test_selector("op-wp-activity-tab", wait: 10) # wait for stimulus js component to be mounted