Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,26 @@ import { HalResourceNotificationService } from 'core-app/features/hal/services/h
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 '@openproject/primer-view-components/app/components/primer/anchored_position';
import type AnchoredPositionElement from '@openproject/primer-view-components/app/components/primer/anchored_position';
import { TimeEntryCalendarComponent } from './te-calendar.component';

describe('TimeEntryCalendarComponent', () => {
let fixture:ComponentFixture<TimeEntryCalendarComponent>;
let element:HTMLElement;
let entries:unknown[];

const saturdayEntry = () => ({
hours: 'PT1H',
spentOn: moment().startOf('isoWeek').add(5, 'days').format('YYYY-MM-DD'),
project: { name: 'Demo' },
entity: { href: '/api/v3/work_packages/42', name: 'Task' },
activity: { name: 'Development' },
comment: { raw: 'note' },
});

beforeEach(async () => {
entries = [];
await TestBed.configureTestingModule({
declarations: [TimeEntryCalendarComponent],
imports: [FullCalendarModule],
Expand All @@ -63,7 +76,7 @@ describe('TimeEntryCalendarComponent', () => {
{ 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: ApiV3Service, useValue: { time_entries: { list: () => of({ elements: entries, createTimeEntry: undefined }) } } },
{
provide: TimezoneService,
useValue: {
Expand All @@ -76,8 +89,19 @@ describe('TimeEntryCalendarComponent', () => {
{ provide: States, useValue: {} },
{ provide: StateService, useValue: {} },
{ provide: HalResourceNotificationService, useValue: {} },
{ provide: SchemaCacheService, useValue: {} },
{ provide: ColorsService, useValue: {} },
{
provide: SchemaCacheService,
useValue: {
ensureLoaded: () => Promise.resolve({
project: { name: 'Project' },
entity: { name: 'Entity' },
activity: { name: 'Activity' },
hours: { name: 'Hours' },
comment: { name: 'Comment' },
}),
},
},
{ provide: ColorsService, useValue: { toHsl: () => 'hsl(200 50% 50%)', toHsla: () => 'hsla(200 50% 50% / 1)' } },
],
})
.overrideComponent(TimeEntryCalendarComponent, {
Expand Down Expand Up @@ -121,6 +145,91 @@ describe('TimeEntryCalendarComponent', () => {
expect(element.querySelectorAll('.fc-col-header-cell.fc-day')).toHaveLength(5);
});

describe('with an entry clipped by the calendar scroller', () => {
let scroller:HTMLElement;
let entry:HTMLElement;
let popover:AnchoredPositionElement;
let layout:HTMLStyleElement;

const scrollerPaddingRight = () => scroller.getBoundingClientRect().left + scroller.clientLeft + scroller.clientWidth;
const twoFrames = async () => {
await new Promise(requestAnimationFrame);
await new Promise(requestAnimationFrame);
};

// TestBed hosts the component in a div, so the tag-scoped calendar sass does not apply.
beforeEach(async () => {
entries = [saturdayEntry()];
element.id = 'calendar-under-test';
element.style.width = '300px';
layout = document.createElement('style');
layout.textContent = '#calendar-under-test full-calendar { overflow-x: auto } #calendar-under-test .fc-view { min-width: 800px }';
document.head.append(layout);
await renderWeek([true, true, true, true, true, true, true]);
await vi.waitUntil(() => element.querySelector('.te-calendar--time-entry[popovertarget]') !== null);

scroller = element.querySelector<HTMLElement>('full-calendar')!;
entry = element.querySelector<HTMLElement>('.te-calendar--time-entry')!;
popover = document.getElementById(entry.getAttribute('popovertarget')!) as AnchoredPositionElement;

scroller.scrollLeft = 0;
scroller.scrollLeft = entry.getBoundingClientRect().right - scrollerPaddingRight() - 20;
});

afterEach(() => {
popover.remove();
layout.remove();
});

it('anchors the popover on the visible part of the entry', () => {
expect(entry.getBoundingClientRect().right).toBeGreaterThan(scrollerPaddingRight());

entry.dispatchEvent(new Event('mouseenter'));

expect(popover.matches(':popover-open')).toBe(true);
const anchor = popover.anchorElement as unknown as DOMRect;
expect(anchor.right).toBeLessThanOrEqual(scrollerPaddingRight() + 0.5);
expect(anchor.right).toBeLessThan(entry.getBoundingClientRect().right);
});

it('follows the calendar when it scrolls', async () => {
entry.dispatchEvent(new Event('mouseenter'));
await new Promise((resolve) => { setTimeout(resolve); });
const before = popover.getBoundingClientRect().left;

scroller.scrollLeft = scroller.scrollWidth;
scroller.dispatchEvent(new Event('scroll'));

expect(popover.matches(':popover-open')).toBe(true);
expect(popover.getBoundingClientRect().left).not.toBeCloseTo(before, 0);
});

it('stops following once the popover was dismissed', async () => {
entry.dispatchEvent(new Event('mouseenter'));
popover.hidePopover();
await new Promise((resolve) => { setTimeout(resolve); });
const left = popover.style.left;

scroller.scrollLeft = scroller.scrollWidth;
scroller.dispatchEvent(new Event('scroll'));

expect(popover.style.left).toBe(left);
});

it('stays beside the entry when focusing it scrolls it into view', async () => {
scroller.scrollLeft -= entry.getBoundingClientRect().width + 40;
expect(entry.getBoundingClientRect().left).toBeGreaterThan(scrollerPaddingRight());

entry.focus();
await twoFrames();

expect(popover.matches(':popover-open')).toBe(true);
const entryBox = entry.getBoundingClientRect();
expect(entryBox.right).toBeLessThanOrEqual(scrollerPaddingRight() + 0.5);
expect(popover.getBoundingClientRect().left).toBeCloseTo(entryBox.right + 8, 0);
});
});

it('closes an open entry popover when the window is resized', () => {
const popover = document.createElement('div');
popover.className = 'te-calendar--popover';
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ import { ensureId, generateId } from 'core-app/shared/helpers/dom-helpers';
import { target } from 'core-app/shared/helpers/event-helpers';
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 { liveRect } from 'core-app/shared/components/anchored-popover/live-rect';
import { visibleRect } from 'core-app/shared/components/anchored-popover/visible-rect';
import type { CaretPlacement } from 'core-app/shared/components/anchored-popover/caret-placement';
import { timeEntryPopoverHtml, timeEntryPopoverRows } from './te-calendar-popover';
import type { TimeEntrySchema } from './te-calendar-popover';
Expand Down Expand Up @@ -177,10 +179,16 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {

private closeDialogHandler:EventListener = this.handleDialogClose.bind(this);

private placeOpenPopover:(() => void)|null = null;

private closeOpenPopover = () => {
this.element.nativeElement.querySelector<HTMLElement>('.te-calendar--popover:popover-open')?.hidePopover();
};

private repositionOpenPopover = () => {
this.placeOpenPopover?.();
};

public additionalOptions:CalendarOptionsWithDayGrid = {
editable: false,
locales: allLocales,
Expand Down Expand Up @@ -235,11 +243,13 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
ngAfterViewInit():void {
document.addEventListener('dialog:close', this.closeDialogHandler);
window.addEventListener('resize', this.closeOpenPopover);
document.addEventListener('scroll', this.repositionOpenPopover, { capture: true });
}

ngOnDestroy():void {
document.removeEventListener('dialog:close', this.closeDialogHandler);
window.removeEventListener('resize', this.closeOpenPopover);
document.removeEventListener('scroll', this.repositionOpenPopover, { capture: true });
}

async requireNonWorkingDays(start:Date | string, end:Date | string) {
Expand Down Expand Up @@ -550,6 +560,10 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
const schema = (await this.schemaCache.ensureLoaded(entry)) as TimeEntrySchema;

const anchorEl = event.el;
if (!anchorEl.isConnected) {
return;
}

const anchorId = ensureId(anchorEl);
anchorEl.role = 'button';

Expand All @@ -565,10 +579,16 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
anchorEl.setAttribute('popovertarget', popoverId);

const popoverEl = document.getElementById(popoverId) as AnchoredPositionElement;
const anchorRect = liveRect(() => visibleRect(anchorEl));
popoverEl.anchorElement = anchorRect as unknown as HTMLElement;
const place = () => draw(placePopover(popoverEl, anchorRect));
popoverEl.addEventListener('toggle', (toggle) => {
this.placeOpenPopover = toggle.newState === 'open' ? place : null;
});
const showPopover = () => {
if (popoverEl.matches(':popover-open')) return;
popoverEl.showPopover();
draw(placePopover(popoverEl, anchorEl));
place();
};
const hidePopover = () => { popoverEl.hidePopover(); };

Expand All @@ -589,7 +609,11 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
anchorEl.removeAttribute('popovertarget');
anchorEl.removeAttribute('aria-haspopup');
anchorEl.removeAttribute('role');
document.querySelector(`anchored-position[anchor="${anchorId}"]`)?.remove();
const popoverEl = document.querySelector(`anchored-position[anchor="${anchorId}"]`);
if (popoverEl?.matches(':popover-open')) {
this.placeOpenPopover = null;
}
popoverEl?.remove();
}

private prependDuration(event:CalendarViewEvent):void {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
//-- 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 { liveRect } from './live-rect';
import { placePopover } from './popover-placement';

describe('liveRect', () => {
it('reads the source again in the next task', async () => {
let current = new DOMRect(10, 20, 30, 40);
const rect = liveRect(() => current);

expect(rect.left).toBe(10);
expect(rect.bottom).toBe(60);

current = new DOMRect(100, 200, 30, 40);
await Promise.resolve();
expect(rect.left).toBe(100);
expect(rect.bottom).toBe(240);
});

it('reads the source once per task', () => {
const source = vi.fn(() => new DOMRect(1, 2, 3, 4));
const rect = liveRect(source);

expect(rect.left + rect.top + rect.right + rect.bottom).toBe(13);
expect(source).toHaveBeenCalledTimes(1);
});

it('remains a DOMRect', () => {
const rect = liveRect(() => new DOMRect(1, 2, 3, 4));

expect(rect).toBeInstanceOf(DOMRect);
expect(rect.toJSON()).toEqual(new DOMRect(1, 2, 3, 4).toJSON());
});

it('anchors a popover where the source currently is', async () => {
const popover = document.createElement('anchored-position') as AnchoredPositionElement;
popover.setAttribute('popover', 'manual');
popover.setAttribute('side', 'outside-top');
popover.setAttribute('align', 'center');
popover.style.cssText = 'margin: 0; padding: 0; border: 0;';
popover.innerHTML = '<div style="width: 100px; height: 60px;"></div>';
document.body.append(popover);

let anchor = new DOMRect(200, 200, 10, 10);
const rect = liveRect(() => anchor);
popover.anchorElement = rect as unknown as HTMLElement;
popover.togglePopover(true);

const centre = () => {
const box = popover.getBoundingClientRect();
return box.left + box.width / 2;
};

placePopover(popover, rect);
expect(centre()).toBeCloseTo(205, 0);

anchor = new DOMRect(300, 200, 10, 10);
await Promise.resolve();
placePopover(popover, rect);
expect(centre()).toBeCloseTo(305, 0);

popover.remove();
});
});
44 changes: 44 additions & 0 deletions frontend/src/app/shared/components/anchored-popover/live-rect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
//-- 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.
//++

// Primer's getAnchoredPosition reads a non-Element anchor as the rect itself.
// One placement reads it many times, so the source is sampled once per task.
export function liveRect(source:() => DOMRect):DOMRect {
let sample:DOMRect|null = null;

return new Proxy(new DOMRect(), {
get(_target, key) {
if (!sample) {
sample = source();
queueMicrotask(() => { sample = null; });
}
const value = Reflect.get(sample, key) as unknown;
return typeof value === 'function' ? (value as (...args:unknown[]) => unknown).bind(sample) : value;
},
});
}
Loading
Loading