diff --git a/frontend/package-lock.json b/frontend/package-lock.json index d67eac2aa35a..0fd6152d95ae 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -64,6 +64,7 @@ "@openproject/primer-view-components": "^0.91.1", "@openproject/reactivestates": "^3.0.1", "@openproject/stimulus-elements": "^0.2.0", + "@primer/behaviors": "^1.10.3", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.5.1", @@ -5533,9 +5534,9 @@ "license": "MIT" }, "node_modules/@primer/behaviors": { - "version": "1.10.2", - "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.2.tgz", - "integrity": "sha512-93juWZbWg2DRhC11+7RT7hMpY1VD3lBosLmccqEZ65yrCHqkBCjI8Uj8wxs3y0U+wWE07LAoLHAPylyWbifg5A==", + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/@primer/behaviors/-/behaviors-1.10.3.tgz", + "integrity": "sha512-cxxWDeR5IE7vdL0Ca0RAAAuPVrU+Bo8CSP1WfawGo4rEP1sZTi7hwl5i7TT0jCTCR0C9IfnQBfSPAkz0oznxUw==", "license": "MIT" }, "node_modules/@primer/css": { diff --git a/frontend/package.json b/frontend/package.json index a04eef1f2d68..d5859f65f919 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -110,6 +110,7 @@ "@openproject/primer-view-components": "^0.91.1", "@openproject/reactivestates": "^3.0.1", "@openproject/stimulus-elements": "^0.2.0", + "@primer/behaviors": "^1.10.3", "@primer/css": "^22.1.0", "@primer/live-region-element": "^0.8.0", "@primer/primitives": "^11.5.1", diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts new file mode 100644 index 000000000000..814081962e18 --- /dev/null +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.spec.ts @@ -0,0 +1,69 @@ +//-- 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 { render } from 'lit-html'; +import { within } from '@testing-library/dom'; +import { timeEntryPopoverHtml } from './te-calendar-popover'; + +describe('timeEntryPopoverHtml', () => { + let host:HTMLElement; + + beforeEach(() => { + host = document.createElement('div'); + }); + + const rows = [{ label: 'Project', value: 'Demo' }, { label: 'Hours', value: '2h' }]; + + it('renders a hint popover anchored to the entry', () => { + render(timeEntryPopoverHtml('pop-1', 'anchor-1', rows, null), host); + const popover = host.querySelector('anchored-position')!; + + expect(popover).toHaveAttribute('id', 'pop-1'); + expect(popover).toHaveAttribute('anchor', 'anchor-1'); + expect(popover).toHaveAttribute('popover', 'hint'); + expect(popover).toHaveAttribute('role', 'dialog'); + expect(popover).toHaveClass('op-anchored-popover--host'); + }); + + it('lists every row as label and value', () => { + render(timeEntryPopoverHtml('pop-1', 'anchor-1', rows, null), host); + const entries = within(host).getAllByRole('listitem'); + + expect(entries).toHaveLength(2); + expect(entries[0]).toHaveTextContent('Project: Demo'); + expect(entries[1]).toHaveTextContent('Hours: 2h'); + }); + + it('renders the caret from the placement', () => { + render(timeEntryPopoverHtml('pop-1', 'anchor-1', rows, { side: 'right', offset: 20 }), host); + const message = host.querySelector('.Popover-message')!; + + expect(message).toHaveClass('Popover-message--right'); + expect(message.style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('20px'); + }); +}); diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.ts new file mode 100644 index 000000000000..6533c044cb12 --- /dev/null +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar-popover.ts @@ -0,0 +1,69 @@ +//-- 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 { html } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; + +export interface PopoverRow { + label:string; + value:string; +} + +export function timeEntryPopoverHtml( + popoverId:string, + anchorId:string, + rows:PopoverRow[], + caret:CaretPlacement|null, +):TemplateResult { + const list = html` + + `; + + return html` + + ${popoverMessage(list, caret)} + + `; +} diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.sass b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.sass index 457b0bb67409..7748a1253469 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.sass +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.sass @@ -21,6 +21,10 @@ op-time-entries-calendar flex-basis: 165px flex-shrink: 0 + .te-calendar--popover .Popover-message + padding: var(--base-size-8) + font-size: var(--text-body-size-small) + .te-calendar--popover-entry @include text-shortener(false) diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts new file mode 100644 index 000000000000..64e2a24fbc11 --- /dev/null +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.spec.ts @@ -0,0 +1,135 @@ +//-- 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 { NO_ERRORS_SCHEMA } from '@angular/core'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { FullCalendarModule } from '@fullcalendar/angular'; +import { StateService } from '@uirouter/core'; +import moment from 'moment'; +import { of } from 'rxjs'; +import { ApiV3Service } from 'core-app/core/apiv3/api-v3.service'; +import { BrowserDetector } from 'core-app/core/browser/browser-detector.service'; +import { ConfigurationService } from 'core-app/core/config/configuration.service'; +import { TimezoneService } from 'core-app/core/datetime/timezone.service'; +import { WeekdayService } from 'core-app/core/days/weekday.service'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; +import { SchemaCacheService } from 'core-app/core/schemas/schema-cache.service'; +import { DayResourceService } from 'core-app/core/state/days/day.service'; +import { States } from 'core-app/core/states/states.service'; +import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service'; +import { HalResourceNotificationService } from 'core-app/features/hal/services/hal-resource-notification.service'; +import { OpCalendarService } from 'core-app/features/calendar/op-calendar.service'; +import { ColorsService } from 'core-app/shared/components/colors/colors.service'; +import { HalResourceEditingService } from 'core-app/shared/components/fields/edit/services/hal-resource-editing.service'; +import { TimeEntryCalendarComponent } from './te-calendar.component'; + +describe('TimeEntryCalendarComponent', () => { + let fixture:ComponentFixture; + let element:HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + declarations: [TimeEntryCalendarComponent], + imports: [FullCalendarModule], + schemas: [NO_ERRORS_SCHEMA], + providers: [ + { provide: I18nService, useValue: { locale: 'en', t: (key:string) => key, toNumber: (value:number) => `${value}` } }, + { provide: ConfigurationService, useValue: { startOfWeek: () => 1, isTimezoneSet: () => false, timezone: () => 'UTC', dateFormatPresent: () => false } }, + { provide: WeekdayService, useValue: { loadWeekdays: () => of([]), isNonWorkingDay: () => false } }, + { provide: DayResourceService, useValue: { requireNonWorkingYears$: () => of([]) } }, + { provide: ApiV3Service, useValue: { time_entries: { list: () => of({ elements: [], createTimeEntry: undefined }) } } }, + { + provide: TimezoneService, + useValue: { + toHours: () => 1, + formattedDuration: () => '1h', + formattedISODate: (date:Date) => moment(date).format('YYYY-MM-DD'), + }, + }, + { provide: BrowserDetector, useValue: { isMobile: false } }, + { provide: States, useValue: {} }, + { provide: StateService, useValue: {} }, + { provide: HalResourceNotificationService, useValue: {} }, + { provide: SchemaCacheService, useValue: {} }, + { provide: ColorsService, useValue: {} }, + ], + }) + .overrideComponent(TimeEntryCalendarComponent, { + set: { + providers: [ + OpCalendarService, + { provide: HalResourceEditingService, useValue: {} }, + { provide: TurboRequestsService, useValue: {} }, + { provide: PathHelperService, useValue: {} }, + ], + }, + }) + .compileComponents(); + + fixture = TestBed.createComponent(TimeEntryCalendarComponent); + fixture.componentRef.setInput('projectIdentifier', 'demo'); + fixture.detectChanges(); + element = fixture.nativeElement as HTMLElement; + }); + + const renderWeek = async (displayedDays:boolean[]) => { + fixture.componentRef.setInput('displayedDays', displayedDays); + await vi.waitUntil(() => { + fixture.detectChanges(); + return element.querySelector('.fc-col-header-cell') !== null; + }); + }; + + it('renders the calendar container', () => { + expect(element.querySelector('.te-calendar--container')).not.toBeNull(); + expect(element.querySelector('full-calendar')).toBeNull(); + }); + + it('renders a week grid once the displayed days are known', async () => { + await renderWeek([true, true, true, true, true, true, true]); + expect(element.querySelectorAll('.fc-col-header-cell.fc-day')).toHaveLength(7); + }); + + it('hides the days that are not displayed', async () => { + await renderWeek([true, true, true, true, true, false, false]); + expect(element.querySelectorAll('.fc-col-header-cell.fc-day')).toHaveLength(5); + }); + + it('closes an open entry popover when the window is resized', () => { + const popover = document.createElement('div'); + popover.className = 'te-calendar--popover'; + popover.setAttribute('popover', 'manual'); + element.append(popover); + popover.showPopover(); + + window.dispatchEvent(new Event('resize')); + + expect(popover.matches(':popover-open')).toBe(false); + }); +}); diff --git a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts index 0cc28aa8d5e9..dcca81f60f08 100644 --- a/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts +++ b/frontend/src/app/features/calendar/te-calendar/te-calendar.component.ts @@ -71,7 +71,12 @@ import { TurboRequestsService } from 'core-app/core/turbo/turbo-requests.service import { PathHelperService } from 'core-app/core/path-helper/path-helper.service'; import { ensureId, generateId } from 'core-app/shared/helpers/dom-helpers'; import { target } from 'core-app/shared/helpers/event-helpers'; -import { html, render } from 'lit-html'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { placePopover } from 'core-app/shared/components/anchored-popover/popover-placement'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; +import { timeEntryPopoverHtml } from './te-calendar-popover'; +import type { PopoverRow } from './te-calendar-popover'; +import { render } from 'lit-html'; import { DialogCloseDetail } from 'core-turbo/dialog-stream-action'; interface TimeEntrySchema extends SchemaResource { @@ -185,6 +190,10 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { private closeDialogHandler:EventListener = this.handleDialogClose.bind(this); + private closeOpenPopover = () => { + this.element.nativeElement.querySelector('.te-calendar--popover:popover-open')?.hidePopover(); + }; + public additionalOptions:CalendarOptionsWithDayGrid = { editable: false, locales: allLocales, @@ -238,10 +247,12 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { ngAfterViewInit():void { document.addEventListener('dialog:close', this.closeDialogHandler); + window.addEventListener('resize', this.closeOpenPopover); } ngOnDestroy():void { document.removeEventListener('dialog:close', this.closeDialogHandler); + window.removeEventListener('resize', this.closeOpenPopover); } async requireNonWorkingDays(start:Date | string, end:Date | string) { @@ -556,15 +567,19 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { anchorEl.role = 'button'; const popoverId = generateId('popover'); - const popoverHtml = this.popoverHtml(popoverId, anchorId, event.event.extendedProps.entry as TimeEntryResource, schema); - - render(popoverHtml, anchorEl); + const rows = this.popoverRows(entry as TimeEntryResource, schema); + const draw = (caret:CaretPlacement|null) => render(timeEntryPopoverHtml(popoverId, anchorId, rows, caret), anchorEl); + draw(null); anchorEl.setAttribute('aria-haspopup', 'true'); anchorEl.setAttribute('popovertarget', popoverId); - const popoverEl = document.getElementById(popoverId)!; - const showPopover = () => { popoverEl.showPopover(); }; + const popoverEl = document.getElementById(popoverId) as AnchoredPositionElement; + const showPopover = () => { + if (popoverEl.matches(':popover-open')) return; + popoverEl.showPopover(); + draw(placePopover(popoverEl, anchorEl)); + }; const hidePopover = () => { popoverEl.hidePopover(); }; target(anchorEl).on('mouseenter.anchor', showPopover); @@ -650,54 +665,14 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy { return formatTimeEntryEntityName(entry.entity); } - private popoverHtml( - popoverId:string, - anchorId:string, - entry:TimeEntryResource, - schema:TimeEntrySchema) { - return html` - - ${this.popoverContentHtml(entry, schema)} - - `; - } - - private popoverContentHtml(entry:TimeEntryResource, schema:TimeEntrySchema) { - return html` -
-
-
    -
  • - ${schema.project.name}: - ${this.sanitizedValue(entry.project.name)} -
  • -
  • - ${schema.entity.name}: - ${entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default')} -
  • -
  • - ${schema.activity.name}: - ${this.sanitizedValue(entry.activity?.name ?? '')} -
  • -
  • - ${schema.hours.name}: - ${this.timezone.formattedDuration(entry.hours as string)} -
  • -
  • - ${schema.comment.name}: - ${this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default'))} -
  • -
-
-
- `; + private popoverRows(entry:TimeEntryResource, schema:TimeEntrySchema):PopoverRow[] { + return [ + { label: schema.project.name, value: this.sanitizedValue(entry.project.name) }, + { label: schema.entity.name, value: entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default') }, + { label: schema.activity.name, value: this.sanitizedValue(entry.activity?.name ?? '') }, + { label: schema.hours.name, value: this.timezone.formattedDuration(entry.hours as string) }, + { label: schema.comment.name, value: this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default')) }, + ]; } private sanitizedValue(value:string):string { diff --git a/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass new file mode 100644 index 000000000000..7424b4aaf83d --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/anchored-popover.sass @@ -0,0 +1,20 @@ +anchored-position.op-anchored-popover--host + margin: 0 + padding: 0 + border: 0 + background: none + text-align: start + +.op-anchored-popover + color: var(--fgColor-default) + + &:not(.Popover-message--left):not(.Popover-message--right) + &::before, + &::after + left: var(--op-anchored-popover-caret-offset, 50%) + + &.Popover-message--left, + &.Popover-message--right + &::before, + &::after + top: var(--op-anchored-popover-caret-offset, 50%) diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts similarity index 97% rename from frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts rename to frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts index a7a767cc9c18..51df66cf02df 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.spec.ts +++ b/frontend/src/app/shared/components/anchored-popover/caret-placement.spec.ts @@ -26,7 +26,7 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { caretPlacement } from './project-timeline-tooltip-caret'; +import { caretPlacement } from './caret-placement'; describe('caretPlacement', () => { const rect = (left:number, top:number, width:number, height:number) => new DOMRect(left, top, width, height); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts b/frontend/src/app/shared/components/anchored-popover/caret-placement.ts similarity index 100% rename from frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip-caret.ts rename to frontend/src/app/shared/components/anchored-popover/caret-placement.ts diff --git a/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts b/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts new file mode 100644 index 000000000000..e105712000c0 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-message.spec.ts @@ -0,0 +1,76 @@ +//-- 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 { render } from 'lit-html'; +import { within } from '@testing-library/dom'; +import { popoverMessage } from './popover-message'; + +describe('popoverMessage', () => { + let host:HTMLElement; + + beforeEach(() => { + host = document.createElement('div'); + }); + + const message = () => host.querySelector('.Popover-message')!; + + it('renders the content inside a Primer popover message', () => { + render(popoverMessage('Launch'), host); + expect(within(host).getByText('Launch')).toHaveClass('Popover-message', 'op-anchored-popover'); + }); + + it('renders an element as content', () => { + const el = document.createElement('strong'); + el.textContent = 'Milestone'; + render(popoverMessage(el), host); + expect(within(host).getByText('Milestone')).toBe(el); + expect(el.parentElement).toBe(message()); + }); + + it('uses the default (top) caret when no placement is known', () => { + render(popoverMessage('x'), host); + expect(message()).toHaveClass('Popover-message op-anchored-popover', { exact: true }); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe(''); + }); + + it.each([ + ['bottom', 'Popover-message--bottom'], + ['left', 'Popover-message--left'], + ['right', 'Popover-message--right'], + ] as const)('sets the %s caret class and offset', (side, className) => { + render(popoverMessage('x', { side, offset: 42 }), host); + expect(message()).toHaveClass(className); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('42px'); + }); + + it('drops the modifier again when re-rendered with a top caret', () => { + render(popoverMessage('x', { side: 'bottom', offset: 10 }), host); + render(popoverMessage('x', { side: 'top', offset: 10 }), host); + expect(message()).not.toHaveClass('Popover-message--bottom'); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/popover-message.ts b/frontend/src/app/shared/components/anchored-popover/popover-message.ts new file mode 100644 index 000000000000..6c23c4336a6d --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-message.ts @@ -0,0 +1,49 @@ +//-- 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 { html } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import { classMap } from 'lit-html/directives/class-map.js'; +import { styleMap } from 'lit-html/directives/style-map.js'; +import type { CaretPlacement } from './caret-placement'; + +export function popoverMessage(content:unknown, caret?:CaretPlacement|null):TemplateResult { + return html` +
+ ${content} +
+ `; +} diff --git a/frontend/src/app/shared/components/anchored-popover/popover-placement.spec.ts b/frontend/src/app/shared/components/anchored-popover/popover-placement.spec.ts new file mode 100644 index 000000000000..7a6650ecc642 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-placement.spec.ts @@ -0,0 +1,105 @@ +//-- 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 '@openproject/primer-view-components/app/components/primer/anchored_position'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { placePopover } from './popover-placement'; + +describe('placePopover', () => { + let popover:AnchoredPositionElement; + let anchor:HTMLElement; + + beforeEach(() => { + anchor = document.createElement('div'); + anchor.style.cssText = 'position: fixed; top: 200px; left: 200px; width: 10px; height: 10px;'; + popover = document.createElement('anchored-position') as AnchoredPositionElement; + popover.setAttribute('popover', 'manual'); + popover.setAttribute('side', 'outside-top'); + popover.setAttribute('align', 'center'); + popover.setAttribute('anchor-offset', 'spacious'); + popover.style.cssText = 'margin: 0; padding: 0; border: 0;'; + popover.innerHTML = '
'; + document.body.append(anchor, popover); + popover.anchorElement = anchor; + popover.togglePopover(true); + }); + + afterEach(() => { + popover.remove(); + anchor.remove(); + document.body.style.minHeight = ''; + window.scrollTo(0, 0); + }); + + const centred = () => popover.getBoundingClientRect().width / 2; + + it('places the caret on the edge facing the anchor as soon as the popover is open', () => { + expect(placePopover(popover, anchor)).toEqual({ side: 'bottom', offset: centred() }); + }); + + it('moves the popover into place without waiting for a frame', () => { + anchor.style.left = '300px'; + placePopover(popover, anchor); + + const box = popover.getBoundingClientRect(); + expect(box.left + box.width / 2).toBeCloseTo(305, 0); + expect(box.bottom).toBeLessThanOrEqual(200); + }); + + it('follows the flip when the anchor is too close to the viewport edge', () => { + anchor.style.top = '0px'; + expect(placePopover(popover, anchor).side).toBe('top'); + }); + + it('accepts a rectangle as the anchor', () => { + expect(placePopover(popover, new DOMRect(200, 200, 10, 10))).toEqual({ side: 'bottom', offset: centred() }); + }); + + it('agrees with where anchored-position puts the popover', async () => { + const caret = placePopover(popover, anchor); + await new Promise(requestAnimationFrame); + + const box = popover.getBoundingClientRect(); + expect(box.bottom).toBeLessThanOrEqual(200); + expect(caret.offset).toBeCloseTo(205 - box.left, 0); + }); + + it('faces the anchor on a scrolled page', async () => { + document.body.style.minHeight = '3000px'; + window.scrollTo(0, 1000); + anchor.style.cssText = 'position: absolute; top: 1200px; left: 200px; width: 10px; height: 10px;'; + + const caret = placePopover(popover, anchor); + expect(caret.side).toBe('bottom'); + + await new Promise(requestAnimationFrame); + const box = popover.getBoundingClientRect(); + expect(box.bottom).toBeLessThanOrEqual(anchor.getBoundingClientRect().top); + expect(caret.offset).toBeCloseTo(205 - box.left, 0); + }); +}); diff --git a/frontend/src/app/shared/components/anchored-popover/popover-placement.ts b/frontend/src/app/shared/components/anchored-popover/popover-placement.ts new file mode 100644 index 000000000000..cd8ff0470880 --- /dev/null +++ b/frontend/src/app/shared/components/anchored-popover/popover-placement.ts @@ -0,0 +1,46 @@ +//-- 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 { getAnchoredPosition } from '@primer/behaviors'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { caretPlacement } from './caret-placement'; +import type { CaretPlacement } from './caret-placement'; + +// `anchored-position` positions its popover in a later animation frame and +// does not expose which side it settled on. Applying Primer's result right +// away pre-empts that frame and yields the box the caret is read from. +export function placePopover(popover:AnchoredPositionElement, anchor:Element|DOMRect):CaretPlacement { + const { left, top } = getAnchoredPosition(popover, anchor, popover); + popover.style.top = `${top}px`; + popover.style.left = `${left}px`; + popover.style.bottom = 'auto'; + popover.style.right = 'auto'; + + const anchorRect = anchor instanceof DOMRect ? anchor : anchor.getBoundingClientRect(); + return caretPlacement(popover.getBoundingClientRect(), anchorRect); +} diff --git a/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass new file mode 100644 index 000000000000..21643abddbcb --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/budget-graphs.sass @@ -0,0 +1,6 @@ +.op-chart-tooltip + pointer-events: none + + .Popover-message + padding: var(--base-size-8) + font-size: var(--text-body-size-small) diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts new file mode 100644 index 000000000000..8b152c811edf --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.spec.ts @@ -0,0 +1,175 @@ +//-- 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 '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { within } from '@testing-library/dom'; +import { createBarTooltipRenderer, createPieTooltipRenderer } from './chart.config'; +import type { BarTooltipContext } from './chart.config'; + +describe('chart tooltip renderer', () => { + let wrapper:HTMLElement; + let host:HTMLElement; + let canvas:HTMLCanvasElement; + let renderers:{ destroy():void }[]; + + const nextFrame = () => new Promise(requestAnimationFrame); + const createRenderer = (formatCurrency:(v:number) => string) => { + const renderer = createPieTooltipRenderer(host, formatCurrency); + renderers.push(renderer); + return renderer; + }; + + beforeEach(() => { + renderers = []; + wrapper = document.createElement('div'); + wrapper.style.cssText = 'position: relative; top: 40px; left: 30px;'; + host = document.createElement('div'); + wrapper.append(host); + canvas = document.createElement('canvas'); + canvas.style.cssText = 'position: fixed; top: 100px; left: 100px; width: 300px; height: 200px;'; + document.body.append(canvas, wrapper); + }); + + afterEach(() => { + renderers.forEach((renderer) => renderer.destroy()); + wrapper.remove(); + canvas.remove(); + }); + + const context = (opacity:number, caretX = 50) => ({ + chart: { canvas }, + tooltip: { + opacity, + caretX, + caretY: 40, + dataPoints: [{ label: 'Labour', parsed: 1234 }], + labelColors: [{ backgroundColor: '#123456' }], + }, + }) as unknown as Parameters>[0]; + + const popover = () => host.querySelector('anchored-position')!; + const message = () => host.querySelector('.Popover-message')!; + + it('opens a popover beside the caret point with the formatted value', async () => { + const renderTooltip = createRenderer((v) => `€${v}`); + renderTooltip(context(1)); + + expect(popover().matches(':popover-open')).toBe(true); + expect(within(host).getByText('Labour')).toBeInTheDocument(); + expect(within(host).getByText('€1234')).toBeInTheDocument(); + + await nextFrame(); + expect(popover().getBoundingClientRect().left).toBeGreaterThanOrEqual(158); + }); + + it('points the caret at the point as soon as it opens', () => { + createRenderer((v) => `${v}`)(context(1)); + + expect(message()).toHaveClass('Popover-message--left'); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).not.toBe(''); + }); + + // Chart.js drives the tooltip from its own animation frame, so the frame + // anchored-position schedules on reopen only runs after the next paint. + it('reopens beside the new point before anchored-position gets its frame', async () => { + const renderTooltip = createRenderer((v) => `${v}`); + renderTooltip(context(1)); + await nextFrame(); + renderTooltip(context(0)); + await nextFrame(); + + const right = await new Promise((resolve) => { + requestAnimationFrame(() => { + renderTooltip(context(1, 250)); + resolve(popover().getBoundingClientRect().right); + }); + }); + expect(right).toBeCloseTo(100 + 250 - 8, 0); + }); + + it('closes the popover when the tooltip fades out', () => { + const renderTooltip = createRenderer((v) => `${v}`); + renderTooltip(context(1)); + renderTooltip(context(0)); + + expect(popover().matches(':popover-open')).toBe(false); + }); + + const viewportChanges = [ + ['the page scrolls', () => document.dispatchEvent(new Event('scroll'))], + ['the window is resized', () => window.dispatchEvent(new Event('resize'))], + ] as const; + + it.each(viewportChanges)('closes the popover when %s', (_, change) => { + createRenderer((v) => `${v}`)(context(1)); + change(); + + expect(popover().matches(':popover-open')).toBe(false); + }); + + it('centres a bar tooltip on the hovered segment', () => { + const renderTooltip = createBarTooltipRenderer(host, (v) => `${v}`); + renderers.push(renderTooltip); + renderTooltip({ + chart: { canvas }, + tooltip: { + opacity: 1, + caretX: 50, + caretY: 20, + dataPoints: [{ + parsed: { x: 0, y: 10 }, + dataset: { label: 'Labour' }, + element: { getProps: () => ({ x: 50, y: 20, base: 120, width: 30 }) }, + }], + labelColors: [{ backgroundColor: '#123456' }], + }, + } as unknown as BarTooltipContext); + + const box = popover().getBoundingClientRect(); + expect(box.left).toBeCloseTo(100 + 65 + 8, 0); + expect(box.top + box.height / 2).toBeCloseTo(100 + 70, 0); + expect(message()).toHaveClass('Popover-message--left'); + expect(message().style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe(`${box.height / 2}px`); + }); + + it('never renders into document.body', () => { + createRenderer((v) => `${v}`)(context(1)); + expect(document.body.querySelector(':scope > anchored-position')).toBeNull(); + }); + + it.each(viewportChanges)('stops listening once destroyed when %s', (_, change) => { + const renderTooltip = createRenderer((v) => `${v}`); + renderTooltip(context(1)); + renderTooltip.destroy(); + expect(popover().matches(':popover-open')).toBe(false); + + renderTooltip(context(1)); + change(); + expect(popover().matches(':popover-open')).toBe(true); + }); +}); diff --git a/frontend/src/app/shared/components/budget-graphs/chart.config.ts b/frontend/src/app/shared/components/budget-graphs/chart.config.ts index 8b2438f7238a..e5302d586ecf 100644 --- a/frontend/src/app/shared/components/budget-graphs/chart.config.ts +++ b/frontend/src/app/shared/components/budget-graphs/chart.config.ts @@ -26,8 +26,13 @@ // See COPYRIGHT and LICENSE files for more details. //++ -import { ChartOptions, TooltipModel } from 'chart.js'; +import { BarElement, ChartOptions, TooltipModel } from 'chart.js'; import { html, render } from 'lit-html'; +import type { TemplateResult } from 'lit-html'; +import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import { placePopover } from 'core-app/shared/components/anchored-popover/popover-placement'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; export const chartFont:ChartOptions['font'] = { family: @@ -54,49 +59,101 @@ interface TooltipContext { tooltip:TooltipModel; } -function applyTooltipPosition( - context:TooltipContext, - popoverHtml:ReturnType, - tooltipId:string, -) { - render(popoverHtml, document.body); +export type BarTooltipContext = TooltipContext<'bar'>; +export type PieTooltipContext = TooltipContext<'pie'>; - const tooltipEl = document.getElementById(tooltipId)!; +class ChartTooltip { + private anchor:DOMRect|null = null; + private caret:CaretPlacement|null = null; + private items:TemplateResult[] = []; + private readonly close = () => { + const popover = this.popover; + if (popover?.isConnected) popover.togglePopover(false); + }; - if (context.tooltip.opacity === 0) { - tooltipEl.style.opacity = '0'; - return; + constructor(private readonly host:HTMLElement) { + this.draw(); + document.addEventListener('scroll', this.close, { capture: true, passive: true }); + window.addEventListener('resize', this.close); } - const { left, top } = context.chart.canvas.getBoundingClientRect(); - const x = Math.round(left + context.tooltip.caretX); - const y = Math.round(top + context.tooltip.caretY); - - const wasHidden = !tooltipEl.style.opacity || tooltipEl.style.opacity === '0'; - if (wasHidden) { - // Snap to position before fading in (avoids sliding from initial 0,0) - tooltipEl.style.transition = 'none'; - tooltipEl.style.transform = `translate(${x}px, ${y}px)`; - void tooltipEl.offsetHeight; // force reflow so transform is committed - tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; - } else { - tooltipEl.style.transition = 'transform 0.1s ease, opacity 0.15s ease'; - tooltipEl.style.transform = `translate(${x}px, ${y}px)`; + destroy():void { + document.removeEventListener('scroll', this.close, { capture: true }); + window.removeEventListener('resize', this.close); + this.close(); } - tooltipEl.style.opacity = '1'; + update(context:TooltipContext, items:TemplateResult[], anchor = caretPoint(context)):void { + if (context.tooltip.opacity === 0) { + this.close(); + return; + } + + const moved = anchor.x !== this.anchor?.x || anchor.y !== this.anchor?.y || !this.popover?.matches(':popover-open'); + + this.anchor = anchor; + this.items = items; + this.draw(); + + const popover = this.popover; + if (!moved || !popover?.isConnected) return; + + // Primer's getAnchoredPosition accepts `Element|DOMRect`; the element's + // setter only narrows the type. + popover.anchorElement = anchor as unknown as HTMLElement; + popover.togglePopover(true); + popover.update(); + this.caret = placePopover(popover, anchor); + this.draw(); + } + + private get popover():AnchoredPositionElement|null { + return this.host.querySelector('anchored-position'); + } + + private draw():void { + render( + html` + + ${popoverMessage(html`
    ${this.items}
`, this.caret)} +
+ `, + this.host, + ); + } +} + +function caretPoint(context:TooltipContext):DOMRect { + const { left, top } = context.chart.canvas.getBoundingClientRect(); + return new DOMRect(Math.round(left + context.tooltip.caretX), Math.round(top + context.tooltip.caretY), 0, 0); +} + +// Chart.js anchors a bar tooltip on the top edge of the hovered segment; the +// segment's box lets anchored-position centre the popover on it instead. +function barBox(context:TooltipContext<'bar'>):DOMRect { + const bar = context.tooltip.dataPoints[0]?.element as BarElement|undefined; + const { x, y, base, width } = bar?.getProps(['x', 'y', 'base', 'width']) ?? {}; + if (x == null || y == null || base == null || width == null) return caretPoint(context); + + const { left, top } = context.chart.canvas.getBoundingClientRect(); + return new DOMRect( + Math.round(left + x - width / 2), + Math.round(top + Math.min(y, base)), + Math.round(width), + Math.round(Math.abs(base - y)), + ); } function renderColorDot(color:string) { return html``; } -function renderTooltipItem( - color:string, - label:string, - formattedValue:string, - dateStr?:string, -):ReturnType { +function renderTooltipItem(color:string, label:string, formattedValue:string, dateStr?:string):TemplateResult { const header = dateStr ? html`
${dateStr}${renderColorDot(color)}${label}
` : html`
${renderColorDot(color)}${label}
`; @@ -107,43 +164,30 @@ function renderTooltipItem( `; } -function renderTooltipPopover(tooltipId:string, items:ReturnType[]):ReturnType { - return html` -
-
-
    - ${items} -
-
-
`; -} - -export function createBarTooltipRenderer(formatCurrency:FormatCurrency) { - return function(context:TooltipContext<'bar'>) { - const { tooltip } = context; - const items = tooltip.dataPoints.map((dp, i) => { +export function createBarTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); + const renderer = (context:TooltipContext<'bar'>) => { + const items = context.tooltip.dataPoints.map((dp, i) => { const timestamp = dp.parsed.x; const dateStr = timestamp != null ? new Date(timestamp).toLocaleDateString(undefined, { month: 'short', year: 'numeric' }) : undefined; - const label = dp.dataset.label ?? ''; - const value = dp.parsed.y ?? 0; - const color = tooltip.labelColors[i]?.backgroundColor as string; - return renderTooltipItem(color, label, formatCurrency(value), dateStr); + const color = context.tooltip.labelColors[i]?.backgroundColor as string; + return renderTooltipItem(color, dp.dataset.label ?? '', formatCurrency(dp.parsed.y ?? 0), dateStr); }); - applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-bar', items), 'chartjs-tooltip-bar'); + tooltip.update(context, items, barBox(context)); }; + return Object.assign(renderer, { destroy: () => tooltip.destroy() }); } -export function createPieTooltipRenderer(formatCurrency:FormatCurrency) { - return function(context:TooltipContext<'pie'>) { - const { tooltip } = context; - const items = tooltip.dataPoints.map((dp, i) => { - const color = tooltip.labelColors[i]?.backgroundColor as string; - const label = dp.label ?? ''; - const value = dp.parsed; - return renderTooltipItem(color, label, formatCurrency(value)); +export function createPieTooltipRenderer(host:HTMLElement, formatCurrency:FormatCurrency) { + const tooltip = new ChartTooltip(host); + const renderer = (context:TooltipContext<'pie'>) => { + const items = context.tooltip.dataPoints.map((dp, i) => { + const color = context.tooltip.labelColors[i]?.backgroundColor as string; + return renderTooltipItem(color, dp.label ?? '', formatCurrency(dp.parsed)); }); - applyTooltipPosition(context, renderTooltipPopover('chartjs-tooltip-pie', items), 'chartjs-tooltip-pie'); + tooltip.update(context, items); }; + return Object.assign(renderer, { destroy: () => tooltip.destroy() }); } diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html index a0b5df90e82b..29db7f9f41f4 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.html @@ -1,5 +1,6 @@ @if (hasChartData()) {
+
} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.spec.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.spec.ts new file mode 100644 index 000000000000..30dc084aa62c --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.spec.ts @@ -0,0 +1,108 @@ +//-- 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 '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { within } from '@testing-library/dom'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { ActualCostsComponent } from './actual-costs.component'; +import type { BarTooltipContext } from '../chart.config'; + +describe('ActualCostsComponent', () => { + let fixture:ComponentFixture; + let element:HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [ActualCostsComponent], + providers: [ + { provide: I18nService, useValue: {} }, + provideCharts(withDefaultRegisterables(PrimerColorsPlugin)), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(ActualCostsComponent); + element = fixture.nativeElement as HTMLElement; + }); + + const renderWith = (datasets:unknown[]) => { + fixture.componentRef.setInput('chartData', JSON.stringify({ labels: ['Labour'], datasets })); + fixture.detectChanges(); + }; + + const tooltipContext = (opacity:number):BarTooltipContext => ({ + chart: { canvas: element.querySelector('canvas')! }, + tooltip: { + opacity, + caretX: 50, + caretY: 40, + dataPoints: [{ parsed: { x: 0, y: 10 }, dataset: { label: 'Labour' }, element: { getProps: () => ({ x: 50, y: 20, base: 120, width: 30 }) } }], + labelColors: [{ backgroundColor: '#123456' }], + }, + } as unknown as BarTooltipContext); + + it('renders the chart with a tooltip host beside the canvas', () => { + renderWith([{ label: 'Labour', data: [10] }]); + + const canvas = element.querySelector('canvas')!; + expect(canvas).not.toBeNull(); + expect(canvas.nextElementSibling?.tagName).toBe('DIV'); + }); + + it('renders nothing without data', () => { + renderWith([]); + expect(element.querySelector('canvas')).toBeNull(); + }); + + it('drops the tooltip renderer together with its host', () => { + renderWith([{ label: 'Labour', data: [10] }]); + const removeListener = vi.spyOn(document, 'removeEventListener'); + + renderWith([]); + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.anything()); + + removeListener.mockClear(); + renderWith([{ label: 'Labour', data: [10] }]); + fixture.destroy(); + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.anything()); + }); + + it('opens the tooltip inside its host from the Chart.js callback', () => { + renderWith([{ label: 'Labour', data: [10] }]); + const external = fixture.componentInstance.barChartOptions()!.plugins!.tooltip!.external as (context:BarTooltipContext) => void; + + external(tooltipContext(1)); + + const popover = element.querySelector('anchored-position')!; + expect(popover.matches(':popover-open')).toBe(true); + expect(within(popover).getByText('Labour')).toBeInTheDocument(); + expect(document.body.querySelector(':scope > anchored-position')).toBeNull(); + }); +}); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts index f6129e60ebff..306c14e0ef35 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/actual-costs.component.ts @@ -29,15 +29,19 @@ import { ChangeDetectionStrategy, Component, + ElementRef, Signal, computed, + effect, inject, input, + viewChild, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import 'chartjs-adapter-luxon'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { chartFont, chartLegend, createBarTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; +import type { BarTooltipContext } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @@ -50,6 +54,23 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 }) export class ActualCostsComponent { private readonly i18n = inject(I18nService); + private readonly tooltipHost = viewChild>('tooltipHost'); + + private renderer:ReturnType|null = null; + + constructor() { + effect((onCleanup) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + + const renderer = createBarTooltipRenderer(host, this.formatCurrency.bind(this)); + this.renderer = renderer; + onCleanup(() => { + renderer.destroy(); + this.renderer = null; + }); + }); + } readonly chartData = input.required(); readonly currency = input('€'); @@ -80,11 +101,13 @@ export class ActualCostsComponent { 'primer-colors': { datasetLabelBased: true }, tooltip: { enabled: false, - external: createBarTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + private readonly tooltipRenderer = (context:BarTooltipContext) => this.renderer?.(context); + private formatCurrencyCompact(value:number):string { const currency = this.currency(); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html index 2fa897c2fdb6..228700ad80c0 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.html @@ -1,5 +1,6 @@ @if (hasChartData()) {
+
} diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.spec.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.spec.ts new file mode 100644 index 000000000000..092e346e769e --- /dev/null +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.spec.ts @@ -0,0 +1,108 @@ +//-- 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 '@openproject/primer-view-components/app/components/primer/anchored_position'; +import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { provideCharts, withDefaultRegisterables } from 'ng2-charts'; +import { within } from '@testing-library/dom'; +import { I18nService } from 'core-app/core/i18n/i18n.service'; +import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; +import { BudgetByCostTypeComponent } from './budget-by-cost-type.component'; +import type { PieTooltipContext } from '../chart.config'; + +describe('BudgetByCostTypeComponent', () => { + let fixture:ComponentFixture; + let element:HTMLElement; + + beforeEach(async () => { + await TestBed.configureTestingModule({ + imports: [BudgetByCostTypeComponent], + providers: [ + { provide: I18nService, useValue: {} }, + provideCharts(withDefaultRegisterables(PrimerColorsPlugin)), + ], + }).compileComponents(); + + fixture = TestBed.createComponent(BudgetByCostTypeComponent); + element = fixture.nativeElement as HTMLElement; + }); + + const renderWith = (datasets:unknown[]) => { + fixture.componentRef.setInput('chartData', JSON.stringify({ labels: ['Labour'], datasets })); + fixture.detectChanges(); + }; + + const tooltipContext = (opacity:number):PieTooltipContext => ({ + chart: { canvas: element.querySelector('canvas')! }, + tooltip: { + opacity, + caretX: 50, + caretY: 40, + dataPoints: [{ label: 'Labour', parsed: 10 }], + labelColors: [{ backgroundColor: '#123456' }], + }, + } as unknown as PieTooltipContext); + + it('renders the chart with a tooltip host beside the canvas', () => { + renderWith([{ data: [10] }]); + + const canvas = element.querySelector('canvas')!; + expect(canvas).not.toBeNull(); + expect(canvas.nextElementSibling?.tagName).toBe('DIV'); + }); + + it('renders nothing without data', () => { + renderWith([{ data: [] }]); + expect(element.querySelector('canvas')).toBeNull(); + }); + + it('drops the tooltip renderer together with its host', () => { + renderWith([{ data: [10] }]); + const removeListener = vi.spyOn(document, 'removeEventListener'); + + renderWith([{ data: [] }]); + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.anything()); + + removeListener.mockClear(); + renderWith([{ data: [10] }]); + fixture.destroy(); + expect(removeListener).toHaveBeenCalledWith('scroll', expect.any(Function), expect.anything()); + }); + + it('opens the tooltip inside its host from the Chart.js callback', () => { + renderWith([{ data: [10] }]); + const external = fixture.componentInstance.pieChartOptions()!.plugins!.tooltip!.external as (context:PieTooltipContext) => void; + + external(tooltipContext(1)); + + const popover = element.querySelector('anchored-position')!; + expect(popover.matches(':popover-open')).toBe(true); + expect(within(popover).getByText('Labour')).toBeInTheDocument(); + expect(document.body.querySelector(':scope > anchored-position')).toBeNull(); + }); +}); diff --git a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts index 538358eed7cb..e4334c110f0b 100644 --- a/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts +++ b/frontend/src/app/shared/components/budget-graphs/overview/budget-by-cost-type.component.ts @@ -29,14 +29,18 @@ import { ChangeDetectionStrategy, Component, + ElementRef, Signal, computed, + effect, inject, input, + viewChild, } from '@angular/core'; import { ChartConfiguration, ChartData } from 'chart.js'; import { I18nService } from 'core-app/core/i18n/i18n.service'; import { chartFont, chartLegend, createPieTooltipRenderer } from 'core-app/shared/components/budget-graphs/chart.config'; +import type { PieTooltipContext } from 'core-app/shared/components/budget-graphs/chart.config'; import PrimerColorsPlugin from 'core-app/shared/components/work-package-graphs/plugin.primer-colors'; import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2-charts'; @@ -49,6 +53,23 @@ import { BaseChartDirective, provideCharts, withDefaultRegisterables } from 'ng2 }) export class BudgetByCostTypeComponent { private readonly i18n = inject(I18nService); + private readonly tooltipHost = viewChild>('tooltipHost'); + + private renderer:ReturnType|null = null; + + constructor() { + effect((onCleanup) => { + const host = this.tooltipHost()?.nativeElement; + if (!host) return; + + const renderer = createPieTooltipRenderer(host, this.formatCurrency.bind(this)); + this.renderer = renderer; + onCleanup(() => { + renderer.destroy(); + this.renderer = null; + }); + }); + } readonly chartData = input.required(); readonly currency = input('€'); @@ -63,11 +84,13 @@ export class BudgetByCostTypeComponent { 'primer-colors': { labelBased: true }, tooltip: { enabled: false, - external: createPieTooltipRenderer(this.formatCurrency.bind(this)), + external: this.tooltipRenderer, }, }, })); + private readonly tooltipRenderer = (context:PieTooltipContext) => this.renderer?.(context); + private formatCurrency(value:number):string { const currency = this.currency(); try { diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass index 4f89253d7f3a..753e35a89d97 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.sass @@ -31,30 +31,13 @@ // Tooltip .op-project-timeline-graph--tooltip pointer-events: none - margin: 0 - padding: 0 - border: 0 - background: none - text-align: start .Popover-message width: auto max-width: 350px padding: var(--base-size-12) - color: var(--fgColor-default) overflow-wrap: anywhere - &:not(.Popover-message--left):not(.Popover-message--right) - &::before, - &::after - left: var(--op-timeline-tooltip-caret-offset, 50%) - - &.Popover-message--left, - &.Popover-message--right - &::before, - &::after - top: var(--op-timeline-tooltip-caret-offset, 50%) - .op-timeline-tooltip--meta-row display: inline-flex align-items: center diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts index 9078dd914c19..e067c023eaa6 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-graph.component.spec.ts @@ -550,8 +550,8 @@ describe('ProjectTimelineGraphComponent', () => { it('renders no caret side until the placement is known', () => { const { message } = renderView({ content: 'Launch' }); - expect(Array.from(message.classList)).toEqual(['Popover-message']); - expect(message.style.getPropertyValue('--op-timeline-tooltip-caret-offset')).toBe(''); + expect(Array.from(message.classList)).toEqual(['Popover-message', 'op-anchored-popover']); + expect(message.style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe(''); }); it('turns the caret to face the anchor at the given offset', () => { @@ -559,7 +559,7 @@ describe('ProjectTimelineGraphComponent', () => { expect(message.classList.contains('Popover-message--left')).toBe(true); expect(message.classList.contains('Popover-message--bottom')).toBe(false); - expect(message.style.getPropertyValue('--op-timeline-tooltip-caret-offset')).toBe('30px'); + expect(message.style.getPropertyValue('--op-anchored-popover-caret-offset')).toBe('30px'); }); it('drops the sideways caret when the popover moves back above the anchor', () => { @@ -662,8 +662,8 @@ describe('ProjectTimelineGraphComponent', () => { let element:HTMLElement; - // Only the hover delay is faked; the caret waits for a real animation frame. const fakeHoverDelay = () => vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] }); + const nextFrame = () => new Promise(requestAnimationFrame); const popover = () => element.querySelector('.op-project-timeline-graph--tooltip')!; const message = () => popover().querySelector('.Popover-message')!; @@ -680,17 +680,19 @@ describe('ProjectTimelineGraphComponent', () => { fixture.detectChanges(); return element.querySelector(selector) !== null; }); + await nextFrame(); return element.querySelector(selector)!; }; - const caretOffsetValue = () => message().style.getPropertyValue('--op-timeline-tooltip-caret-offset'); + const caretOffsetValue = () => message().style.getPropertyValue('--op-anchored-popover-caret-offset'); - const openTooltip = async (item:Element) => { - fakeHoverDelay(); + // anchored-position places the popover in the frame after it opens. + const openTooltip = (item:Element) => { + vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout', 'requestAnimationFrame', 'cancelAnimationFrame'] }); hover('mouseover', item); vi.advanceTimersByTime(500); + vi.advanceTimersToNextFrame(); vi.useRealTimers(); - await vi.waitUntil(() => caretOffsetValue() !== ''); }; const caretOffset = () => parseFloat(caretOffsetValue()); @@ -759,8 +761,8 @@ describe('ProjectTimelineGraphComponent', () => { expect(popover()).toBe(before); }); - it('points the caret at the diamond from the side facing it', async () => { - await openTooltip(milestoneItem); + it('points the caret at the diamond from the side facing it', () => { + openTooltip(milestoneItem); const diamond = milestoneItem.querySelector('.vis-dot')!.getBoundingClientRect(); const box = popover().getBoundingClientRect(); @@ -772,16 +774,16 @@ describe('ProjectTimelineGraphComponent', () => { expect(message().classList.contains('Popover-message--bottom')).toBe(popoverIsAbove); }); - it('closes the tooltip when the page scrolls', async () => { - await openTooltip(milestoneItem); + it('closes the tooltip when the page scrolls', () => { + openTooltip(milestoneItem); expect(isOpen()).toBe(true); document.dispatchEvent(new Event('scroll')); expect(isOpen()).toBe(false); }); - it('closes the tooltip when the window is resized', async () => { - await openTooltip(milestoneItem); + it('closes the tooltip when the window is resized', () => { + openTooltip(milestoneItem); expect(isOpen()).toBe(true); window.dispatchEvent(new Event('resize')); @@ -813,7 +815,7 @@ describe('ProjectTimelineGraphComponent', () => { it('keeps a long milestone name inside the viewport', async () => { const longName = 'really long milestone '.repeat(12).trim(); const item = await renderItems('.vis-item.vis-point', { milestonesData: [{ ...milestone, subject: longName }] }); - await openTooltip(item); + openTooltip(item); const box = popover().getBoundingClientRect(); expect(box.left).toBeGreaterThanOrEqual(0); @@ -823,7 +825,7 @@ describe('ProjectTimelineGraphComponent', () => { it('anchors a phase bar on the bar itself', async () => { const bar = await renderItems('.vis-item.vis-range', { phasesData: [phaseWithDates] }); - await openTooltip(bar); + openTooltip(bar); expect(caretOffset()).toBeCloseTo(expectedCaretOffset(bar.getBoundingClientRect()), 0); expect(popover().textContent).toContain('Design'); @@ -831,7 +833,7 @@ describe('ProjectTimelineGraphComponent', () => { it('anchors a gate on its visible icon rather than the hidden dot', async () => { const gate = await renderItems('.vis-item.vis-point.op-timeline-gate', { phasesData: [phaseWithGates] }); - await openTooltip(gate); + openTooltip(gate); const icon = gate.getBoundingClientRect(); expect(icon.width).toBeGreaterThan(0); @@ -842,7 +844,7 @@ describe('ProjectTimelineGraphComponent', () => { it('shows every gate of a cluster', async () => { const secondPhase = { ...phaseWithGates, id: 3, name: 'Test', startGateName: 'Test Start', finishGate: false }; const cluster = await renderItems('.vis-item.vis-cluster', { phasesData: [phaseWithGates, secondPhase] }); - await openTooltip(cluster); + openTooltip(cluster); expect(popover().textContent).toContain('Build Start'); expect(popover().textContent).toContain('Test Start'); diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts index d758a6a9b440..321a1c783cae 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.builder.ts @@ -33,10 +33,9 @@ import { TimezoneService } from 'core-app/core/datetime/timezone.service'; import { octiconElement } from 'core-app/shared/helpers/op-icon-builder'; import { html, nothing } from 'lit-html'; import type { TemplateResult } from 'lit-html'; -import { classMap } from 'lit-html/directives/class-map.js'; -import { styleMap } from 'lit-html/directives/style-map.js'; +import { popoverMessage } from 'core-app/shared/components/anchored-popover/popover-message'; +import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement'; import type { ProjectTimelineItem } from './project-timeline-item.builder'; -import type { CaretPlacement } from './project-timeline-tooltip-caret'; export interface TooltipView { anchor:HTMLElement | null; @@ -52,22 +51,13 @@ export class ProjectTimelineTooltipBuilder { popoverTemplate({ anchor, content, caret }:TooltipView):TemplateResult { return html` -
- ${content ?? nothing} -
+ ${popoverMessage(content ?? nothing, caret)}
`; } diff --git a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts index 887cec366b50..c01c3cb3d145 100644 --- a/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts +++ b/frontend/src/app/shared/components/project-timeline-graph/project-timeline-tooltip.popover.ts @@ -29,8 +29,8 @@ import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position'; import { render } from 'lit-html'; import type { Timeline } from 'vis-timeline/standalone'; +import { placePopover } from 'core-app/shared/components/anchored-popover/popover-placement'; import type { ProjectTimelineTooltipBuilder, TooltipView } from './project-timeline-tooltip.builder'; -import { caretPlacement } from './project-timeline-tooltip-caret'; const TOOLTIP_DELAY_IN_MS = 500; @@ -113,22 +113,14 @@ export class ProjectTimelineTooltipPopover { }, TOOLTIP_DELAY_IN_MS); } - // `anchored-position` positions the popover in a frame it requests on - // `beforetoggle` and does not report which side it settled on, so the caret - // is derived from the resulting geometry one frame after opening. private open(anchor:HTMLElement):void { if (!anchor.isConnected) return; - this.popover?.togglePopover(true); - requestAnimationFrame(() => this.alignCaret()); - } - - private alignCaret():void { const popover = this.popover; - const { anchor } = this.view; - if (!popover || !anchor || !popover.matches(':popover-open')) return; + if (!popover) return; - this.view = { ...this.view, caret: caretPlacement(popover.getBoundingClientRect(), anchor.getBoundingClientRect()) }; + popover.togglePopover(true); + this.view = { ...this.view, caret: placePopover(popover, anchor) }; this.render(); } diff --git a/frontend/src/global_styles/common/openproject-common.module.sass b/frontend/src/global_styles/common/openproject-common.module.sass index adc7bbbed6fb..ac437c45b0d9 100644 --- a/frontend/src/global_styles/common/openproject-common.module.sass +++ b/frontend/src/global_styles/common/openproject-common.module.sass @@ -2,6 +2,8 @@ @import 'menu/menu' @import 'input/input' @import 'bubble/bubble' +@import '../../app/shared/components/anchored-popover/anchored-popover' +@import '../../app/shared/components/budget-graphs/budget-graphs' @import '../../app/shared/components/forms' @import '../../app/shared/components/option-list/option-list' @import '../../app/shared/components/table/table'