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/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/meetings_controller.rb b/modules/meeting/app/controllers/meetings_controller.rb index 6656d0490e20..1c63ade6a505 100644 --- a/modules/meeting/app/controllers/meetings_controller.rb +++ b/modules/meeting/app/controllers/meetings_controller.rb @@ -32,11 +32,12 @@ class MeetingsController < ApplicationController 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 + include MeetingsHelper include WatchersHelper include PaginationHelper include SortHelper @@ -76,7 +77,9 @@ def create end end - def new; end + def new + render layout: 'no_menu' if global_create_context? + end current_menu_item :new do :meetings @@ -123,14 +126,24 @@ def set_time_zone(&) def build_meeting @meeting = Meeting.new + if meeting_params.present? + convert_params + @meeting.participants.clear # Start with a clean set of participants + @meeting.participants_attributes = @converted_params.delete(:participants_attributes) + @meeting.attributes = @converted_params + end @meeting.project = @project @meeting.author = User.current end + def project_id + params[:project_id] || params.dig(:meeting, :project_id) + end + def find_optional_project - return true unless params[:project_id] + return true if project_id.blank? - @project = Project.find(params[:project_id]) + @project = Project.find(project_id) authorize rescue ActiveRecord::RecordNotFound render_404 @@ -163,7 +176,10 @@ def convert_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]) + if params[:meeting].present? + 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 end diff --git a/modules/meeting/app/helpers/meetings_helper.rb b/modules/meeting/app/helpers/meetings_helper.rb index debd096f36e7..986ff3f1ddff 100644 --- a/modules/meeting/app/helpers/meetings_helper.rb +++ b/modules/meeting/app/helpers/meetings_helper.rb @@ -72,4 +72,26 @@ 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 global_create_context? + request.path == new_meeting_path + end + + def new_form_refresh_url + if global_create_context? + new_meeting_path + else + new_project_meeting_path(@project) + end + end + + def options_for_project_selection + Project.allowed_to(User.current, :create_meetings) + .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/_form.html.erb b/modules/meeting/app/views/meetings/_form.html.erb index 371ec4d76aee..7fc6ab65e783 100644 --- a/modules/meeting/app/views/meetings/_form.html.erb +++ b/modules/meeting/app/views/meetings/_form.html.erb @@ -35,6 +35,23 @@ See COPYRIGHT and LICENSE files for more details. <%= f.text_field :title, :required => true, :size => 60, container_class: '-wide' %> + <% if global_create_context? %> +
    + <%= f.select :project_id, + options_for_project_selection, + {prompt: t(:project_selection_placeholder), + container_class: '-wide'}, + class: project_select_initial_class_list, + required: true, + 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 +103,8 @@ See COPYRIGHT and LICENSE files for more details. -
    + <% if @project %> +
    @@ -128,6 +146,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/meetings/index.html.erb b/modules/meeting/app/views/meetings/index.html.erb index bf9d94998e23..6db164c86c0a 100644 --- a/modules/meeting/app/views/meetings/index.html.erb +++ b/modules/meeting/app/views/meetings/index.html.erb @@ -30,18 +30,7 @@ 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 %> + <%= render Meetings::AddButtonComponent.new(current_project: @project) %> <% end %> <% if @meetings.empty? -%> diff --git a/modules/meeting/app/views/meetings/new.html.erb b/modules/meeting/app/views/meetings/new.html.erb index ee5dd0bb5c29..b33ebae5677f 100644 --- a/modules/meeting/app/views/meetings/new.html.erb +++ b/modules/meeting/app/views/meetings/new.html.erb @@ -29,9 +29,9 @@ 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| -%> +<%= labelled_tabular_form_for @meeting, url: {:controller => '/meetings', :action => 'create', :project_id => @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_form_refresh_url }} 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 %> +<% 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/spec/controllers/meetings_controller_spec.rb b/modules/meeting/spec/controllers/meetings_controller_spec.rb index e08f617abe8c..ec5708838fb7 100644 --- a/modules/meeting/spec/controllers/meetings_controller_spec.rb +++ b/modules/meeting/spec/controllers/meetings_controller_spec.rb @@ -96,12 +96,24 @@ end describe 'html' do - before do - get 'new', params: { project_id: project.id } + context 'when requesting the global page' do + before do + get 'new' + end + + it { expect(response).to be_successful } + it { expect(assigns(:meeting)).to eql meeting } + end + + context 'when requesting the project-scoped page' 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 - it { expect(response).to be_successful } - it { expect(assigns(:meeting)).to eql meeting } 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..6c7694546b16 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,143 @@ 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 + it 'does not render menus' do + index_page.expect_no_main_menu + end + + ['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 + + ['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 - index_page.expect_no_create_new_button + 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 'shows no edit link' do + index_page.visit! - it 'allows creating meeting in a project without members' do - index_page.visit! + index_page.expect_no_create_new_button + end + end - new_page = index_page.click_create_new + context 'as an admin' do + let(:current_user) { admin } - new_page.set_title 'Some title' + it 'allows creating meeting in a project without members' do + index_page.visit! - show_page = new_page.click_create + new_page = index_page.click_create_new - show_page.expect_toast(message: 'Successful creation') + new_page.set_title 'Some title' - # Not sure if that is then intended behaviour but that is what is currently programmed - show_page.expect_invited(admin) + show_page = new_page.click_create + + 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) + end end end end diff --git a/modules/meeting/spec/support/pages/meetings/index.rb b/modules/meeting/spec/support/pages/meetings/index.rb index 5550953158ca..6f01f796deac 100644 --- a/modules/meeting/spec/support/pages/meetings/index.rb +++ b/modules/meeting/spec/support/pages/meetings/index.rb @@ -46,6 +46,10 @@ def click_create_new New.new(project) end + def expect_no_main_menu + expect(page).not_to have_selector '#main-menu' + end + def expect_no_create_new_button expect(page).not_to have_selector '#add-meeting-button' 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