diff --git a/app/components/work_packages/date_picker/dialog_content_component.html.erb b/app/components/work_packages/date_picker/dialog_content_component.html.erb new file mode 100644 index 000000000000..58777cf07daf --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.html.erb @@ -0,0 +1,50 @@ +<%= + component_wrapper do + component_collection do |collection| + if show_banner? + collection.with_component(Primer::Alpha::Banner.new(scheme: banner_scheme, + full: true, + icon: :info, + description: banner_description, + mb: 3, + classes: "rounded-top-2")) do |banner| + banner.with_action_button(tag: :a, href: banner_link, target: "_blank") { I18n.t("work_packages.datepicker_modal.show_relations") } + banner_title + end + end + + collection.with_component(Primer::Alpha::Dialog::Body.new) do + flex_layout do |body| + body.with_row(mb: 3) do + flex_layout(align_items: :flex_end, justify_content: :space_between) do |first_row| + first_row.with_column do + render(Primer::Alpha::FormControl.new(label: I18n.t("work_packages.datepicker_modal.mode.title"))) do |component| + component.with_input do + render(Primer::Alpha::SegmentedControl.new("aria-label": I18n.t("work_packages.datepicker_modal.mode.title"))) do |control| + control.with_item(tag: :a, + href: datepicker_dialog_content_work_package_path(mode: :manual), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.manual"), + title: I18n.t("work_packages.datepicker_modal.mode.manual"), + selected: @mode.to_s == MANUAL_MODE) + control.with_item(tag: :a, + href: datepicker_dialog_content_work_package_path(mode: :automatic), + data: { turbo_stream: true }, + label: I18n.t("work_packages.datepicker_modal.mode.automatic"), + title: I18n.t("work_packages.datepicker_modal.mode.automatic"), + selected: @mode.to_s == AUTOMATIC_MODE) + end + end + end + end + + first_row.with_column(mb: 1) do + render(Primer::Alpha::CheckBox.new(name: "name", label: I18n.t("work_packages.datepicker_modal.ignore_non_working_days.title"))) + end + end + end + end + end + end + end +%> diff --git a/app/components/work_packages/date_picker/dialog_content_component.rb b/app/components/work_packages/date_picker/dialog_content_component.rb new file mode 100644 index 000000000000..7c3182c3d996 --- /dev/null +++ b/app/components/work_packages/date_picker/dialog_content_component.rb @@ -0,0 +1,108 @@ +# frozen_string_literal: true + +module WorkPackages + module DatePicker + class DialogContentComponent < ApplicationComponent + include OpPrimer::ComponentHelpers + include OpTurbo::Streamable + + AUTOMATIC_MODE = "automatic" + MANUAL_MODE = "manual" + + def initialize(work_package:, mode: MANUAL_MODE) + super + + @work_package = work_package + @mode = mode + end + + private + + def manually_scheduled? + @work_package.schedule_manually + end + + def show_banner? + true # TODO + end + + def banner_scheme + manually_scheduled? ? :warning : :default + end + + def banner_link + gantt_index_path( + query_props: { + c: %w[id subject type status assignee project startDate dueDate], + tll: '{"left":"startDate","right":"subject","farRight":null}', + tzl: "auto", + t: "id:asc", + tv: true, + hi: true, + f: [ + { "n" => "id", "o" => "=", "v" => all_relational_wp_ids } + ] + }.to_json.freeze + ) + end + + def banner_title + if manually_scheduled? + I18n.t("work_packages.datepicker_modal.banner.title.manually_scheduled") + elsif children.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_children") + elsif predecessor_relations.any? + I18n.t("work_packages.datepicker_modal.banner.title.automatic_with_predecessor") + end + end + + def banner_description + if manually_scheduled? + if children.any? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_with_children") + elsif predecessor_relations.any? + if overlapping_predecessor? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_overlap_with_predecessors") + elsif predecessor_with_large_gap? + return I18n.t("work_packages.datepicker_modal.banner.description.manual_gap_between_predecessors") + end + end + end + + I18n.t("work_packages.datepicker_modal.banner.description.click_on_show_relations_to_open_gantt", + button_name: I18n.t("js.work_packages.datepicker_modal.show_relations")) + end + + def overlapping_predecessor? + predecessor_work_packages.any? { |wp| wp.due_date.after?(@work_package.start_date) } + end + + def predecessor_with_large_gap? + sorted = predecessor_work_packages.sort_by(&:due_date) + sorted.last.due_date.before?(@work_package.start_date - 2) + end + + def predecessor_relations + @predecessor_relations ||= @work_package.follows_relations + end + + def predecessor_work_packages + @predecessor_work_packages ||= predecessor_relations + .includes(:to) + .map(&:to) + end + + def children + @children ||= @work_package.children + end + + def all_relational_wp_ids + @work_package + .relations + .pluck(:from_id, :to_id) + .flatten + .uniq + end + end + end +end diff --git a/app/controllers/work_packages_controller.rb b/app/controllers/work_packages_controller.rb index 81e3613a662e..7831d9df054a 100644 --- a/app/controllers/work_packages_controller.rb +++ b/app/controllers/work_packages_controller.rb @@ -38,12 +38,13 @@ class WorkPackagesController < ApplicationController before_action :authorize_on_work_package, :project, only: %i[show generate_pdf_dialog generate_pdf] + before_action :authorize_on_work_package, only: %i[datepicker_dialog_content] before_action :load_and_authorize_in_optional_project, :check_allowed_export, :protect_from_unauthorized_export, only: %i[index export_dialog] before_action :authorize, only: :show_conflict_flash_message - authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf + authorization_checked! :index, :show, :export_dialog, :generate_pdf_dialog, :generate_pdf, :datepicker_dialog_content before_action :load_and_validate_query, only: :index, unless: -> { request.format.html? } before_action :load_work_packages, only: :index, if: -> { request.format.atom? } @@ -106,6 +107,25 @@ def generate_pdf redirect_back(fallback_location: work_package_path(work_package)) end + def datepicker_dialog_content + mode = params.delete :mode || WorkPackages::DatePicker::DialogContentComponent::MANUAL_MODE + + respond_to do |format| + format.html do + render :datepicker_dialog_content, + locals: { work_package:, mode:, params: }, + layout: false + end + + format.turbo_stream do + replace_via_turbo_stream( + component: WorkPackages::DatePicker::DialogContentComponent.new(work_package:, mode:) + ) + render turbo_stream: turbo_streams + end + end + end + def show_conflict_flash_message scheme = params[:scheme]&.to_sym || :danger @@ -174,7 +194,7 @@ def per_page_param end def project - @project ||= work_package ? work_package.project : nil + @project ||= work_package&.project end def work_package diff --git a/app/views/work_packages/datepicker_dialog_content.html.erb b/app/views/work_packages/datepicker_dialog_content.html.erb new file mode 100644 index 000000000000..5d01cba57afd --- /dev/null +++ b/app/views/work_packages/datepicker_dialog_content.html.erb @@ -0,0 +1,5 @@ +<%= + content_tag("turbo-frame", id: "wp-datepicker-dialog--content") do + render(WorkPackages::DatePicker::DialogContentComponent.new(work_package:, mode:)) + end +%> diff --git a/config/locales/en.yml b/config/locales/en.yml index 8535ce37aef1..52264bcd92c7 100644 --- a/config/locales/en.yml +++ b/config/locales/en.yml @@ -664,6 +664,25 @@ en: no_results_title_text: There are currently no workflows. work_packages: + datepicker_modal: + banner: + description: + click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for Gantt overview.' + manual_gap_between_predecessors: "There is a gap between this and all predecessors." + manual_overlap_with_predecessors: "Overlaps with at least one predecessor." + manual_with_children: "This has child work package but their start dates are ignored." + title: + automatic_with_children: "The dates are determined by child work packages." + automatic_with_predecessor: "The start date is set by a predecessor." + manually_scheduled: "Manually scheduled. Dates not affected by relations." + ignore_non_working_days: + title: "Working days only" + mode: + title: "Scheduling mode" + automatic: "Automatic" + manual: "Manual" + show_relations: "Show relations" + x_descendants: one: "One descendant work package" other: "%{count} work package descendants" diff --git a/config/locales/js-en.yml b/config/locales/js-en.yml index 761b7a5153c6..a85af59eff4f 100644 --- a/config/locales/js-en.yml +++ b/config/locales/js-en.yml @@ -983,15 +983,6 @@ en: comment_send_failed: "An error has occurred. Could not submit the comment." comment_updated: "The comment was successfully updated." confirm_edit_cancel: "Are you sure you want to cancel editing the work package?" - datepicker_modal: - automatically_scheduled_parent: "Automatically scheduled. Dates are derived from relations." - manually_scheduled: "Manual scheduling enabled, all relations ignored." - start_date_limited_by_relations: "Available start and finish dates are limited by relations." - changing_dates_affects_follow_relations: "Changing these dates will affect dates of related work packages." - click_on_show_relations_to_open_gantt: 'Click on "%{button_name}" for GANTT overview.' - show_relations: "Show relations" - ignore_non_working_days: - title: "Working days only" description_filter: "Filter" description_enter_text: "Enter text" description_options_hide: "Hide options" diff --git a/config/routes.rb b/config/routes.rb index 38c278f2cdea..0c3332f792f6 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -612,6 +612,7 @@ get "/export_dialog" => "work_packages#export_dialog", on: :collection, as: "export_dialog" get :show_conflict_flash_message, on: :collection # we don't need a specific work package for this + get "/datepicker_dialog_content" => "work_packages#datepicker_dialog_content", on: :member, as: "datepicker_dialog_content" get "/split_view/update_counter" => "work_packages/split_view#update_counter", on: :member diff --git a/frontend/src/app/core/path-helper/path-helper.service.ts b/frontend/src/app/core/path-helper/path-helper.service.ts index 442dacf5e612..6f3557ad6e56 100644 --- a/frontend/src/app/core/path-helper/path-helper.service.ts +++ b/frontend/src/app/core/path-helper/path-helper.service.ts @@ -300,6 +300,10 @@ export class PathHelperService { return `${this.workPackagePath(workPackageId)}/split_view/update_counter?counter=${counter}`; } + public workPackageDatepickerDialogContentPath(workPackageId:string|number) { + return `${this.workPackagePath(workPackageId)}/datepicker_dialog_content`; + } + // Work Package Bulk paths public workPackagesBulkEditPath() { diff --git a/frontend/src/app/shared/components/datepicker/datepicker.module.ts b/frontend/src/app/shared/components/datepicker/datepicker.module.ts index 3cfc24d62ef0..25f56ca82c22 100644 --- a/frontend/src/app/shared/components/datepicker/datepicker.module.ts +++ b/frontend/src/app/shared/components/datepicker/datepicker.module.ts @@ -17,6 +17,7 @@ import { OpBasicDatePickerModule } from './basic-datepicker.module'; import { OpSpotModule } from 'core-app/spot/spot.module'; import { OpenprojectModalModule } from '../modal/modal.module'; import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicker/sheet/date-picker-sheet.component'; +import { OpenprojectContentLoaderModule } from 'core-app/shared/components/op-content-loader/openproject-content-loader.module'; @NgModule({ imports: [ @@ -27,6 +28,7 @@ import { OpDatePickerSheetComponent } from 'core-app/shared/components/datepicke OpSpotModule, OpBasicDatePickerModule, OpenprojectModalModule, + OpenprojectContentLoaderModule, ], providers: [ diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html index 90d1fb365405..96e219433d60 100644 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.html @@ -7,13 +7,22 @@ tabindex="0" (submit)="doSave($event)" > - + + + -
-
+ + + + + + + + +
diff --git a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts index d1dd3a218f95..357015d8c6d8 100644 --- a/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts +++ b/frontend/src/app/shared/components/datepicker/wp-multi-date-form/wp-multi-date-form.component.ts @@ -82,6 +82,7 @@ import { WorkPackageChangeset } from 'core-app/features/work-packages/components import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; import { DateModalSchedulingService } from '../services/date-modal-scheduling.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; export type DateKeys = 'start'|'end'; export type DateFields = DateKeys|'duration'; @@ -132,6 +133,8 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A @Output() save = new EventEmitter(); + public turboFrameSrc:string; + text = { save: this.I18n.t('js.button_save'), cancel: this.I18n.t('js.button_cancel'), @@ -243,6 +246,7 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A readonly deviceService:DeviceService, readonly weekdayService:WeekdayService, readonly focusHelper:FocusHelperService, + readonly pathHelper:PathHelperService, ) { super(); @@ -289,6 +293,8 @@ export class OpWpMultiDateFormComponent extends UntilDestroyedMixin implements A this.dates.start = this.changeset.value('startDate'); this.dates.end = this.changeset.value('dueDate'); this.setCurrentActivatedField(this.initialActivatedField); + + this.turboFrameSrc = this.pathHelper.workPackageDatepickerDialogContentPath(this.changeset.id); } ngAfterViewInit():void {