diff --git a/frontend/src/app/core/datetime/timezone.service.ts b/frontend/src/app/core/datetime/timezone.service.ts index 4b3fcb41a123..1106bce3b145 100644 --- a/frontend/src/app/core/datetime/timezone.service.ts +++ b/frontend/src/app/core/datetime/timezone.service.ts @@ -30,9 +30,7 @@ import { Injectable } from '@angular/core'; import { ConfigurationService } from 'core-app/core/config/configuration.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import * as moment from 'moment-timezone'; -import { - Moment, -} from 'moment'; +import { Moment } from 'moment'; @Injectable({ providedIn: 'root' }) export class TimezoneService { @@ -84,9 +82,9 @@ export class TimezoneService { return this.parseDate(date, 'YYYY-MM-DD'); } - public formattedDate(date:string):string { + public formattedDate(date:string, format = this.getDateFormat()):string { const d = this.parseDate(date); - return d.format(this.getDateFormat()); + return d.format(format); } /** diff --git a/frontend/src/app/core/schemas/schema-cache.service.ts b/frontend/src/app/core/schemas/schema-cache.service.ts index eabbb5471fc9..e9c54acd5346 100644 --- a/frontend/src/app/core/schemas/schema-cache.service.ts +++ b/frontend/src/app/core/schemas/schema-cache.service.ts @@ -28,16 +28,10 @@ import { State } from '@openproject/reactivestates'; import { Injectable } from '@angular/core'; import { StateCacheService } from 'core-app/core/apiv3/cache/state-cache.service'; -import { - firstValueFrom, - Observable, -} from 'rxjs'; +import { firstValueFrom, Observable } from 'rxjs'; import { take } from 'rxjs/operators'; import { States } from 'core-app/core/states/states.service'; -import { - ISchemaProxy, - SchemaProxy, -} from 'core-app/features/hal/schemas/schema-proxy'; +import { ISchemaProxy, SchemaProxy } from 'core-app/features/hal/schemas/schema-proxy'; import { HalResourceService } from 'core-app/features/hal/services/hal-resource.service'; import { WorkPackageSchemaProxy } from 'core-app/features/hal/schemas/work-package-schema-proxy'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; @@ -71,6 +65,10 @@ export class SchemaCacheService extends StateCacheService { of(resource:HalResource):ISchemaProxy { const schema = this.state(resource).value as SchemaResource; + return this.proxied(resource, schema); + } + + proxied(resource:HalResource, schema:SchemaResource):ISchemaProxy { if (resource._type === 'WorkPackage') { return WorkPackageSchemaProxy.create(schema, resource); } diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.html b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.html index 805f68ea95b7..57a000ef47b6 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.html +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.html @@ -52,20 +52,20 @@ *ngIf="isNewResource(workPackage)">
+ [wrapperClasses]="'work-packages--type-selector'" + [fieldName]="'type'" + class="op-wp-single-card-content--type"> + fieldName="subject" + class="op-wp-single-card-content--subject -bold">
- + - {{startDate(workPackage)}} - + + + - - - {{endDate(workPackage)}} + - + + @@ -118,7 +130,7 @@ [workPackage]="workPackage" [small]="true" class="op-wp-single-card--content-status" - > + > - + containerType="single-view"> +
diff --git a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts index 7b6195bef3ad..d204c5f31b41 100644 --- a/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts +++ b/frontend/src/app/features/work-packages/components/wp-card-view/wp-single-card/wp-single-card.component.ts @@ -7,20 +7,29 @@ import { OnInit, Output, } from '@angular/core'; -import { uiStateLinkClass } from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder'; +import { + uiStateLinkClass, +} from 'core-app/features/work-packages/components/wp-fast-table/builders/ui-state-link-builder'; import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; -import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; import { - StateService, - UIRouterGlobals, -} from '@uirouter/core'; -import { WorkPackageViewSelectionService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; -import { WorkPackageCardViewService } from 'core-app/features/work-packages/components/wp-card-view/services/wp-card-view.service'; + Highlighting, +} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; +import { StateService, UIRouterGlobals } from '@uirouter/core'; +import { + WorkPackageViewSelectionService, +} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-selection.service'; +import { + WorkPackageCardViewService, +} from 'core-app/features/work-packages/components/wp-card-view/services/wp-card-view.service'; import { I18nService } from 'core-app/core/i18n/i18n.service'; -import { CardHighlightingMode } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting-mode.const'; +import { + CardHighlightingMode, +} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting-mode.const'; import { CardViewOrientation } from 'core-app/features/work-packages/components/wp-card-view/wp-card-view.component'; import { UntilDestroyedMixin } from 'core-app/shared/helpers/angular/until-destroyed.mixin'; -import { WorkPackageViewFocusService } from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service'; +import { + WorkPackageViewFocusService, +} from 'core-app/features/work-packages/routing/wp-view-base/view-services/wp-view-focus.service'; import { WorkPackageResource } from 'core-app/features/hal/resources/work-package-resource'; import { isClickedWithModifier } from 'core-app/shared/helpers/link-handling/link-handling'; import isNewResource from 'core-app/features/hal/helpers/is-new-resource'; @@ -30,9 +39,10 @@ import { combineLatest } from 'rxjs'; import { map } from 'rxjs/operators'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import SpotDropAlignmentOption from 'core-app/spot/drop-alignment-options'; +import { getBaselineState } from 'core-app/features/work-packages/components/wp-baseline/baseline-helpers'; import { - getBaselineState, -} from 'core-app/features/work-packages/components/wp-baseline/baseline-helpers'; + CombinedDateDisplayField, +} from 'core-app/shared/components/fields/display/field-types/combined-date-display.field'; @Component({ selector: 'wp-single-card', @@ -99,16 +109,7 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen public tooltipPosition = SpotDropAlignmentOption.BottomLeft; - private dateTimeFormatYear = new Intl.DateTimeFormat(this.I18n.locale, { - year: 'numeric', - month: 'short', - day: 'numeric', - }); - - private dateTimeFormat = new Intl.DateTimeFormat(this.I18n.locale, { - month: 'short', - day: 'numeric', - }); + combinedDateDisplayField = CombinedDateDisplayField; constructor( readonly pathHelper:PathHelperService, @@ -206,50 +207,6 @@ export class WorkPackageSingleCardComponent extends UntilDestroyedMixin implemen return wp.project?.name; } - public wpDates(wp:WorkPackageResource):string { - const { startDate, dueDate } = wp; - - if (startDate && dueDate) { - // eslint-disable-next-line @typescript-eslint/ban-ts-comment - // @ts-ignore see https://github.com/microsoft/TypeScript/issues/46905 - // eslint-disable-next-line @typescript-eslint/no-unsafe-call - return String(this.dateTimeFormatYear.formatRange(new Date(startDate), new Date(dueDate))); - } - - if (!startDate && dueDate) { - return `– ${this.dateTimeFormatYear.format(new Date(dueDate))}`; - } - - if (startDate && !dueDate) { - return `${this.dateTimeFormatYear.format(new Date(startDate))} –`; - } - - return ''; - } - - startDate(wp:WorkPackageResource):string { - const { startDate } = wp; - if (!startDate) { - return ''; - } - - return this.dateTimeFormat.format(new Date(startDate)); - } - - endDate(wp:WorkPackageResource):string { - const { dueDate } = wp; - if (!dueDate) { - return ''; - } - - return this.dateTimeFormat.format(new Date(dueDate)); - } - - wpOverDueHighlighting(wp:WorkPackageResource):string { - const diff = this.timezoneService.daysFromToday(wp.dueDate); - return Highlighting.overdueDate(diff); - } - public fullWorkPackageLink(wp:WorkPackageResource):string { return this.$state.href('work-packages.show', { workPackageId: wp.id }); } diff --git a/frontend/src/app/shared/components/fields/display/display-field.component.ts b/frontend/src/app/shared/components/fields/display/display-field.component.ts index 4994cbdeb1ee..d6509e3ba8e3 100644 --- a/frontend/src/app/shared/components/fields/display/display-field.component.ts +++ b/frontend/src/app/shared/components/fields/display/display-field.component.ts @@ -1,18 +1,11 @@ -import { - ChangeDetectionStrategy, - Component, - ElementRef, - Injector, - Input, - OnInit, - ViewChild, -} from '@angular/core'; +import { ChangeDetectionStrategy, Component, ElementRef, Injector, Input, OnInit, ViewChild } from '@angular/core'; import { IFieldSchema } from 'core-app/shared/components/fields/field.base'; import { DisplayFieldService } from 'core-app/shared/components/fields/display/display-field.service'; import { HalResource } from 'core-app/features/hal/resources/hal-resource'; import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; import { Constructor } from '@angular/cdk/table'; import { DisplayField } from 'core-app/shared/components/fields/display/display-field.module'; +import { SchemaResource } from 'core-app/features/hal/resources/schema-resource'; @Component({ selector: 'display-field', @@ -32,16 +25,20 @@ export class DisplayFieldComponent implements OnInit { @ViewChild('displayFieldContainer') container:ElementRef; - constructor(private injector:Injector, + constructor( + private injector:Injector, private displayFieldService:DisplayFieldService, - private schemaCache:SchemaCacheService) { + private schemaCache:SchemaCacheService, + ) { } ngOnInit():void { void this.schemaCache .ensureLoaded(this.resource) .then((schema) => { - this.render(schema[this.fieldName]); + const proxied = this.schemaCache.proxied(this.resource, schema); + this.fieldName = this.attributeName(this.fieldName, proxied); + this.render(proxied[this.fieldName] as IFieldSchema); }); } @@ -76,6 +73,15 @@ export class DisplayFieldComponent implements OnInit { ); } + private attributeName(attribute:string, schema:SchemaResource):string { + if (schema.mappedName) { + // eslint-disable-next-line @typescript-eslint/no-unsafe-call + return schema.mappedName(attribute) as string; + } + + return attribute; + } + private get displayFieldContext() { return { injector: this.injector, container: this.containerType, options: this.displayFieldOptions }; } diff --git a/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts b/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts index d169a99fc2d3..da2d111e9eb2 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/combined-date-display.field.ts @@ -33,10 +33,60 @@ export class CombinedDateDisplayField extends DateDisplayField { placeholder: { startDate: this.I18n.t('js.label_no_start_date'), dueDate: this.I18n.t('js.label_no_due_date'), + date: this.I18n.t('js.label_no_date'), }, }; - public render(element:HTMLElement, displayText:string):void { + public render(element:HTMLElement):void { + if (this.name === 'date') { + this.renderSingleDate('date', element); + return; + } + + if (this.startDate && (this.startDate === this.dueDate)) { + this.renderSingleDate('dueDate', element); + return; + } + + if (!this.startDate && !this.dueDate) { + element.innerHTML = this.customPlaceholder(`${this.text.placeholder.startDate} - ${this.text.placeholder.dueDate}`); + return; + } + + this.renderDates(element); + } + + isEmpty():boolean { + return false; + } + + customPlaceholder(fallback:string):string { + if (typeof this.context.options.placeholder === 'string') { + return this.context.options.placeholder; + } + + return fallback; + } + + private get startDate():string|null { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return + return this.resource.startDate; + } + + private get dueDate():string|null { + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access,@typescript-eslint/no-unsafe-return + return this.resource.dueDate; + } + + private renderSingleDate(field:'date'|'startDate'|'dueDate', element:HTMLElement):void { + element.innerHTML = ''; + + const dateElement = this.createDateDisplayField(field); + + element.appendChild(dateElement); + } + + private renderDates(element:HTMLElement):void { element.innerHTML = ''; const startDateElement = this.createDateDisplayField('startDate'); @@ -50,14 +100,15 @@ export class CombinedDateDisplayField extends DateDisplayField { element.appendChild(dueDateElement); } - private createDateDisplayField(date:'dueDate'|'startDate'):HTMLElement { + private createDateDisplayField(date:'dueDate'|'startDate'|'date'):HTMLElement { const dateElement = document.createElement('span'); const dateDisplayField = new DateDisplayField(date, this.context); - const text = this.resource[date] - ? this.timezoneService.formattedDate(this.resource[date]) - : this.text.placeholder[date]; - dateDisplayField.apply(this.resource, this.schema); + + // eslint-disable-next-line @typescript-eslint/no-unsafe-member-access + const text = this.resource[date] + ? dateDisplayField.valueString + : this.customPlaceholder(this.text.placeholder[date]); dateDisplayField.render(dateElement, text); return dateElement; diff --git a/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts b/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts index cbe798e1a3f9..01640ccb2fcf 100644 --- a/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts +++ b/frontend/src/app/shared/components/fields/display/field-types/date-display-field.module.ts @@ -26,8 +26,12 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { Highlighting } from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; -import { HighlightableDisplayField } from 'core-app/shared/components/fields/display/field-types/highlightable-display-field.module'; +import { + Highlighting, +} from 'core-app/features/work-packages/components/wp-fast-table/builders/highlighting/highlighting.functions'; +import { + HighlightableDisplayField, +} from 'core-app/shared/components/fields/display/field-types/highlightable-display-field.module'; import { InjectField } from 'core-app/shared/helpers/angular/inject-field.decorator'; import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; import { TimezoneService } from 'core-app/core/datetime/timezone.service'; @@ -75,7 +79,8 @@ export class DateDisplayField extends HighlightableDisplayField { public get valueString() { if (this.value) { - return this.timezoneService.formattedDate(this.value); + // eslint-disable-next-line @typescript-eslint/no-unsafe-argument + return this.timezoneService.formattedDate(this.value, this.context.options.dateFormat); } return ''; } diff --git a/frontend/src/app/shared/components/fields/openproject-fields.module.ts b/frontend/src/app/shared/components/fields/openproject-fields.module.ts index 093e3e21cc1e..bbb87b76de54 100644 --- a/frontend/src/app/shared/components/fields/openproject-fields.module.ts +++ b/frontend/src/app/shared/components/fields/openproject-fields.module.ts @@ -131,6 +131,7 @@ import { FormsModule } from '@angular/forms'; EditFormPortalComponent, EditFormComponent, EditableAttributeFieldComponent, + DisplayFieldComponent, ], providers: [ { diff --git a/modules/team_planner/spec/features/team_planner_create_spec.rb b/modules/team_planner/spec/features/team_planner_create_spec.rb index ec7b3a156dac..70de2980ba54 100644 --- a/modules/team_planner/spec/features/team_planner_create_spec.rb +++ b/modules/team_planner/spec/features/team_planner_create_spec.rb @@ -56,7 +56,7 @@ split_create.expect_and_dismiss_toaster(message: I18n.t('js.notice_successful_create')) split_create.expect_attributes( - combinedDate: "#{start_of_week.strftime('%m/%d/%Y')} - #{start_of_week.strftime('%m/%d/%Y')}", + combinedDate: start_of_week.strftime('%m/%d/%Y'), assignee: user.name ) diff --git a/spec/features/work_packages/cards/wp_card_dates_spec.rb b/spec/features/work_packages/cards/wp_card_dates_spec.rb new file mode 100644 index 000000000000..43e3a351aa31 --- /dev/null +++ b/spec/features/work_packages/cards/wp_card_dates_spec.rb @@ -0,0 +1,126 @@ +#-- copyright +# OpenProject is an open source project management software. +# Copyright (C) 2012-2024 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 'spec_helper' + +RSpec.describe 'Showing dates on WP cards', + :js, + :with_cuprite, + with_settings: { date_format: '%Y-%m-%d' } do + let(:manager_role) do + create(:project_role, permissions: %i[view_work_packages edit_work_packages]) + end + let(:manager) do + create(:user, + firstname: 'Manager', + lastname: 'Guy', + member_with_roles: { project => manager_role }) + end + let(:status1) { create(:status) } + let(:status2) { create(:status) } + + let(:type) { create(:type) } + let(:type_milestone) { create(:type_milestone) } + let!(:project) { create(:project, types: [type, type_milestone]) } + let!(:empty) do + create(:work_package, + project:, + type:, + status: status1, + start_date: nil, + due_date: nil, + subject: 'Empty dates') + end + + let!(:start) do + create(:work_package, + project:, + type:, + status: status1, + start_date: '2024-01-22', + due_date: nil, + subject: 'Start only') + end + + let!(:due) do + create(:work_package, + project:, + type:, + status: status1, + due_date: '2024-01-22', + start_date: nil, + subject: 'Due only') + end + + let!(:both) do + create(:work_package, + project:, + type:, + status: status1, + start_date: '2024-01-20', + due_date: '2024-01-22', + subject: 'Due only') + end + + let!(:milestone) do + create(:work_package, + project:, + type: type_milestone, + status: status1, + due_date: '2024-01-22', + subject: 'Milestone') + end + + let(:wp_table) { Pages::WorkPackagesTable.new(project) } + let(:wp_card_view) { Pages::WorkPackageCards.new(project) } + let(:display_representation) { Components::WorkPackages::DisplayRepresentation.new } + + before do + login_as(manager) + + wp_table.visit! + display_representation.switch_to_card_layout + end + + it 'shows the correct dates' do + empty_card = wp_card_view.card(empty) + expect(empty_card).to have_css('.op-wp-single-card--content-dates', text: '', exact_text: true) + + start_only_card = wp_card_view.card(start) + expect(start_only_card).to have_css('.op-wp-single-card--content-dates', text: 'Jan 22, 2024 -', exact_text: true) + + due_only_card = wp_card_view.card(due) + expect(due_only_card).to have_css('.op-wp-single-card--content-dates', text: '- Jan 22, 2024', exact_text: true) + + both_card = wp_card_view.card(both) + expect(both_card).to have_css('.op-wp-single-card--content-dates', text: 'Jan 20, 2024 - Jan 22, 2024', exact_text: true) + + milestone_card = wp_card_view.card(milestone) + expect(milestone_card).to have_css('.op-wp-single-card--content-dates', text: 'Jan 22, 2024', exact_text: true) + end +end diff --git a/spec/features/work_packages/details/date_editor_spec.rb b/spec/features/work_packages/details/date_editor_spec.rb index b91f1fe192f5..b0556bad4ffc 100644 --- a/spec/features/work_packages/details/date_editor_spec.rb +++ b/spec/features/work_packages/details/date_editor_spec.rb @@ -226,7 +226,7 @@ start_date.save! start_date.expect_inactive! - start_date.expect_state_text "#{Time.zone.today.strftime('%Y-%m-%d')} - #{Time.zone.today.strftime('%Y-%m-%d')}" + start_date.expect_state_text Time.zone.today.strftime('%Y-%m-%d') end it 'can set a negative duration which gets transformed (Regression #44219)' do