diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index eb315c387683..c5525dfec624 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -249,11 +249,11 @@ def authorize_global # * a parameter-like Hash (eg. { controller: '/projects', action: 'edit' }) # * a permission Symbol (eg. :edit_project) def do_authorize(action, global: false) - context = @project || @projects + context = @current_project || @project || @projects is_authorized = User.current.allowed_to?(action, context, global:) unless is_authorized - if @project&.archived? + if (@current_project || @project)&.archived? render_403 message: :notice_not_authorized_archived_project else deny_access diff --git a/frontend/src/global_styles/content/_forms.sass b/frontend/src/global_styles/content/_forms.sass index ac6f30a814b8..b387f73f382c 100644 --- a/frontend/src/global_styles/content/_forms.sass +++ b/frontend/src/global_styles/content/_forms.sass @@ -629,6 +629,10 @@ input[readonly].-clickable line-height: normal padding: 3px 24px 3px 3px + &.-prompt-visible + font-style: italic + color: $spot-color-basic-gray-3 + &[multiple] background-image: none padding-right: $form-padding diff --git a/frontend/src/stimulus/controllers/dynamic/select-field-with-prompt.controller.ts b/frontend/src/stimulus/controllers/dynamic/select-field-with-prompt.controller.ts new file mode 100644 index 000000000000..63d959fd611a --- /dev/null +++ b/frontend/src/stimulus/controllers/dynamic/select-field-with-prompt.controller.ts @@ -0,0 +1,51 @@ +/* + * -- copyright + * OpenProject is an open source project management software. + * Copyright (C) 2023 the OpenProject GmbH + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License version 3. + * + * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: + * Copyright (C) 2006-2013 Jean-Philippe Lang + * Copyright (C) 2010-2013 the ChiliProject Team + * + * This program is free software; you can redistribute it and/or + * modify it under the terms of the GNU General Public License + * as published by the Free Software Foundation; either version 2 + * of the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + * + * See COPYRIGHT and LICENSE files for more details. + * ++ + */ + +import { Controller } from '@hotwired/stimulus'; + +export default class SelectFieldWithPromptController extends Controller { + connect() { + this.togglePromptStyling(); + } + + togglePromptStyling() { + if (this.promptSelected()) { + this.element.classList.add('-prompt-visible'); + } else { + this.element.classList.remove('-prompt-visible'); + } + } + + private promptSelected() { + const options = Array.from(this.element.options); + + return options.find((option) => option.value === '' && option.selected); + } +} diff --git a/frontend/src/stimulus/controllers/refresh-on-form-changes.controller.ts b/frontend/src/stimulus/controllers/refresh-on-form-changes.controller.ts index 43ad1ec3de4b..4b55ba7a53bb 100644 --- a/frontend/src/stimulus/controllers/refresh-on-form-changes.controller.ts +++ b/frontend/src/stimulus/controllers/refresh-on-form-changes.controller.ts @@ -37,17 +37,42 @@ export default class RefreshOnFormChangesController extends ApplicationControlle static values = { refreshUrl: String, + preserveParams: Boolean, }; declare readonly formTarget:HTMLFormElement; declare refreshUrlValue:string; + declare preserveParamsValue:boolean; triggerReload():void { // without the cast to undefined, the URLSearchParams constructor will // not accept the FormData object. const formData = new FormData(this.formTarget) as unknown as undefined; - const serializedFormData = new URLSearchParams(formData).toString(); + const formParams = new URLSearchParams(formData); + const currentParams = new URLSearchParams(window.location.search); + const mergedParams = this.mergeQueryParams(currentParams, formParams); + + const serializedFormData = mergedParams.toString(); + window.location.href = `${this.refreshUrlValue}?${serializedFormData}`; } + + // Merge the form's submitted params onto the currently present + // query string parameters on the URL. + // + // This preserves scope-dependent form data that would painfully + // get reset in some reload scenarios when calling triggerReload + // from a dependent form field. + private mergeQueryParams(currentParams:URLSearchParams, newParams:URLSearchParams) { + if (!this.preserveParamsValue) { + return newParams; + } + + newParams.forEach((value, key) => { + currentParams.set(key, value); + }); + + return currentParams; + } } diff --git a/modules/meeting/app/components/meetings/add_button_component.html.erb b/modules/meeting/app/components/meetings/add_button_component.html.erb new file mode 100644 index 000000000000..9ba9cfb898b7 --- /dev/null +++ b/modules/meeting/app/components/meetings/add_button_component.html.erb @@ -0,0 +1,10 @@ +
  • + + <%= icon %> + <%= label %> + +
  • diff --git a/modules/meeting/app/components/meetings/add_button_component.rb b/modules/meeting/app/components/meetings/add_button_component.rb new file mode 100644 index 000000000000..91df6c1a3698 --- /dev/null +++ b/modules/meeting/app/components/meetings/add_button_component.rb @@ -0,0 +1,78 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ +# + +module Meetings + class AddButtonComponent < ::RailsComponent + options :current_project + + def render? + if current_project + User.current.allowed_to?(:create_meetings, current_project) + else + User.current.allowed_to_globally?(:create_meetings) + end + end + + def li_css_class + 'toolbar-item' + end + + def dynamic_path + polymorphic_path([:new, current_project, :meeting]) + end + + def id + 'add-meeting-button' + end + + def title + I18n.t(:label_meeting_new) + end + + def aria_label + I18n.t(:label_meeting_new) + end + + def link_css_class + 'button -alt-highlight' + end + + def label + content_tag(:span, + I18n.t(:label_meeting), + class: 'button--text') + end + + def icon + helpers.op_icon('button--icon icon-add') + end + end +end diff --git a/modules/meeting/app/controllers/base_controller.rb b/modules/meeting/app/controllers/base_controller.rb new file mode 100644 index 000000000000..5359f005b2f9 --- /dev/null +++ b/modules/meeting/app/controllers/base_controller.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +# -- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +# ++ +# + +class BaseController < ApplicationController + around_action :set_time_zone + + helper :watchers + helper :meeting_contents + include WatchersHelper + include PaginationHelper + include SortHelper + + private + + def set_time_zone(&) + zone = User.current.time_zone + if zone.nil? + localzone = Time.current.utc_offset + localzone -= 3600 if Time.current.dst? + zone = ::ActiveSupport::TimeZone[localzone] + end + + Time.use_zone(zone, &) + end + + def convert_params + if params.key?(:meeting) + # We do some preprocessing of `meeting_params` that we will store in this + # instance variable. + @converted_params = meeting_params.to_h + + @converted_params[:duration] = @converted_params[:duration].to_hours + # Force defaults on participants + @converted_params[:participants_attributes] ||= {} + @converted_params[:participants_attributes].each { |p| p.reverse_merge! attended: false, invited: false } + end + end + + def populate_meeting_with_converted_params + @meeting.participants.clear # Start with a clean set of participants + @meeting.participants_attributes = @converted_params.delete(:participants_attributes) + @meeting.attributes = @converted_params + end + + def meeting_params + params.require(:meeting).permit(:title, :location, :start_time, :duration, :start_date, :start_time_hour, + participants_attributes: %i[email name invited attended user user_id meeting id]) + end +end diff --git a/modules/meeting/app/controllers/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 6656d0490e20..70fbb60784b2 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -26,14 +26,13 @@ # See COPYRIGHT and LICENSE files for more details. #++ -class MeetingsController < ApplicationController - around_action :set_time_zone - before_action :find_optional_project, only: %i[index new create] +class MeetingsController < BaseController + before_action :find_optional_current_project, only: %i[new create] + before_action :convert_params, only: %i[new create update] before_action :build_meeting, only: %i[new create] before_action :find_meeting, except: %i[index new create] - before_action :convert_params, only: %i[create update] - before_action :authorize, except: [:index] - before_action :authorize_global, only: :index + before_action :authorize, except: %i[index new] + before_action :authorize_global, only: %i[index new] helper :watchers helper :meeting_contents @@ -41,47 +40,16 @@ class MeetingsController < ApplicationController include PaginationHelper include SortHelper - menu_item :new_meeting, only: %i[new create] - def index - @meetings = @project ? @project.meetings : global_upcoming_meetings + @meetings = global_upcoming_meetings end def show params[:tab] ||= 'minutes' if @meeting.agenda.present? && @meeting.agenda.locked? end - def create - @meeting.participants.clear # Start with a clean set of participants - @meeting.participants_attributes = @converted_params.delete(:participants_attributes) - @meeting.attributes = @converted_params - if params[:copied_from_meeting_id].present? && params[:copied_meeting_agenda_text].present? - @meeting.agenda = MeetingAgenda.new( - text: params[:copied_meeting_agenda_text], - journal_notes: I18n.t('meeting.copied', id: params[:copied_from_meeting_id]) - ) - @meeting.agenda.author = User.current - end - if @meeting.save - text = I18n.t(:notice_successful_create) - if User.current.time_zone.nil? - link = I18n.t(:notice_timezone_missing, zone: Time.zone) - text += " #{view_context.link_to(link, { controller: '/my', action: :account }, class: 'link_to_profile')}" - end - flash[:notice] = text.html_safe - - redirect_to action: 'show', id: @meeting - else - render template: 'meetings/new', project_id: @project - end - end - def new; end - current_menu_item :new do - :meetings - end - def copy params[:copied_from_meeting_id] = @meeting.id params[:copied_meeting_agenda_text] = @meeting.agenda.text if @meeting.agenda.present? @@ -92,7 +60,7 @@ def copy def destroy @meeting.destroy flash[:notice] = I18n.t(:notice_successful_delete) - redirect_to action: 'index', project_id: @project + redirect_to project_meetings_path(@project) end def edit; end @@ -110,32 +78,26 @@ def update private - def set_time_zone(&) - zone = User.current.time_zone - if zone.nil? - localzone = Time.current.utc_offset - localzone -= 3600 if Time.current.dst? - zone = ::ActiveSupport::TimeZone[localzone] - end - - Time.use_zone(zone, &) - end - def build_meeting @meeting = Meeting.new - @meeting.project = @project + populate_meeting_with_converted_params if @converted_params + @meeting.project = @current_project @meeting.author = User.current end - def find_optional_project - return true unless params[:project_id] + def find_optional_current_project + return true if project_id.blank? - @project = Project.find(params[:project_id]) + @current_project = Project.find(project_id) authorize rescue ActiveRecord::RecordNotFound render_404 end + def project_id + @project_id ||= params[:project_id] || params.dig(:meeting, :project_id) + end + def global_upcoming_meetings projects = Project.allowed_to(User.current, :view_meetings) @@ -150,20 +112,4 @@ def find_meeting rescue ActiveRecord::RecordNotFound render_404 end - - def convert_params - # We do some preprocessing of `meeting_params` that we will store in this - # instance variable. - @converted_params = meeting_params.to_h - - @converted_params[:duration] = @converted_params[:duration].to_hours - # Force defaults on participants - @converted_params[:participants_attributes] ||= {} - @converted_params[:participants_attributes].each { |p| p.reverse_merge! attended: false, invited: false } - end - - def meeting_params - params.require(:meeting).permit(:title, :location, :start_time, :duration, :start_date, :start_time_hour, - participants_attributes: %i[email name invited attended user user_id meeting id]) - end end diff --git a/modules/meeting/app/controllers/projects/meetings_controller.rb b/modules/meeting/app/controllers/projects/meetings_controller.rb new file mode 100644 index 000000000000..afe224a57900 --- /dev/null +++ b/modules/meeting/app/controllers/projects/meetings_controller.rb @@ -0,0 +1,109 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +class Projects::MeetingsController < BaseController + before_action :find_project, only: %i[index new create] + before_action :build_meeting, only: %i[new create] + before_action :convert_params, only: %i[create update] + before_action :authorize + + def index + @meetings = @project.meetings + end + + def create + populate_meeting_with_converted_params + build_copied_agenda if copied_params_present? + if @meeting.save + set_successful_flash_message + redirect_to meeting_path(@meeting) + else + render failed_create_partial, project_id: @project + end + end + + def new; end + + current_menu_item [:new, :create, :index] do + :meetings + end + + private + + def build_meeting + @meeting = Meeting.new + @meeting.project = @project + @meeting.author = User.current + end + + def build_copied_agenda + @meeting.agenda = MeetingAgenda.new( + text: params[:copied_meeting_agenda_text], + journal_notes: I18n.t('meeting.copied', id: params[:copied_from_meeting_id]) + ) + @meeting.agenda.author = User.current + end + + def set_successful_flash_message + text = I18n.t(:notice_successful_create) + if User.current.time_zone.nil? + link = I18n.t(:notice_timezone_missing, zone: Time.zone) + text += " #{view_context.link_to(link, { controller: '/my', action: :account }, class: 'link_to_profile')}" + end + # rubocop:disable Rails/OutputSafety + flash[:notice] = text.html_safe + # rubocop:enable Rails/OutputSafety + end + + def copied_params_present? + params[:copied_from_meeting_id].present? && params[:copied_meeting_agenda_text].present? + end + + def find_project + @project = Project.find(params[:project_id]) + authorize + rescue ActiveRecord::RecordNotFound + render_404 + end + + def meeting_params + super.merge(params.require(:meeting).permit(:project_id)) + end + + def failed_create_partial + if coming_from_global_form? + 'meetings/new' + else + 'projects/meetings/new' + end + end + + def coming_from_global_form? + params[:meeting].key?(:project_id) + end +end diff --git a/modules/meeting/app/helpers/meetings_helper.rb b/modules/meeting/app/helpers/meetings_helper.rb index debd096f36e7..dd601f8b6aa5 100644 --- a/modules/meeting/app/helpers/meetings_helper.rb +++ b/modules/meeting/app/helpers/meetings_helper.rb @@ -72,4 +72,22 @@ def render_journal_details(journal, header_label = :label_updated_time_by, _mode content_tag('div', "#{header}#{details}".html_safe, id: "change-#{journal.id}", class: 'journal') end + + def create_meeting_url_for(project) + project ? project_meetings_path(project) : '' + end + + def global_meeting_form? + request.path == new_meeting_path + end + + def options_for_project_selection + Project.visible + .filter { _1.module_enabled?('meetings') } + .map { [_1.name, _1.id] } + end + + def project_select_initial_class_list + params.dig(:meeting, :project_id).blank? ? '-prompt-visible' : '' + end end diff --git a/modules/meeting/app/views/meetings/edit.html.erb b/modules/meeting/app/views/meetings/edit.html.erb index 2773a0cf8398..0882dbbcb1d2 100644 --- a/modules/meeting/app/views/meetings/edit.html.erb +++ b/modules/meeting/app/views/meetings/edit.html.erb @@ -30,9 +30,11 @@ See COPYRIGHT and LICENSE files for more details. <% html_title "#{t(:label_meeting_edit)}: #{@meeting.title}" %> <%= toolbar title: "#{t(:label_meeting)} ##{@meeting.id}" %> -<%= labelled_tabular_form_for @meeting, :url => {:controller => '/meetings', :action => 'update'}, :html => {:id => 'meeting-form', :method => :put} do |f| -%> - <%= render :partial => 'form', :locals => {:f => f} %> -<%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> -<%= link_to t(:button_cancel), { :action => 'show', :id => @meeting }, - class: 'button -with-icon icon-cancel' %> +<%= labelled_tabular_form_for @meeting, + url: { controller: '/meetings', action: 'update'}, + html: { id: 'meeting-form', method: :put } do |f| -%> + <%= render partial: 'shared/meetings/form', locals: { f:, meeting: @meeting, project: @project } %> + <%= styled_button_tag t(:button_save), class: '-highlight -with-icon icon-checkmark' %> + <%= link_to t(:button_cancel), { action: 'show', id: @meeting }, + class: 'button -with-icon icon-cancel' %> <% end if @project %> diff --git a/modules/meeting/app/views/meetings/index.html.erb b/modules/meeting/app/views/meetings/index.html.erb index bf9d94998e23..10992f1c1d22 100644 --- a/modules/meeting/app/views/meetings/index.html.erb +++ b/modules/meeting/app/views/meetings/index.html.erb @@ -27,25 +27,4 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_meeting_plural) %> - -<%= toolbar title: t(:label_meeting_plural) do %> - <% if authorize_for(:meetings, :new) %> -
  • - - <%= op_icon('button--icon icon-add') %> - <%= t(:label_meeting) %> - -
  • - <% end %> -<% end %> - -<% if @meetings.empty? -%> - <%= no_results_box %> -<% else -%> -<%= render Meetings::TableComponent.new(rows: @meetings, current_project: @project) %> -<% end -%> +<%= render partial: 'shared/meetings/index', locals: { project: nil, meetings: @meetings } %> diff --git a/modules/meeting/app/views/meetings/new.html.erb b/modules/meeting/app/views/meetings/new.html.erb index ee5dd0bb5c29..296708c7a49f 100644 --- a/modules/meeting/app/views/meetings/new.html.erb +++ b/modules/meeting/app/views/meetings/new.html.erb @@ -27,11 +27,4 @@ See COPYRIGHT and LICENSE files for more details. ++#%> -<% html_title t(:label_meeting_new) %> -<%= toolbar title: t(:label_meeting_new) %> -<%= labelled_tabular_form_for @meeting, :url => {:controller => '/meetings', :action => 'create', :project_id => @project}, :html => {:id => 'meeting-form'} do |f| -%> - <%= render :partial => 'form', :locals => {:f => f} %> - <%= styled_button_tag t(:button_create), class: '-highlight' %> - <%= link_to t(:button_cancel), { :action => 'index', :project_id => @project }, - class: 'button' %> -<% end if @project %> +<%= render partial: 'shared/meetings/new', locals: { meeting: @meeting, project: (@current_project || @project) } %> diff --git a/modules/meeting/app/views/meetings/show.html.erb b/modules/meeting/app/views/meetings/show.html.erb index 6624b5c8bda6..51a45fbcdfae 100644 --- a/modules/meeting/app/views/meetings/show.html.erb +++ b/modules/meeting/app/views/meetings/show.html.erb @@ -40,7 +40,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if authorize_for(:meetings, :edit) %>
  • - <%= link_to({:controller => '/meetings', :action => 'edit', :id => @meeting}, class: 'button',:accesskey => accesskey(:edit)) do%> + <%= link_to({ controller: '/meetings', action: 'edit', id: @meeting}, class: 'button', accesskey: accesskey(:edit)) do%> <%= op_icon('button--icon icon-edit') %> <%= t(:button_edit) %> <% end %> @@ -48,7 +48,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if authorize_for(:meetings, :copy) %>
  • - <%= link_to({:controller => '/meetings', :action => 'copy', :id => @meeting}, class: 'button') do %> + <%= link_to({ controller: '/meetings', action: 'copy', id: @meeting }, class: 'button') do %> <%= op_icon('button--icon icon-copy') %> <%= t(:button_copy) %> <% end %> @@ -56,7 +56,7 @@ See COPYRIGHT and LICENSE files for more details. <% end %> <% if authorize_for(:meetings, :destroy) %>
  • - <%= link_to({controller: '/meetings', action: 'destroy', id: @meeting}, + <%= link_to({ controller: '/meetings', action: 'destroy', id: @meeting }, class: 'button', method: :delete, data: { confirm: t(:text_are_you_sure) }) do %> @@ -88,8 +88,8 @@ See COPYRIGHT and LICENSE files for more details. -<%= render_tabs [{:name => 'agenda', :action => :create_meeting_agendas, :partial => 'meeting_contents/show', :path => meeting_agenda_path(@meeting), :label => :label_meeting_agenda, :content => @meeting.agenda || @meeting.build_agenda, :content_type => "meeting_agenda"}, - {:name => 'minutes', :action => :create_meeting_minutes, :partial => 'meeting_contents/show', :path => meeting_minutes_path(@meeting), :label => :label_meeting_minutes, :content => @meeting.minutes || @meeting.build_minutes, :content_type => "meeting_minutes"}] %> +<%= render_tabs [{ name: 'agenda', action: :create_meeting_agendas, partial: 'meeting_contents/show', path: meeting_agenda_path(@meeting), label: :label_meeting_agenda, content: @meeting.agenda || @meeting.build_agenda, content_type: "meeting_agenda" }, + { name: 'minutes', action: :create_meeting_minutes, partial: 'meeting_contents/show', path: meeting_minutes_path(@meeting), label: :label_meeting_minutes, content: @meeting.minutes || @meeting.build_minutes, content_type: "meeting_minutes" }] %> <% if @meeting.journals.changing.present? %>
    diff --git a/modules/meeting/app/views/projects/meetings/index.html.erb b/modules/meeting/app/views/projects/meetings/index.html.erb new file mode 100644 index 000000000000..3a4f692a29d3 --- /dev/null +++ b/modules/meeting/app/views/projects/meetings/index.html.erb @@ -0,0 +1,30 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render partial: 'shared/meetings/index', locals: { project: @project, meetings: @meetings } %> diff --git a/modules/meeting/app/views/projects/meetings/new.html.erb b/modules/meeting/app/views/projects/meetings/new.html.erb new file mode 100644 index 000000000000..da14e91e8736 --- /dev/null +++ b/modules/meeting/app/views/projects/meetings/new.html.erb @@ -0,0 +1,30 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<%= render partial: 'shared/meetings/new', locals: { meeting: @meeting, project: @project } %> diff --git a/modules/meeting/app/views/meetings/_form.html.erb b/modules/meeting/app/views/shared/meetings/_form.html.erb similarity index 86% rename from modules/meeting/app/views/meetings/_form.html.erb rename to modules/meeting/app/views/shared/meetings/_form.html.erb index 371ec4d76aee..f61e0d5fc0f9 100644 --- a/modules/meeting/app/views/meetings/_form.html.erb +++ b/modules/meeting/app/views/shared/meetings/_form.html.erb @@ -35,6 +35,22 @@ See COPYRIGHT and LICENSE files for more details. <%= f.text_field :title, :required => true, :size => 60, container_class: '-wide' %>
    + <% if global_meeting_form? %> +
    + <%= f.select :project_id, + options_for_project_selection, + {include_blank: t(:project_selection_placeholder), + container_class: '-wide'}, + class: project_select_initial_class_list, + data: { + 'application-target': 'dynamic', + controller: 'select-field-with-prompt', + action: 'change->select-field-with-prompt#togglePromptStyling ' \ + 'change->refresh-on-form-changes#triggerReload' + } %> +
    + <% end %> +
    <%= f.text_field :location, :size => 60, container_class: '-wide' %>
    @@ -86,7 +102,8 @@ See COPYRIGHT and LICENSE files for more details. -
    + <% if project %> +
    @@ -98,11 +115,11 @@ See COPYRIGHT and LICENSE files for more details. - <% @meeting.all_changeable_participants.sort.each do |user| -%> + <% meeting.all_changeable_participants.sort.each do |user| -%> <%= hidden_field_tag "meeting[participants_attributes][][user_id]", user.id %> - <% if @meeting.participants.present? && participant = @meeting.participants.detect{|p| p.user_id == user.id} -%> + <% if meeting.participants.present? && participant = meeting.participants.detect{|p| p.user_id == user.id} -%> <%= hidden_field_tag "meeting[participants_attributes][][id]", participant.id %>
    <%=h user %> <%= label_tag "checkbox_invited_#{user.id}", user.name + " " + t(:description_invite), :class => "hidden-for-sighted" %> @@ -128,6 +145,8 @@ See COPYRIGHT and LICENSE files for more details.
    + <% end %> + <%= hidden_field_tag "copied_from_meeting_id", params[:copied_from_meeting_id] if params[:copied_from_meeting_id].present? %> <%= hidden_field_tag "copied_meeting_agenda_text", params[:copied_meeting_agenda_text] if params[:copied_meeting_agenda_text].present? %> diff --git a/modules/meeting/app/views/shared/meetings/_index.html.erb b/modules/meeting/app/views/shared/meetings/_index.html.erb new file mode 100644 index 000000000000..4df673a42114 --- /dev/null +++ b/modules/meeting/app/views/shared/meetings/_index.html.erb @@ -0,0 +1,40 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_meeting_plural) %> + +<%= toolbar title: t(:label_meeting_plural) do %> + <%= render Meetings::AddButtonComponent.new(current_project: project) %> +<% end %> + +<% if meetings.empty? -%> + <%= no_results_box %> +<% else -%> +<%= render Meetings::TableComponent.new(rows: meetings, current_project: project) %> +<% end -%> diff --git a/modules/meeting/app/views/shared/meetings/_new.html.erb b/modules/meeting/app/views/shared/meetings/_new.html.erb new file mode 100644 index 000000000000..0c0438126cf6 --- /dev/null +++ b/modules/meeting/app/views/shared/meetings/_new.html.erb @@ -0,0 +1,43 @@ +<%#-- copyright +OpenProject is an open source project management software. +Copyright (C) 2012-2023 the OpenProject GmbH + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License version 3. + +OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +Copyright (C) 2006-2013 Jean-Philippe Lang +Copyright (C) 2010-2013 the ChiliProject Team + +This program is free software; you can redistribute it and/or +modify it under the terms of the GNU General Public License +as published by the Free Software Foundation; either version 2 +of the License, or (at your option) any later version. + +This program is distributed in the hope that it will be useful, +but WITHOUT ANY WARRANTY; without even the implied warranty of +MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +GNU General Public License for more details. + +You should have received a copy of the GNU General Public License +along with this program; if not, write to the Free Software +Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. + +See COPYRIGHT and LICENSE files for more details. + +++#%> + +<% html_title t(:label_meeting_new) %> +<%= toolbar title: t(:label_meeting_new) %> +<%= labelled_tabular_form_for meeting, + url: create_meeting_url_for(project), + html: { id: 'meeting-form', + data: { 'controller': 'refresh-on-form-changes', + 'refresh-on-form-changes-target': 'form', + 'refresh-on-form-changes-refresh-url-value': new_meeting_url, + 'refresh-on-form-changes-preserve-params-value': true } } do |f| -%> + <%= render partial: 'shared/meetings/form', locals: { f:, meeting:, project: } %> + <%= styled_button_tag t(:button_create), class: '-highlight' %> + <%= link_to t(:button_cancel), { action: 'index', project_id: project }, + class: 'button' %> +<% end %> diff --git a/modules/meeting/config/locales/en.yml b/modules/meeting/config/locales/en.yml index 56a80aace60e..04fd9f9e618e 100644 --- a/modules/meeting/config/locales/en.yml +++ b/modules/meeting/config/locales/en.yml @@ -36,6 +36,7 @@ en: participants: "Participants" participants_attended: "Attendees" participants_invited: "Invitees" + project: "Project" start_time: "Time" start_time_hour: "Starting time" errors: @@ -79,6 +80,7 @@ en: label_time_zone: "Time zone" label_start_date: "Start date" + meeting: copied: "Copied from Meeting #%{id}" @@ -99,6 +101,8 @@ en: project_module_meetings: "Meetings" + project_selection_placeholder: "Select project" + text_duration_in_hours: "Duration in hours" text_in_hours: "in hours" text_meeting_agenda_for_meeting: 'agenda for the meeting "%{meeting}"' diff --git a/modules/meeting/config/routes.rb b/modules/meeting/config/routes.rb index 95e17f9ff84e..a53717316ad4 100644 --- a/modules/meeting/config/routes.rb +++ b/modules/meeting/config/routes.rb @@ -28,10 +28,10 @@ OpenProject::Application.routes.draw do resources :projects, only: %i[] do - resources :meetings, only: %i[new create index] + resources :meetings, only: %i[new create index], module: :projects end - resources :meetings, except: %i[new create] do + resources :meetings, except: %i[create] do resource :agenda, controller: 'meeting_agendas', only: [:update] do member do get :history diff --git a/modules/meeting/lib/open_project/meeting/engine.rb b/modules/meeting/lib/open_project/meeting/engine.rb index 98ff2c5043f4..626000cc5356 100644 --- a/modules/meeting/lib/open_project/meeting/engine.rb +++ b/modules/meeting/lib/open_project/meeting/engine.rb @@ -38,9 +38,9 @@ class Engine < ::Rails::Engine author_url: 'https://www.openproject.org', bundled: true do project_module :meetings do - permission :view_meetings, meetings: %i[index show], meeting_agendas: %i[history show diff], - meeting_minutes: %i[history show diff] - permission :create_meetings, { meetings: %i[new create copy] }, require: :member + permission :view_meetings, 'projects/meetings': %i[index show], meetings: %i[index show], + meeting_agendas: %i[history show diff], meeting_minutes: %i[history show diff] + permission :create_meetings, { 'projects/meetings': %i[new create], meetings: %i[new copy] }, require: :member permission :edit_meetings, { meetings: %i[edit update] }, require: :member permission :delete_meetings, { meetings: [:destroy] }, require: :member permission :meetings_send_invite, { meetings: [:icalendar] }, require: :member @@ -57,7 +57,7 @@ class Engine < ::Rails::Engine end menu :project_menu, - :meetings, { controller: '/meetings', action: 'index' }, + :meetings, { controller: '/projects/meetings', action: 'index' }, caption: :project_module_meetings, after: :wiki, before: :members, diff --git a/modules/meeting/spec/controllers/meetings_controller_spec.rb b/modules/meeting/spec/controllers/meetings_controller_spec.rb index e08f617abe8c..10e9673d15b2 100644 --- a/modules/meeting/spec/controllers/meetings_controller_spec.rb +++ b/modules/meeting/spec/controllers/meetings_controller_spec.rb @@ -54,23 +54,12 @@ end describe 'html' do - context 'when requesting meetings globally' do - before do - get 'index' - end - - it { expect(response).to be_successful } - it { expect(assigns(:meetings)).to match_array meetings } + before do + get 'index' end - context 'when requesting meetings scoped to a project ID' do - before do - get 'index', params: { project_id: project.id } - end - - it { expect(response).to be_successful } - it { expect(assigns(:meetings)).to match_array meetings[0..1] } - end + it { expect(response).to be_successful } + it { expect(assigns(:meetings)).to match_array meetings } end end @@ -117,55 +106,5 @@ it { expect(assigns(:meeting)).to eql meeting } end end - - describe 'create' do - render_views - - before do - allow(Project).to receive(:find).and_return(project) - post :create, - params: { - project_id: project.id, - meeting: { - title: 'Foobar', - duration: '1.0' - }.merge(params) - } - end - - describe 'invalid start_date' do - let(:params) do - { - start_date: '-', - start_time_hour: '10:00' - } - end - - it 'renders an error' do - expect(response).to have_http_status :ok - expect(response).to render_template :new - expect(response.body) - .to have_selector '#errorExplanation li', - text: "Start date #{I18n.t('activerecord.errors.messages.not_an_iso_date')}" - end - end - - describe 'invalid start_time_hour' do - let(:params) do - { - start_date: '2015-06-01', - start_time_hour: '-' - } - end - - it 'renders an error' do - expect(response).to have_http_status :ok - expect(response).to render_template :new - expect(response.body) - .to have_selector '#errorExplanation li', - text: "Starting time #{I18n.t('activerecord.errors.messages.invalid_time_format')}" - end - end - end end end diff --git a/modules/meeting/spec/controllers/projects/meetings_controller_spec.rb b/modules/meeting/spec/controllers/projects/meetings_controller_spec.rb new file mode 100644 index 000000000000..e49529ea9587 --- /dev/null +++ b/modules/meeting/spec/controllers/projects/meetings_controller_spec.rb @@ -0,0 +1,144 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2023 the OpenProject GmbH +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License version 3. +# +# OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: +# Copyright (C) 2006-2013 Jean-Philippe Lang +# Copyright (C) 2010-2013 the ChiliProject Team +# +# This program is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either version 2 +# of the License, or (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. +# +# See COPYRIGHT and LICENSE files for more details. +#++ + +require "#{File.dirname(__FILE__)}/../../spec_helper" + +RSpec.describe Projects::MeetingsController do + let(:user) { create(:admin) } + let(:project) { create(:project) } + let(:other_project) { create(:project) } + + before do + allow(User).to receive(:current).and_return user + + allow(Project).to receive(:find).and_return(project) + + allow(controller).to receive(:authorize) + allow(controller).to receive(:authorize_global) + allow(controller).to receive(:check_if_login_required) + end + + describe 'GET' do + describe 'index' do + let(:meetings) do + [ + create(:meeting, project:), + create(:meeting, project:), + create(:meeting, project: other_project) + ] + end + + describe 'html' do + before do + get :index, params: { project_id: project.id } + end + + it { expect(response).to be_successful } + it { expect(assigns(:meetings)).to match_array meetings[0..1] } + end + end + + describe 'new' do + let(:meeting) { Meeting.new(project:) } + + before do + allow(Project).to receive(:find).and_return(project) + allow(Meeting).to receive(:new).and_return(meeting) + end + + describe 'html' do + before do + get :new, params: { project_id: project.id } + end + + it { expect(response).to be_successful } + it { expect(assigns(:meeting)).to eql meeting } + end + end + end + + describe 'POST' do + describe 'create' do + render_views + + before do + allow(Project).to receive(:find).and_return(project) + + post :create, + params: { + project_id: project.id, + meeting: { + title: 'Foobar', + duration: '1.0' + }.merge(params) + } + end + + let(:params) do + { + start_date: '2015-06-01', + start_time_hour: '10:00' + } + end + + context 'with an invalid start_date' do + let(:params) do + { + start_date: '-', + start_time_hour: '10:00' + } + end + + it 'renders an error' do + expect(response).to have_http_status :ok + expect(response).to render_template :new + expect(response.body) + .to have_selector '#errorExplanation li', + text: "Start date #{I18n.t('activerecord.errors.messages.not_an_iso_date')}" + end + end + + describe 'with an invalid start_time_hour' do + let(:params) do + { + start_date: '2015-06-01', + start_time_hour: '-' + } + end + + it 'renders an error' do + expect(response).to have_http_status :ok + expect(response).to render_template :new + expect(response.body) + .to have_selector '#errorExplanation li', + text: "Starting time #{I18n.t('activerecord.errors.messages.invalid_time_format')}" + end + end + end + end +end diff --git a/modules/meeting/spec/features/meetings_index_spec.rb b/modules/meeting/spec/features/meetings_index_spec.rb index f2ba4630234a..58c0f766725a 100644 --- a/modules/meeting/spec/features/meetings_index_spec.rb +++ b/modules/meeting/spec/features/meetings_index_spec.rb @@ -78,10 +78,10 @@ context 'when the user is allowed to create meetings' do let(:permissions) { %i(view_meetings create_meetings) } - it 'does not show a create button' do + it 'shows a create button' do meetings_page.navigate_by_modules_menu - meetings_page.expect_no_create_new_button + meetings_page.expect_create_new_button end end end diff --git a/modules/meeting/spec/features/meetings_new_spec.rb b/modules/meeting/spec/features/meetings_new_spec.rb index 103d2a07bfb7..fdc61c99a966 100644 --- a/modules/meeting/spec/features/meetings_new_spec.rb +++ b/modules/meeting/spec/features/meetings_new_spec.rb @@ -32,7 +32,6 @@ RSpec.describe 'Meetings new', js: true do let(:project) { create(:project, enabled_module_names: %w[meetings]) } - let(:index_page) { Pages::Meetings::Index.new(project:) } let(:time_zone) { 'utc' } let(:user) do create(:user, @@ -60,62 +59,139 @@ login_as(current_user) end - context 'with permission to create meetings' do + context 'when creating a meeting from the global create page' do before do other_user + project end - ['CET', 'UTC', '', 'Pacific Time (US & Canada)'].each do |zone| - let(:time_zone) { zone } + let(:index_page) { Pages::Meetings::Index.new(project: nil) } - it "allows creating a project and handles errors in time zone #{zone}" do + context 'with permission to create meetings' do + ['CET', 'UTC', '', 'Pacific Time (US & Canada)'].each do |zone| + let(:time_zone) { zone } + + it "allows creating a project and handles errors in time zone #{zone}" do + index_page.visit! + + new_page = index_page.click_create_new + + new_page.set_title 'Some title' + new_page.set_project project + + # Setting the project reloads the page + # causing a StaleElementReferenceError + # if the execution is too quick. + SeleniumHubWaiter.wait + + new_page.set_start_date '2013-03-28' + new_page.set_start_time '13:30' + new_page.set_duration '1.5' + new_page.invite(other_user) + + show_page = new_page.click_create + + show_page.expect_toast(message: 'Successful creation') + + show_page.expect_invited(user, other_user) + + show_page.expect_date_time "03/28/2013 01:30 PM - 03:00 PM" + end + end + end + + context 'without permission to create meetings' do + let(:permissions) { %i[view_meetings] } + + it 'shows no edit link' do + index_page.visit! + + index_page.expect_no_create_new_button + end + end + + context 'as an admin' do + let(:current_user) { admin } + + it 'allows creating meeting in a project without members' do index_page.visit! new_page = index_page.click_create_new new_page.set_title 'Some title' - new_page.set_start_date '2013-03-28' - new_page.set_start_time '13:30' - new_page.set_duration '1.5' - new_page.invite(other_user) + + new_page.set_project project show_page = new_page.click_create show_page.expect_toast(message: 'Successful creation') - show_page.expect_invited(user, other_user) - - show_page.expect_date_time "03/28/2013 01:30 PM - 03:00 PM" + # Not sure if that is then intended behaviour but that is what is currently programmed + show_page.expect_invited(admin) end end end - context 'without permission to create meetings' do - let(:permissions) { %i[view_meetings] } + context 'when creating a meeting from the project-specific page' do + let(:index_page) { Pages::Meetings::Index.new(project:) } - it 'shows no edit link' do - index_page.visit! + context 'with permission to create meetings' do + before do + other_user + end - index_page.expect_no_create_new_button + ['CET', 'UTC', '', 'Pacific Time (US & Canada)'].each do |zone| + let(:time_zone) { zone } + + it "allows creating a project and handles errors in time zone #{zone}" do + index_page.visit! + + new_page = index_page.click_create_new + + new_page.set_title 'Some title' + new_page.set_start_date '2013-03-28' + new_page.set_start_time '13:30' + new_page.set_duration '1.5' + new_page.invite(other_user) + + show_page = new_page.click_create + + show_page.expect_toast(message: 'Successful creation') + + show_page.expect_invited(user, other_user) + + show_page.expect_date_time "03/28/2013 01:30 PM - 03:00 PM" + end + end end - end - context 'as an admin' do - let(:current_user) { admin } + context 'without permission to create meetings' do + let(:permissions) { %i[view_meetings] } - it 'allows creating meeting in a project without members' do - index_page.visit! + it 'shows no edit link' do + index_page.visit! - new_page = index_page.click_create_new + index_page.expect_no_create_new_button + end + end + + context 'as an admin' do + let(:current_user) { admin } + + it 'allows creating meeting in a project without members' do + index_page.visit! + + new_page = index_page.click_create_new - new_page.set_title 'Some title' + new_page.set_title 'Some title' - show_page = new_page.click_create + show_page = new_page.click_create - show_page.expect_toast(message: 'Successful creation') + show_page.expect_toast(message: 'Successful creation') - # Not sure if that is then intended behaviour but that is what is currently programmed - show_page.expect_invited(admin) + # Not sure if that is then intended behaviour but that is what is currently programmed + show_page.expect_invited(admin) + end end end end diff --git a/modules/meeting/spec/support/pages/meetings/new.rb b/modules/meeting/spec/support/pages/meetings/new.rb index 5448a66759b0..a632914888c5 100644 --- a/modules/meeting/spec/support/pages/meetings/new.rb +++ b/modules/meeting/spec/support/pages/meetings/new.rb @@ -47,6 +47,10 @@ def set_title(text) fill_in 'Title', with: text end + def set_project(project) + select project.name, from: 'Project' + end + def set_start_date(date) find_by_id('meeting_start_date').click datepicker = Components::BasicDatepicker.new