diff --git a/frontend/src/app/core/days/weekday.service.ts b/frontend/src/app/core/days/weekday.service.ts index dfee9443f7a6..97a22a8c8ce7 100644 --- a/frontend/src/app/core/days/weekday.service.ts +++ b/frontend/src/app/core/days/weekday.service.ts @@ -62,6 +62,10 @@ export class WeekdayService { return !!(this.weekdays || []).find((wd) => wd.day === isoDayOfWeek && !wd.working); } + public get nonWorkingDays():IWeekday[] { + return this.weekdays.filter((day) => !day.working); + } + loadWeekdays():Observable { if (this.weekdays) { return of(this.weekdays); diff --git a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html index 207e9bce42ce..462e8baed928 100644 --- a/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html +++ b/frontend/src/app/features/team-planner/team-planner/planner/team-planner.component.html @@ -25,6 +25,19 @@ data-qa-selector="add-existing-pane" > + +
> }; + @Component({ selector: 'op-team-planner', templateUrl: './team-planner.component.html', @@ -286,6 +291,7 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, add_assignee: this.I18n.t('js.team_planner.add_assignee'), remove_assignee: this.I18n.t('js.team_planner.remove_assignee'), noData: this.I18n.t('js.team_planner.no_data'), + work_week: this.I18n.t('js.team_planner.work_week'), two_weeks: this.I18n.t('js.team_planner.two_weeks'), one_week: this.I18n.t('js.team_planner.one_week'), today: this.I18n.t('js.team_planner.today'), @@ -307,6 +313,55 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, isMobile = this.deviceService.isMobile; + private initialCalendarView = this.workPackagesCalendar.initialView || 'resourceTimelineWorkWeek'; + + private viewOptionDefaults = { + type: 'resourceTimeline', + slotDuration: { days: 1 }, + resourceAreaColumns: [ + { + field: 'title', + headerContent: { + html: ` ${this.text.assignee}`, + }, + }, + ], + }; + + public viewOptions:TeamPlannerViewOptions = { + resourceTimelineWorkWeek: { + ...this.viewOptionDefaults, + ...{ + duration: { weeks: 1 }, + slotLabelFormat: [ + { weekday: 'long', day: '2-digit' }, + ], + buttonText: this.text.work_week, + }, + }, + resourceTimelineWeek: { + ...this.viewOptionDefaults, + ...{ + duration: { weeks: 1 }, + slotLabelFormat: [ + { weekday: 'long', day: '2-digit' }, + ], + buttonText: this.text.one_week, + }, + }, + resourceTimelineTwoWeeks: { + ...this.viewOptionDefaults, + ...{ + buttonText: this.text.two_weeks, + duration: { weeks: 2 }, + dateIncrement: { weeks: 1 }, + slotLabelFormat: [ + { weekday: 'short', day: '2-digit' }, + ], + }, + }, + }; + constructor( private $state:StateService, private configuration:ConfigurationService, @@ -428,49 +483,21 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, plugins: [resourceTimelinePlugin, interactionPlugin], titleFormat: { year: 'numeric', month: 'long', day: 'numeric' }, buttonText: { today: this.text.today }, - initialView: this.workPackagesCalendar.initialView || 'resourceTimelineWeek', + initialView: this.initialCalendarView, headerToolbar: { left: '', center: 'title', - right: 'prev,next today resourceTimelineWeek,resourceTimelineTwoWeeks', + right: 'prev,next today', }, - views: { - resourceTimelineWeek: { - type: 'resourceTimeline', - buttonText: this.text.one_week, - duration: { weeks: 1 }, - slotDuration: { days: 1 }, - slotLabelFormat: [ - { weekday: 'long', day: '2-digit' }, - ], - resourceAreaColumns: [ - { - field: 'title', - headerContent: { - html: ` ${this.text.assignee}`, - }, - }, - ], - }, - resourceTimelineTwoWeeks: { - type: 'resourceTimeline', - buttonText: this.text.two_weeks, - slotDuration: { days: 1 }, - duration: { weeks: 2 }, - dateIncrement: { weeks: 1 }, - slotLabelFormat: [ - { weekday: 'short', day: '2-digit' }, - ], - resourceAreaColumns: [ - { - field: 'title', - headerContent: { - html: ` ${this.text.assignee}`, - }, - }, - ], + views: _.merge( + {}, + this.viewOptions, + { + resourceTimelineWorkWeek: { + hiddenDays: this.weekdayService.nonWorkingDays.map((weekday) => weekday.day % 7), // The OP days are 1 based but this needs to be 0 based. + }, }, - }, + ), // Ensure we show the skeleton from the beginning progressiveEventRendering: true, eventSources: [ @@ -607,6 +634,14 @@ export class TeamPlannerComponent extends UntilDestroyedMixin implements OnInit, void this.workPackagesCalendar.updateTimeframe(fetchInfo, this.projectIdentifier); } + public switchView(key:TeamPlannerViewOptionKey):void { + this.ucCalendar.getApi().changeView(key); + } + + public get currentViewTitle():string { + return this.viewOptions[((this.ucCalendar && this.ucCalendar.getApi().view.type) || this.initialCalendarView) as TeamPlannerViewOptionKey].buttonText as string; + } + /** * Clear loading and show successful toast if we were reloading the page * @private diff --git a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts index 5b7263ed1361..ff5e5130c14a 100644 --- a/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts +++ b/frontend/src/app/features/team-planner/team-planner/team-planner.module.ts @@ -15,6 +15,7 @@ import { OPSharedModule } from 'core-app/shared/shared.module'; import { AddExistingPaneComponent } from './add-work-packages/add-existing-pane.component'; import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; import { TeamPlannerSidemenuComponent } from 'core-app/features/team-planner/team-planner/sidemenu/team-planner-sidemenu.component'; +import { TeamPlannerViewSelectMenuDirective } from 'core-app/features/team-planner/team-planner/view-select/view-select-menu.directive'; @NgModule({ declarations: [ @@ -23,6 +24,7 @@ import { TeamPlannerSidemenuComponent } from 'core-app/features/team-planner/tea AddAssigneeComponent, AddExistingPaneComponent, TeamPlannerSidemenuComponent, + TeamPlannerViewSelectMenuDirective, ], imports: [ OPSharedModule, diff --git a/frontend/src/app/features/team-planner/team-planner/view-select/view-select-menu.directive.ts b/frontend/src/app/features/team-planner/team-planner/view-select/view-select-menu.directive.ts new file mode 100644 index 000000000000..499de3f19e05 --- /dev/null +++ b/frontend/src/app/features/team-planner/team-planner/view-select/view-select-menu.directive.ts @@ -0,0 +1,71 @@ +/* + * --copyright + * OpenProject is an open source project management software. + * Copyright (C) 2010-2022 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 { + Directive, + EventEmitter, + Input, + Output, +} from '@angular/core'; +import { OpContextMenuTrigger } from 'core-app/shared/components/op-context-menu/handlers/op-context-menu-trigger.directive'; +import { + TeamPlannerViewOptionKey, + TeamPlannerViewOptions, +} from 'core-app/features/team-planner/team-planner/planner/team-planner.component'; +import { OpContextMenuItem } from 'core-app/shared/components/op-context-menu/op-context-menu.types'; + +@Directive({ + selector: '[opTeamPlannerViewSelectDropdown]', +}) +export class TeamPlannerViewSelectMenuDirective extends OpContextMenuTrigger { + @Input() public viewOptions:NonNullable; + + @Output() public viewSelected = new EventEmitter(); + + public get locals():{ showAnchorRight?:boolean, contextMenuId?:string, items:OpContextMenuItem[] } { + return { + items: this.buildItems(), + contextMenuId: 'op-team-planner--view-select-dropdown', + }; + } + + private selected(key:TeamPlannerViewOptionKey):boolean { + this.viewSelected.emit(key); + // Done to satisfy the interface. + return true; + } + + private buildItems():OpContextMenuItem[] { + return Object.entries(this.viewOptions).map(([key, viewOption]) => ({ + linkText: viewOption.buttonText as string, + onClick: () => this.selected(key as TeamPlannerViewOptionKey), + })); + } +} diff --git a/frontend/src/global_styles/content/modules/_team_planner.sass b/frontend/src/global_styles/content/modules/_team_planner.sass index 229dfb17c746..e2678f636b40 100644 --- a/frontend/src/global_styles/content/modules/_team_planner.sass +++ b/frontend/src/global_styles/content/modules/_team_planner.sass @@ -1,5 +1,7 @@ @import "../../../app/features/team-planner/team-planner/assignee/tp-assignee" +$view-select-dropdown-width: 7rem + .router--team-planner #content height: 100% @@ -31,10 +33,20 @@ background: white z-index: 5 padding-left: 138px + // Necessary for the button to switch between views which is hacked in via absolute positioning. + padding-right: $view-select-dropdown-width + 0.5rem padding-bottom: 1.5rem margin: 0 !important - &--add-existing-toggle + &--view-select-dropdown + width: $view-select-dropdown-width + display: flex + + .button--text + flex-grow: 1 + text-align: left + + &--add-existing-toggle, &--view-select-dropdown z-index: 6 .fc-scrollgrid @@ -74,4 +86,6 @@ justify-content: center !important .fc-datagrid-cell-cushion padding: 12px 12px !important - \ No newline at end of file + +#op-team-planner--view-select-dropdown + min-width: $view-select-dropdown-width diff --git a/modules/team_planner/config/locales/js-en.yml b/modules/team_planner/config/locales/js-en.yml index 4dbc566d21f4..f6f6bec45409 100644 --- a/modules/team_planner/config/locales/js-en.yml +++ b/modules/team_planner/config/locales/js-en.yml @@ -12,6 +12,7 @@ en: remove_assignee: 'Remove assignee' two_weeks: '2-week' one_week: '1-week' + work_week: 'Work week' today: 'Today' drag_here_to_remove: 'Drag here to remove assignee and start and end dates.' cannot_drag_here: 'Cannot remove the work package due to permissions or editing restrictions.' diff --git a/modules/team_planner/spec/features/team_planner_dates_spec.rb b/modules/team_planner/spec/features/team_planner_dates_spec.rb index a1508180b373..b71193f927a4 100644 --- a/modules/team_planner/spec/features/team_planner_dates_spec.rb +++ b/modules/team_planner/spec/features/team_planner_dates_spec.rb @@ -29,7 +29,7 @@ require 'spec_helper' require_relative './shared_context' -describe 'Team planner working days', type: :feature, js: true do +describe 'Team planner working days', js: true do before do with_enterprise_token(:team_planner_view) end @@ -39,7 +39,7 @@ context 'with week days defined' do let!(:week_days) { week_with_saturday_and_sunday_as_weekend } - it 'renders sat and sun as non working' do + it 'hides sat and sun in the "Work week" view andd renders sat and sun as non working in the "1-week" view' do team_planner.visit! team_planner.expect_empty_state @@ -49,58 +49,38 @@ team_planner.select_user_to_add user.name end - expect(page).to have_selector('.fc-day-sat.fc-non-working-day', minimum: 1, wait: 10) - expect(page).to have_selector('.fc-day-sun.fc-non-working-day', minimum: 1) + # Initially, in the "Work week" view, non working days are hidden + expect(page).to have_selector('.fc-day-mon') + expect(page).to have_selector('.fc-day-tue') + expect(page).to have_selector('.fc-day-wed') + expect(page).to have_selector('.fc-day-thu') + expect(page).to have_selector('.fc-day-fri') - expect(page).to have_no_selector('.fc-day-mon.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-tue.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-wed.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-thu.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-fri.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-sat') + expect(page).not_to have_selector('.fc-day-sun') - find('.fc-next-button').click + # In the "1-week" view, non working days are displayed but marked + team_planner.switch_view_mode '1-week' expect(page).to have_selector('.fc-day-sat.fc-non-working-day', minimum: 1, wait: 10) expect(page).to have_selector('.fc-day-sun.fc-non-working-day', minimum: 1) - expect(page).to have_no_selector('.fc-day-mon.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-tue.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-wed.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-thu.fc-non-working-day') - expect(page).to have_no_selector('.fc-day-fri.fc-non-working-day') - end - end - - context 'with all days marked as weekend' do - let!(:week_days) { week_with_no_working_days } - - it 'renders all as non working' do - team_planner.visit! - - team_planner.expect_empty_state - retry_block do - team_planner.click_add_user - page.find('[data-qa-selector="tp-add-assignee"] input') - team_planner.select_user_to_add user.name - end - - expect(page).to have_selector('.fc-day-sat.fc-non-working-day', minimum: 1, wait: 10) - expect(page).to have_selector('.fc-day-sun.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-mon.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-tue.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-wed.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-thu.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-fri.fc-non-working-day', minimum: 1) + expect(page).not_to have_selector('.fc-day-mon.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-tue.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-wed.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-thu.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-fri.fc-non-working-day') find('.fc-next-button').click expect(page).to have_selector('.fc-day-sat.fc-non-working-day', minimum: 1, wait: 10) expect(page).to have_selector('.fc-day-sun.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-mon.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-tue.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-wed.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-thu.fc-non-working-day', minimum: 1) - expect(page).to have_selector('.fc-day-fri.fc-non-working-day', minimum: 1) + + expect(page).not_to have_selector('.fc-day-mon.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-tue.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-wed.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-thu.fc-non-working-day') + expect(page).not_to have_selector('.fc-day-fri.fc-non-working-day') end end end diff --git a/modules/team_planner/spec/features/team_planner_spec.rb b/modules/team_planner/spec/features/team_planner_spec.rb index 6105d23c075f..ebda9f60f9b1 100644 --- a/modules/team_planner/spec/features/team_planner_spec.rb +++ b/modules/team_planner/spec/features/team_planner_spec.rb @@ -62,57 +62,84 @@ filters.expect_available_filter "Assignee's role", present: false end - context 'with an assigned work package' do + context 'with an assigned work package', with_settings: { working_days: [1, 2, 3, 4, 5] } do let!(:other_user) do - create :user, + create(:user, firstname: 'Other', lastname: 'User', member_in_project: project, member_with_permissions: %w[ view_work_packages edit_work_packages view_team_planner manage_team_planner - ] + ]) end - let!(:user_outside_project) { create :user, firstname: 'Not', lastname: 'In Project' } - let(:type_task) { create :type_task } - let(:type_bug) { create :type_bug } - let(:closed_status) { create :status, is_closed: true } + let!(:user_outside_project) { create(:user, firstname: 'Not', lastname: 'In Project') } + let(:type_task) { create(:type_task) } + let(:type_bug) { create(:type_bug) } + let(:closed_status) { create(:status, is_closed: true) } let!(:other_task) do - create :work_package, + create(:work_package, project:, type: type_task, assigned_to: other_user, - start_date: Time.zone.today - 1.day, - due_date: Time.zone.today + 1.day, - subject: 'A task for the other user' + start_date: Time.zone.today.monday + 1.day, + due_date: Time.zone.today.monday + 3.days, + subject: 'A task for the other user') end let!(:other_bug) do - create :work_package, + create(:work_package, project:, type: type_bug, assigned_to: other_user, - start_date: Time.zone.today - 1.day, - due_date: Time.zone.today + 1.day, - subject: 'Another task for the other user' + start_date: Time.zone.today.monday + 1.day, + due_date: Time.zone.today.monday + 3.days, + subject: 'Another task for the other user') end let!(:closed_bug) do - create :work_package, + create(:work_package, project:, type: type_bug, assigned_to: other_user, status: closed_status, - start_date: Time.zone.today - 1.day, - due_date: Time.zone.today + 1.day, - subject: 'Closed bug' + start_date: Time.zone.today.monday + 1.day, + due_date: Time.zone.today.monday + 3.days, + subject: 'Closed bug') end let!(:user_bug) do - create :work_package, + create(:work_package, project:, type: type_bug, assigned_to: user, start_date: Time.zone.today - 10.days, due_date: Time.zone.today + 20.days, - subject: 'A task for the logged in user' + subject: 'A task for the logged in user') + end + let!(:user_bug_next_week) do + create(:work_package, + project:, + type: type_bug, + assigned_to: user, + start_date: Time.zone.today.monday + 7.days, + due_date: Time.zone.today.monday + 12.days, + subject: 'A task for the logged in user in the next week') + end + let!(:user_bug_last_week) do + create(:work_package, + project:, + type: type_bug, + assigned_to: user, + start_date: Time.zone.today.monday - 7.days, + due_date: Time.zone.today.monday - 5.days, + subject: 'A task for the logged in user in the last week') + end + let!(:user_bug_on_weekend) do + create(:work_package, + project:, + type: type_bug, + assigned_to: user, + start_date: Time.zone.today.monday + 5.days, + due_date: Time.zone.today.monday + 6.days, + subject: 'A task for the logged in user on the weekend') end before do @@ -120,7 +147,7 @@ project.types << type_task end - it 'renders a basic board' do + it 'renders a team planner displaying work packages by assignee and date' do team_planner.visit! team_planner.title @@ -147,8 +174,15 @@ team_planner.expect_assignee user team_planner.expect_assignee other_user + # Starting on the "Work week" by default means that + # work packages on the weekend as well as in the last or upcoming week are not displayed. + # Those work packages that are displayed, are displayed in the row of their assignee. + team_planner.within_lane(user) do team_planner.expect_event user_bug + team_planner.expect_event user_bug_next_week, present: false + team_planner.expect_event user_bug_last_week, present: false + team_planner.expect_event user_bug_on_weekend, present: false end team_planner.within_lane(other_user) do @@ -157,6 +191,31 @@ team_planner.expect_event closed_bug end + # Switching to the '1-week' view means that + # work packages on the weekend are displayed now but + # those outside of the current week are still hidden. + team_planner.switch_view_mode('1-week') + + team_planner.within_lane(user) do + team_planner.expect_event user_bug + team_planner.expect_event user_bug_next_week, present: false + team_planner.expect_event user_bug_last_week, present: false + team_planner.expect_event user_bug_on_weekend + end + + # Switching to the '2-week' view means that + # work packages on the weekend and those of the upcoming week are displayed. + # Those in the last week are still hidden. + + team_planner.switch_view_mode('2-week') + + team_planner.within_lane(user) do + team_planner.expect_event user_bug + team_planner.expect_event user_bug_next_week + team_planner.expect_event user_bug_last_week, present: false + team_planner.expect_event user_bug_on_weekend + end + # Add filter for type task filters.expect_filter_count("1") filters.open @@ -261,16 +320,16 @@ end context 'with a readonly work package' do - let(:readonly_status) { create :status, is_readonly: true } + let(:readonly_status) { create(:status, is_readonly: true) } let!(:blocked_task) do - create :work_package, + create(:work_package, project:, assigned_to: user, status: readonly_status, start_date: Time.zone.today - 1.day, due_date: Time.zone.today + 1.day, - subject: 'A blocked task' + subject: 'A blocked task') end it 'disables editing on readonly tasks' do diff --git a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb index 59f161ceb99a..9f2fb7e940ba 100644 --- a/modules/team_planner/spec/features/team_planner_view_modes_spec.rb +++ b/modules/team_planner/spec/features/team_planner_view_modes_spec.rb @@ -36,7 +36,7 @@ include_context 'with team planner full access' - it 'allows switching of view modes' do + it 'allows switching of view modes', with_settings: { working_days: [1, 2, 3, 4, 5] } do team_planner.visit! team_planner.expect_empty_state @@ -46,8 +46,11 @@ team_planner.select_user_to_add user.name end + team_planner.expect_view_mode 'Work week' + expect(page).to have_selector('.fc-timeline-slot-frame', count: 5) + # weekly: Expect 7 slots - team_planner.expect_view_mode '1-week' + team_planner.switch_view_mode '1-week' expect(page).to have_selector('.fc-timeline-slot-frame', count: 7) # 2 weeks: expect 14 slots diff --git a/modules/team_planner/spec/support/pages/team_planner.rb b/modules/team_planner/spec/support/pages/team_planner.rb index 3d053888240a..cb8763461c4e 100644 --- a/modules/team_planner/spec/support/pages/team_planner.rb +++ b/modules/team_planner/spec/support/pages/team_planner.rb @@ -72,9 +72,10 @@ def expect_empty_state(present: true) end def expect_view_mode(text) - expect(page).to have_selector('.fc-button-active', text:) + expect(page).to have_selector('[data-qa-selector="op-team-planner--view-select-dropdown"]', text:) param = { + 'Work week' => :resourceTimelineWorkWeek, '1-week' => :resourceTimelineWeek, '2-week' => :resourceTimelineTwoWeeks }[text] @@ -83,7 +84,12 @@ def expect_view_mode(text) end def switch_view_mode(text) - page.find('.fc-button', text:).click + find('[data-qa-selector="op-team-planner--view-select-dropdown"]').click + + within('#op-team-planner--view-select-dropdown') do + click_button(text) + end + expect_view_mode(text) end