diff --git a/app/components/work_packages/progress/status_based/modal_body_component.html.erb b/app/components/work_packages/progress/status_based/modal_body_component.html.erb index d1a61701022f..d23796492650 100644 --- a/app/components/work_packages/progress/status_based/modal_body_component.html.erb +++ b/app/components/work_packages/progress/status_based/modal_body_component.html.erb @@ -6,10 +6,9 @@ id: "progress-form", html: { autocomplete: "off" }, data: { "application-target": "dynamic", - "work-packages--progress--preview-progress-target": "form", + "work-packages--progress--preview-target": "form", controller: "work-packages--progress--focus-field " \ - "work-packages--progress--preview-progress " \ - "work-packages--progress--touched-field-marker" } + "work-packages--progress--preview" } ) do |f| %> <%= flex_layout do |modal_body| %> <% modal_body.with_row(classes: "FormControl-horizontalGroup--sm-vertical") do |_fields| %> diff --git a/app/components/work_packages/progress/work_based/modal_body_component.html.erb b/app/components/work_packages/progress/work_based/modal_body_component.html.erb index d29236da0e9e..2726ce5db9ea 100644 --- a/app/components/work_packages/progress/work_based/modal_body_component.html.erb +++ b/app/components/work_packages/progress/work_based/modal_body_component.html.erb @@ -6,10 +6,9 @@ id: "progress-form", html: { autocomplete: "off" }, data: { "application-target": "dynamic", - "work-packages--progress--preview-progress-target": "form", + "work-packages--progress--preview-target": "form", controller: "work-packages--progress--focus-field " \ - "work-packages--progress--preview-progress " \ - "work-packages--progress--touched-field-marker" } + "work-packages--progress--preview" } ) do |f| %> <%= flex_layout do |modal_body| %> <% modal_body.with_row(classes: "FormControl-horizontalGroup--sm-vertical") do |_fields| %> diff --git a/app/controllers/work_packages/progress_controller.rb b/app/controllers/work_packages/progress_controller.rb index 3d1831c70f49..25e4c43fdce7 100644 --- a/app/controllers/work_packages/progress_controller.rb +++ b/app/controllers/work_packages/progress_controller.rb @@ -37,24 +37,18 @@ class WorkPackages::ProgressController < ApplicationController layout false authorization_checked! :new, :edit, :create, :update - helper_method :modal_class - def new make_fake_initial_work_package set_progress_attributes_to_work_package - render modal_class.new(@work_package, - focused_field: params[:field], - touched_field_map:) + render progress_modal_component end def edit find_work_package set_progress_attributes_to_work_package - render modal_class.new(@work_package, - focused_field: params[:field], - touched_field_map:) + render progress_modal_component end # rubocop:disable Metrics/AbcSize @@ -71,7 +65,9 @@ def create # Angular has context as to the success or failure of # the request in order to fetch the new set of Work Package # attributes in the ancestry solely on success. - render :update, status: :unprocessable_entity + render turbo_stream: [ + turbo_stream.morph("work_package_progress_modal", progress_modal_component) + ], status: :unprocessable_entity end end # following 3 lines to be removed in 15.0 with :percent_complete_edition feature flag removal @@ -95,7 +91,9 @@ def update if service_call.success? respond_to do |format| - format.turbo_stream + format.turbo_stream do + render turbo_stream: [] + end end else respond_to do |format| @@ -104,7 +102,9 @@ def update # Angular has context as to the success or failure of # the request in order to fetch the new set of Work Package # attributes in the ancestry solely on success. - render :update, status: :unprocessable_entity + render turbo_stream: [ + turbo_stream.morph("work_package_progress_modal", progress_modal_component) + ], status: :unprocessable_entity end end end @@ -112,6 +112,10 @@ def update private + def progress_modal_component + modal_class.new(@work_package, focused_field:, touched_field_map:) + end + def modal_class if WorkPackage.use_status_for_done_ratio? WorkPackages::Progress::StatusBased::ModalBodyComponent @@ -120,6 +124,10 @@ def modal_class end end + def focused_field + params[:field] + end + def find_work_package @work_package = WorkPackage.visible.find(params[:work_package_id]) end diff --git a/app/forms/work_packages/pre_14_4_progress_form.rb b/app/forms/work_packages/pre_14_4_progress_form.rb index 2127f3fdb343..f6b530f93dd7 100644 --- a/app/forms/work_packages/pre_14_4_progress_form.rb +++ b/app/forms/work_packages/pre_14_4_progress_form.rb @@ -91,12 +91,12 @@ def initialize(work_package:, group.hidden(name: :status_id_touched, value: @touched_field_map["status_id_touched"] || false, - data: { "work-packages--progress--touched-field-marker-target": "touchedFieldInput", - "referrer-field": "work_package[status_id]" }) + data: { "work-packages--progress--preview-target": "touchedFieldInput", + "referrer-field": "status_id" }) group.hidden(name: :estimated_hours_touched, value: @touched_field_map["estimated_hours_touched"] || false, - data: { "work-packages--progress--touched-field-marker-target": "touchedFieldInput", - "referrer-field": "work_package[estimated_hours]" }) + data: { "work-packages--progress--preview-target": "touchedFieldInput", + "referrer-field": "estimated_hours" }) else render_text_field(group, name: :estimated_hours, label: I18n.t(:label_work)) render_text_field(group, name: :remaining_hours, label: I18n.t(:label_remaining_work), @@ -105,12 +105,12 @@ def initialize(work_package:, group.hidden(name: :estimated_hours_touched, value: @touched_field_map["estimated_hours_touched"] || false, - data: { "work-packages--progress--touched-field-marker-target": "touchedFieldInput", - "referrer-field": "work_package[estimated_hours]" }) + data: { "work-packages--progress--preview-target": "touchedFieldInput", + "referrer-field": "estimated_hours" }) group.hidden(name: :remaining_hours_touched, value: @touched_field_map["remaining_hours_touched"] || false, - data: { "work-packages--progress--touched-field-marker-target": "touchedFieldInput", - "referrer-field": "work_package[remaining_hours]" }) + data: { "work-packages--progress--preview-target": "touchedFieldInput", + "referrer-field": "remaining_hours" }) end group.fields_for(:initial) do |builder| ::WorkPackages::ProgressForm::InitialValuesForm.new(builder, work_package:, mode:) @@ -195,9 +195,8 @@ def format_to_smallest_fractional_part(number) end def default_field_options(name) - data = { "work-packages--progress--preview-progress-target": "progressInput", - "work-packages--progress--touched-field-marker-target": "progressInput", - action: "input->work-packages--progress--touched-field-marker#markFieldAsTouched" } + data = { "work-packages--progress--preview-target": "progressInput", + action: "input->work-packages--progress--preview#markFieldAsTouched" } if @focused_field == name data[:"work-packages--progress--focus-field-target"] = "fieldToFocus" diff --git a/app/forms/work_packages/progress_form.rb b/app/forms/work_packages/progress_form.rb index e61176221d27..87542bb29ca2 100644 --- a/app/forms/work_packages/progress_form.rb +++ b/app/forms/work_packages/progress_form.rb @@ -163,8 +163,8 @@ def readonly_text_field(group, def hidden_touched_field(group, name:) group.hidden(name: :"#{name}_touched", value: touched(name), - data: { "work-packages--progress--touched-field-marker-target": "touchedFieldInput", - "referrer-field": "work_package[#{name}]" }) + data: { "work-packages--progress--preview-target": "touchedFieldInput", + "referrer-field": name }) end def touched(name) @@ -197,9 +197,8 @@ def as_percent(value) end def default_field_options(name) - data = { "work-packages--progress--preview-progress-target": "progressInput", - "work-packages--progress--touched-field-marker-target": "progressInput", - action: "work-packages--progress--touched-field-marker#markFieldAsTouched" } + data = { "work-packages--progress--preview-target": "progressInput", + action: "work-packages--progress--preview#markFieldAsTouched" } if @focused_field == name data[:"work-packages--progress--focus-field-target"] = "fieldToFocus" diff --git a/app/forms/work_packages/progress_form/initial_values_form.rb b/app/forms/work_packages/progress_form/initial_values_form.rb index 6ffe9618b536..91c10a2a5889 100644 --- a/app/forms/work_packages/progress_form/initial_values_form.rb +++ b/app/forms/work_packages/progress_form/initial_values_form.rb @@ -57,8 +57,8 @@ def initialize(work_package:, def hidden_initial_field(form, name:) form.hidden(name:, value: work_package.public_send(:"#{name}_was"), - data: { "work-packages--progress--touched-field-marker-target": "initialValueInput", - "referrer-field": "work_package[#{name}]" }) + data: { "work-packages--progress--preview-target": "initialValueInput", + "referrer-field": name }) end end end diff --git a/app/services/work_packages/set_attributes_service/derive_progress_values_status_based.rb b/app/services/work_packages/set_attributes_service/derive_progress_values_status_based.rb index 7f140a9caf1a..00ea713c06af 100644 --- a/app/services/work_packages/set_attributes_service/derive_progress_values_status_based.rb +++ b/app/services/work_packages/set_attributes_service/derive_progress_values_status_based.rb @@ -54,7 +54,8 @@ def derive_remaining_work? end def status_percent_complete_changed? - work_package.status_id_changed? && work_package.status.default_done_ratio != work_package.done_ratio_was + work_package.status_id.present? && work_package.status_id_came_from_user? \ + && work_package.status.default_done_ratio != work_package.done_ratio_was end # Update +% complete+ from the status if the status changed. diff --git a/app/views/work_packages/progress/update.turbo_stream.erb b/app/views/work_packages/progress/update.turbo_stream.erb deleted file mode 100644 index eb5c77eb6d17..000000000000 --- a/app/views/work_packages/progress/update.turbo_stream.erb +++ /dev/null @@ -1,3 +0,0 @@ -<%= turbo_stream.update "work_package_progress_modal" do %> - <%= render modal_class.new(@work_package, focused_field: params[:field]) %> -<% end %> diff --git a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts index d15042caf666..8abf179696ee 100644 --- a/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts +++ b/frontend/src/app/shared/components/fields/edit/field-types/progress-popover-edit-field.component.ts @@ -260,6 +260,7 @@ export class ProgressPopoverEditFieldComponent extends ProgressEditFieldComponen public onModalClosed():void { this.opened = false; + this.frameSrc = ''; if (!this.handler.inEditMode) { this.handler.deactivate(false); diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview-progress.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview-progress.controller.ts deleted file mode 100644 index bf722fa38d63..000000000000 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview-progress.controller.ts +++ /dev/null @@ -1,137 +0,0 @@ -/* - * -- copyright - * OpenProject is an open source project management software. - * Copyright (C) the OpenProject GmbH - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License version 3. - * - * OpenProject is a fork of ChiliProject, which is a fork of Redmine. The copyright follows: - * Copyright (C) 2006-2013 Jean-Philippe Lang - * Copyright (C) 2010-2013 the ChiliProject Team - * - * This program is free software; you can redistribute it and/or - * modify it under the terms of the GNU General Public License - * as published by the Free Software Foundation; either version 2 - * of the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program; if not, write to the Free Software - * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA. - * - * See COPYRIGHT and LICENSE files for more details. - * ++ - */ - -import { Controller } from '@hotwired/stimulus'; -import { debounce, DebouncedFunc } from 'lodash'; -import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; - -interface TurboBeforeFrameRenderEventDetail { - render:(currentElement:HTMLElement, newElement:HTMLElement) => void; -} - -export default class PreviewProgressController extends Controller { - static targets = [ - 'form', 'progressInput', - ]; - - declare readonly progressInputTargets:HTMLInputElement[]; - declare readonly formTarget:HTMLFormElement; - - private debouncedPreview:DebouncedFunc<(event:Event) => void>; - private frameMorphRenderer:(event:CustomEvent) => void; - - connect() { - this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 100); - // TODO: Ideally morphing in this single controller should not be necessary. - // Turbo supports morphing, by adding the attribute. - // However, it has a bug, and it doesn't morphs when reloading the frame via javascript. - // See https://github.com/hotwired/turbo/issues/1161 . Once the issue is solved, we can remove - // this code and just use instead. - this.frameMorphRenderer = (event:CustomEvent) => { - event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { - Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true }); - }; - }; - - this.progressInputTargets.forEach((target) => { - if (target.tagName.toLowerCase() === 'select') { - target.addEventListener('change', this.debouncedPreview); - } else { - target.addEventListener('input', this.debouncedPreview); - } - target.addEventListener('blur', this.debouncedPreview); - }); - - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; - turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - - disconnect() { - this.progressInputTargets.forEach((target) => { - target.removeEventListener('input', this.debouncedPreview); - target.removeEventListener('blur', this.debouncedPreview); - }); - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; - turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); - } - - async preview(event:Event) { - let field:HTMLInputElement; - if (event.type === 'blur') { - // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access - field = (event as FocusEvent).relatedTarget as HTMLInputElement; - } else { - field = event.target as HTMLInputElement; - } - - const form = this.formTarget; - const formData = new FormData(form) as unknown as undefined; - const formParams = new URLSearchParams(formData); - const wpParams = [ - ['work_package[initial][estimated_hours]', formParams.get('work_package[initial][estimated_hours]') || ''], - ['work_package[initial][remaining_hours]', formParams.get('work_package[initial][remaining_hours]') || ''], - ['work_package[initial][done_ratio]', formParams.get('work_package[initial][done_ratio]') || ''], - ['work_package[estimated_hours]', formParams.get('work_package[estimated_hours]') || ''], - ['work_package[remaining_hours]', formParams.get('work_package[remaining_hours]') || ''], - ['work_package[done_ratio]', formParams.get('work_package[done_ratio]') || ''], - ['work_package[status_id]', formParams.get('work_package[status_id]') || ''], - ['field', field?.name ?? ''], - ]; - - this.progressInputTargets.forEach((progressInput) => { - const touchedInputName = progressInput.name.replace(']', '_touched]'); - const touchedValue = formParams.get(touchedInputName) || ''; - wpParams.push([touchedInputName, touchedValue]); - }); - - const wpPath = this.ensureValidPathname(form.action); - const wpAction = wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; - - const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; - const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; - - if (turboFrame) { - turboFrame.src = editUrl; - } - } - - // Ensures that on create forms, there is an "id" for the un-persisted - // work package when sending requests to the edit action for previews. - private ensureValidPathname(formAction:string):string { - const wpPath = new URL(formAction); - - if (wpPath.pathname.endsWith('/work_packages/progress')) { - // Replace /work_packages/progress with /work_packages/new/progress - wpPath.pathname = wpPath.pathname.replace('/work_packages/progress', '/work_packages/new/progress'); - } - - return wpPath.toString(); - } -} diff --git a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/touched-field-marker.controller.ts b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts similarity index 52% rename from frontend/src/stimulus/controllers/dynamic/work-packages/progress/touched-field-marker.controller.ts rename to frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts index 7c96bcd31913..261bb72a3a74 100644 --- a/frontend/src/stimulus/controllers/dynamic/work-packages/progress/touched-field-marker.controller.ts +++ b/frontend/src/stimulus/controllers/dynamic/work-packages/progress/preview.controller.ts @@ -29,21 +29,82 @@ */ import { Controller } from '@hotwired/stimulus'; +import { debounce, DebouncedFunc } from 'lodash'; +import Idiomorph from 'idiomorph/dist/idiomorph.cjs'; -export default class TouchedFieldMarkerController extends Controller { +interface TurboBeforeFrameRenderEventDetail { + render:(currentElement:HTMLElement, newElement:HTMLElement) => void; +} + +export default class PreviewController extends Controller { static targets = [ + 'form', + 'progressInput', 'initialValueInput', 'touchedFieldInput', - 'progressInput', ]; + declare readonly progressInputTargets:HTMLInputElement[]; + declare readonly formTarget:HTMLFormElement; declare readonly initialValueInputTargets:HTMLInputElement[]; declare readonly touchedFieldInputTargets:HTMLInputElement[]; - declare readonly progressInputTargets:HTMLInputElement[]; + private debouncedPreview:DebouncedFunc<(event:Event) => void>; + private frameMorphRenderer:(event:CustomEvent) => void; private targetFieldName:string; + private touchedFields:Set; - private markFieldAsTouched(event:{ target:HTMLInputElement }) { + connect() { + this.touchedFields = new Set(); + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName && input.value === 'true') { + this.touchedFields.add(fieldName); + } + }); + + this.debouncedPreview = debounce((event:Event) => { void this.preview(event); }, 100); + // TODO: Ideally morphing in this single controller should not be necessary. + // Turbo supports morphing, by adding the attribute. + // However, it has a bug, and it doesn't morphs when reloading the frame via javascript. + // See https://github.com/hotwired/turbo/issues/1161 . Once the issue is solved, we can remove + // this code and just use instead. + this.frameMorphRenderer = (event:CustomEvent) => { + event.detail.render = (currentElement:HTMLElement, newElement:HTMLElement) => { + Idiomorph.morph(currentElement, newElement, { ignoreActiveValue: true }); + }; + }; + + this.progressInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.addEventListener('change', this.debouncedPreview); + } else { + target.addEventListener('input', this.debouncedPreview); + } + target.addEventListener('blur', this.debouncedPreview); + }); + + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; + turboFrame.addEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + + disconnect() { + this.debouncedPreview.cancel(); + this.progressInputTargets.forEach((target) => { + if (target.tagName.toLowerCase() === 'select') { + target.removeEventListener('change', this.debouncedPreview); + } else { + target.removeEventListener('input', this.debouncedPreview); + } + target.removeEventListener('blur', this.debouncedPreview); + }); + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; + if (turboFrame) { + turboFrame.removeEventListener('turbo:before-frame-render', this.frameMorphRenderer); + } + } + + markFieldAsTouched(event:{ target:HTMLInputElement }) { this.targetFieldName = event.target.name.replace(/^work_package\[([^\]]+)\]$/, '$1'); this.markTouched(this.targetFieldName); @@ -52,6 +113,47 @@ export default class TouchedFieldMarkerController extends Controller { } } + async preview(event:Event) { + let field:HTMLInputElement; + if (event.type === 'blur') { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + field = (event as FocusEvent).relatedTarget as HTMLInputElement; + } else { + field = event.target as HTMLInputElement; + } + + const form = this.formTarget; + const formData = new FormData(form) as unknown as undefined; + const formParams = new URLSearchParams(formData); + + const wpParams = Array.from(formParams.entries()) + .filter(([key, _]) => key.startsWith('work_package')); + wpParams.push(['field', field?.name ?? '']); + + const wpPath = this.ensureValidPathname(form.action); + const wpAction = wpPath.endsWith('/work_packages/new/progress') ? 'new' : 'edit'; + + const editUrl = `${wpPath}/${wpAction}?${new URLSearchParams(wpParams).toString()}`; + const turboFrame = this.formTarget.closest('turbo-frame') as HTMLFrameElement; + + if (turboFrame) { + turboFrame.src = editUrl; + } + } + + // Ensures that on create forms, there is an "id" for the un-persisted + // work package when sending requests to the edit action for previews. + private ensureValidPathname(formAction:string):string { + const wpPath = new URL(formAction); + + if (wpPath.pathname.endsWith('/work_packages/progress')) { + // Replace /work_packages/progress with /work_packages/new/progress + wpPath.pathname = wpPath.pathname.replace('/work_packages/progress', '/work_packages/new/progress'); + } + + return wpPath.toString(); + } + private isWorkBasedMode() { return this.findValueInput('done_ratio') !== undefined; } @@ -122,16 +224,7 @@ export default class TouchedFieldMarkerController extends Controller { // before being set by the user or derived. private findInitialValueInput(fieldName:string):HTMLInputElement|undefined { return this.initialValueInputTargets.find((input) => - (input.dataset.referrerField === fieldName) || (input.dataset.referrerField === `work_package[${fieldName}]`)); - } - - // Finds the touched field input based on a field name. - // - // The touched input field is used to mark a field as touched by the user so - // that the backend keeps the value instead of deriving it. - private findTouchedInput(fieldName:string):HTMLInputElement|undefined { - return this.touchedFieldInputTargets.find((input) => - (input.dataset.referrerField === fieldName) || (input.dataset.referrerField === `work_package[${fieldName}]`)); + (input.dataset.referrerField === fieldName)); } // Finds the value field input based on a field name. @@ -147,8 +240,7 @@ export default class TouchedFieldMarkerController extends Controller { } private isTouched(fieldName:string) { - const touchedInput = this.findTouchedInput(fieldName); - return touchedInput?.value === 'true'; + return this.touchedFields.has(fieldName); } private isInitialValueEmpty(fieldName:string) { @@ -167,16 +259,21 @@ export default class TouchedFieldMarkerController extends Controller { } private markTouched(fieldName:string) { - const touchedInput = this.findTouchedInput(fieldName); - if (touchedInput) { - touchedInput.value = 'true'; - } + this.touchedFields.add(fieldName); + this.updateTouchedFieldHiddenInputs(); } private markUntouched(fieldName:string) { - const touchedInput = this.findTouchedInput(fieldName); - if (touchedInput) { - touchedInput.value = 'false'; - } + this.touchedFields.delete(fieldName); + this.updateTouchedFieldHiddenInputs(); + } + + private updateTouchedFieldHiddenInputs() { + this.touchedFieldInputTargets.forEach((input) => { + const fieldName = input.dataset.referrerField; + if (fieldName) { + input.value = this.isTouched(fieldName) ? 'true' : 'false'; + } + }); } } diff --git a/spec/features/work_packages/progress_modal_spec.rb b/spec/features/work_packages/progress_modal_spec.rb index 21d7ea323905..8cfdd6499d67 100644 --- a/spec/features/work_packages/progress_modal_spec.rb +++ b/spec/features/work_packages/progress_modal_spec.rb @@ -223,9 +223,23 @@ def visit_progress_query_displaying_work_package it "can create work package after setting work" do work_package_create_page.visit! + work_package_create_page.expect_fully_loaded + + progress_popover.open + progress_popover.set_values(work: "invalid") + progress_popover.expect_values(remaining_work: "") + progress_popover.expect_errors(work: "Is not a valid duration.") + + # The modal does not go away when clicking Save until all fields are valid + 3.times do + progress_popover.save + sleep 0.2 + progress_popover.expect_errors(work: "Is not a valid duration.") + end + progress_popover.set_values(work: "10h") + progress_popover.save work_package_create_page.set_attributes({ subject: "hello" }) - work_package_create_page.set_progress_attributes({ estimatedTime: "10h" }) work_package_create_page.save! work_package_table.expect_and_dismiss_toaster(message: "Successful creation.", wait: 5) @@ -265,6 +279,25 @@ def visit_progress_query_displaying_work_package work_package_create_page.save! work_package_table.expect_and_dismiss_toaster(message: "Successful creation.") end + + it "updates remaining work when status is changed" do + work_package_create_page.visit! + work_package_create_page.set_attributes({ subject: "hello" }) + + progress_popover.open + progress_popover.expect_hints(work: nil, remaining_work: nil) + progress_popover.set_values(work: "14h") + progress_popover.expect_values(remaining_work: "14h") + progress_popover.expect_hints(remaining_work: :derived) + progress_popover.save + + status_field = work_package_create_page.edit_field(:status) + status_field.update("in progress") + + progress_popover.open + progress_popover.expect_values(work: "14h", remaining_work: "7h") + progress_popover.expect_hints(remaining_work: :derived) + end end end end @@ -386,6 +419,28 @@ def visit_progress_query_displaying_work_package end end + context "with invalid values" do + before do + update_work_package_with(work_package, estimated_hours: nil, remaining_hours: nil, done_ratio: nil) + end + + it "does not close the modal when clicking save multiple times (Bug #57423)" do + visit_progress_query_displaying_work_package + + progress_popover.open + progress_popover.set_values(work: "invalid") + progress_popover.expect_values(remaining_work: "") + progress_popover.expect_errors(work: "Is not a valid duration.") + + # The modal does not go away when clicking save + 3.times do + progress_popover.save + sleep 0.2 + progress_popover.expect_errors(work: "Is not a valid duration.") + end + end + end + describe "status field", with_settings: { work_package_done_ratio: "status" } do it "renders the status options as the << status_name (percent_complete_value %) >>" do visit_progress_query_displaying_work_package diff --git a/spec/services/work_packages/set_attributes_service/derive_progress_values_status_based_spec.rb b/spec/services/work_packages/set_attributes_service/derive_progress_values_status_based_spec.rb index 5cc0bd8a0722..e2587b2816b1 100644 --- a/spec/services/work_packages/set_attributes_service/derive_progress_values_status_based_spec.rb +++ b/spec/services/work_packages/set_attributes_service/derive_progress_values_status_based_spec.rb @@ -138,6 +138,28 @@ end end + context "given a work package with status and % complete not being in sync" do + before do + work_package.status = status_50_pct_complete + work_package.done_ratio = 0 + work_package.estimated_hours = 10.0 + work_package.remaining_hours = 10.0 + work_package.clear_changes_information + end + + context "when status is set again to the same value" do + let(:set_attributes) { { status: status_50_pct_complete } } + let(:expected_derived_attributes) { { remaining_hours: 5.0, done_ratio: 50 } } + let(:expected_kept_attributes) { %w[estimated_hours] } + + include_examples "update progress values", description: "updates % complete value to the status default % complete value " \ + "and derives remaining work", + expected_hints: { + remaining_work: :derived + } + end + end + context "given a work package with work and remaining work being empty, and a status with 0% complete" do before do work_package.status = status_0_pct_complete diff --git a/spec/support/components/work_packages/progress_popover.rb b/spec/support/components/work_packages/progress_popover.rb index 968018f26245..c1cbe42cfc77 100644 --- a/spec/support/components/work_packages/progress_popover.rb +++ b/spec/support/components/work_packages/progress_popover.rb @@ -156,6 +156,18 @@ def expect_hints(**field_hint_pairs) end end + def expect_error(field_name, error_message) + field(field_name).expect_error(error_message) + end + + def expect_errors(**field_error_pairs) + aggregate_failures("progress popover errors expectations") do + field_error_pairs.each do |field_name, error_message| + expect_error(field_name, error_message) + end + end + end + private def field(field_name) diff --git a/spec/support/edit_fields/progress_edit_field.rb b/spec/support/edit_fields/progress_edit_field.rb index 02b2862144fd..be72681ed16d 100644 --- a/spec/support/edit_fields/progress_edit_field.rb +++ b/spec/support/edit_fields/progress_edit_field.rb @@ -129,10 +129,11 @@ def input_element end def input_caption_element - input_element["aria-describedby"] - .split - .find { _1.start_with?("caption-") } - &.then { |caption_id| find(id: caption_id) } + input_aria_related_element(describedby: "caption") + end + + def input_validation_element + input_aria_related_element(describedby: "validation") end def trigger_element @@ -183,7 +184,12 @@ def display_selector # If they are the same, it means the modal field is in focus. # @return [Boolean] true if the modal field is in focus, false otherwise. def expect_modal_field_in_focus - expect(focused?).to be(true) + # Use capybara `synchronize` helper to wait until the modal field is in focus + page.document.synchronize do + unless focused? + raise Capybara::ExpectationNotMet, "Input #{input_element} does not have focus" + end + end end def focused? @@ -239,6 +245,15 @@ def expect_caption(expected_caption) end end + def expect_error(expected_error) + if expected_error.nil? + expect(input_validation_element).to be_nil, "Expected no error message for #{@human_field_name} field, " \ + "got \"#{input_validation_element&.text}\"" + else + expect(input_validation_element).to have_text(expected_error) + end + end + def expect_select_field_with_options(*expected_options) within modal_element do expect(page).to have_select(field_name, with_options: expected_options) @@ -273,4 +288,11 @@ def expect_migration_warning_banner(should_render: true) def modal_element page.find(MODAL_SELECTOR) end + + def input_aria_related_element(describedby:) + input_element["aria-describedby"] + .split + .find { _1.start_with?("#{describedby}-") } + &.then { |id| find(id:) } + end end