Skip to content
Merged
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
29 changes: 29 additions & 0 deletions frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@
"jquery.cookie": "^1.4.1",
"jquery.flot": "^0.8.3",
"json5": "^2.2.2",
"lit-html": "^3.3.1",
"lodash": "^4.17.21",
"mark.js": "^8.11.0",
"mdx-embed": "^1.1.2",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,9 @@ import { DayResourceService } from 'core-app/core/state/days/day.service';
import allLocales from '@fullcalendar/core/locales-all';
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';

interface TimeEntrySchema extends SchemaResource {
activity:IFieldSchema;
Expand Down Expand Up @@ -304,7 +307,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
}

private buildTimeEntryEntries(entries:TimeEntryResource[]):EventInput[] {
const hoursDistribution:{ [key:string]:Moment } = {};
const hoursDistribution:Record<string, Moment> = {};

return entries.map((entry) => {
let start:Moment;
Expand Down Expand Up @@ -344,8 +347,8 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
return calendarEntries;
}

private calculateDateSums(entries:TimeEntryResource[]):{ [p:string]:number; } {
const dateSums:{ [key:string]:number } = {};
private calculateDateSums(entries:TimeEntryResource[]):Record<string, number> {
const dateSums:Record<string, number> = {};

entries.forEach((entry) => {
const hours = this.timezone.toHours(entry.hours as string);
Expand Down Expand Up @@ -410,7 +413,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
};
}

protected dmFilters(start:Moment, end:Moment):Array<[string, FilterOperator, string[]]> {
protected dmFilters(start:Moment, end:Moment):[string, FilterOperator, string[]][] {
const startDate = start.format('YYYY-MM-DD');
const endDate = end.subtract(1, 'd').format('YYYY-MM-DD');
return [
Expand All @@ -429,7 +432,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {

private editEvent(entry:TimeEntryResource):void {
void this.turboRequests.request(
`${this.pathHelper.timeEntryEditDialog(entry.id as string)}?onlyMe=true`,
`${this.pathHelper.timeEntryEditDialog(entry.id!)}?onlyMe=true`,
{ method: 'GET' },
);
}
Expand Down Expand Up @@ -503,7 +506,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
return;
}

void this.addTooltip(event);
void this.addPopover(event);
this.prependDuration(event);
this.appendFadeout(event);
}
Expand All @@ -525,7 +528,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
}
}

private async addTooltip(event:CalendarViewEvent):Promise<void> {
private async addPopover(event:CalendarViewEvent):Promise<void> {
if (this.browserDetector.isMobile) {
return;
}
Expand All @@ -534,23 +537,40 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {

const schema = (await this.schemaCache.ensureLoaded(entry as TimeEntryResource)) as TimeEntrySchema;

const tooltip = document.createElement('tool-tip');
tooltip.textContent = this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema);
event.el.appendChild(tooltip);
const anchorEl = event.el;
const anchorId = ensureId(anchorEl);
anchorEl.role = 'button';

// TODO: port tooltips
// jQuery(event.el).tooltip({
// content: this.tooltipContentString(event.event.extendedProps.entry as TimeEntryResource, schema),
// items: '.fc-event',
// close() {
// document.querySelectorAll('.ui-helper-hidden-accessible').forEach(element => element.remove());
// },
// track: true,
// });
const popoverId = generateId('popover');
const popoverHtml = this.popoverHtml(popoverId, anchorId, event.event.extendedProps.entry as TimeEntryResource, schema);

render(popoverHtml, anchorEl);

anchorEl.setAttribute('aria-haspopup', 'true');
anchorEl.setAttribute('popovertarget', popoverId);

const popoverEl = document.getElementById(popoverId)!;
const showPopover = () => { popoverEl.showPopover(); };
const hidePopover = () => { popoverEl.hidePopover(); };

target(anchorEl).on('mouseenter.anchor', showPopover);
target(anchorEl).on('focus.anchor', showPopover);
target(anchorEl).on('mouseleave.anchor', hidePopover);
target(anchorEl).on('blur.anchor', hidePopover);
}

private removeTooltip(event:CalendarViewEvent):void {
// TODO: port tooltips
private removePopover(event:CalendarViewEvent):void {
const anchorId = event.el.id;
const anchorEl = document.getElementById(anchorId);
if (!anchorEl) {
return;
}

target(anchorEl).off('.anchor');
anchorEl.removeAttribute('popovertarget');
anchorEl.removeAttribute('aria-haspopup');
anchorEl.removeAttribute('role');
document.querySelector(`anchored-position[anchor="${anchorId}"]`)?.remove();
}

private prependDuration(event:CalendarViewEvent):void {
Expand Down Expand Up @@ -600,7 +620,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
return;
}

this.removeTooltip(event);
this.removePopover(event);
}

private entryName(entry:TimeEntryResource):string {
Expand All @@ -609,42 +629,66 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
name += ` - ${this.entityName(entry)}`;
}

return name || '-';
return name ?? '-';
}

private entityName(entry:TimeEntryResource):string {
const entity = entry.entity;
return `#${idFromLink(entity.href)}: ${entity.name}`;
}

private tooltipContentString(entry:TimeEntryResource, schema:TimeEntrySchema):string {
return `
<ul class="tooltip--map">
<li class="tooltip--map--item">
<span class="tooltip--map--key">${schema.project.name}:</span>
<span class="tooltip--map--value">${this.sanitizedValue(entry.project.name)}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${schema.entity.name}:</span>
<span class="tooltip--map--value">${entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default')}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${schema.activity.name}:</span>
<span class="tooltip--map--value">${this.sanitizedValue(entry.activity?.name || '')}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${schema.hours.name}:</span>
<span class="tooltip--map--value">${this.timezone.formattedDuration(entry.hours as string)}</span>
</li>
<li class="tooltip--map--item">
<span class="tooltip--map--key">${schema.comment.name}:</span>
<span class="tooltip--map--value">${this.sanitizedValue(entry.comment.raw || this.i18n.t('js.placeholders.default'))}</span>
</li>
private popoverHtml(
popoverId:string,
anchorId:string,
entry:TimeEntryResource,
schema:TimeEntrySchema) {
return html`
<anchored-position
id="${popoverId}"
role="dialog"
align="start"
anchor="${anchorId}"
anchor-offset="condensed"
popover="hint"
side="outside-right">
${this.popoverContentHtml(entry, schema)}
</anchored-position>
`;
}

private popoverContentHtml(entry:TimeEntryResource, schema:TimeEntrySchema) {
return html`
<div class="Popover">
<div class="Box Popover-message Popover-message--left-top ml-2 mx-auto p-2 text-left text-small">
<ul class="list-style-none ml-0">
<li>
<span class="text-bold">${schema.project.name}:</span>
<span>${this.sanitizedValue(entry.project.name)}</span>
</li>
<li>
<span class="text-bold">${schema.entity.name}:</span>
<span>${entry.entity ? this.sanitizedValue(this.entityName(entry)) : this.i18n.t('js.placeholders.default')}</span>
</li>
<li>
<span class="text-bold">${schema.activity.name}:</span>
<span>${this.sanitizedValue(entry.activity?.name ?? '')}</span>
</li>
<li>
<span class="text-bold">${schema.hours.name}:</span>
<span>${this.timezone.formattedDuration(entry.hours as string)}</span>
</li>
<li>
<span class="text-bold">${schema.comment.name}:</span>
<span>${this.sanitizedValue(entry.comment.raw ?? this.i18n.t('js.placeholders.default'))}</span>
</li>
</ul>
</div>
</div>
`;
}

private sanitizedValue(value:string):string {
return this.sanitizer.sanitize(SecurityContext.HTML, value) || '';
return this.sanitizer.sanitize(SecurityContext.HTML, value) ?? '';
}

protected formatNumber(value:number):string {
Expand All @@ -671,7 +715,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
}
return null;
})
.filter((value) => value !== null) as number[];
.filter((value) => value !== null);
}

private handleDialogClose(event:CustomEvent):void {
Expand All @@ -682,7 +726,7 @@ export class TimeEntryCalendarComponent implements AfterViewInit, OnDestroy {
void this.fetchTimeEntries(
this.memoizedTimeEntries.start,
this.memoizedTimeEntries.end,
).then(async (collection) => {
).then((collection) => {
this.entries.emit(collection);
this.ucCalendar.getApi().refetchEvents();
});
Expand Down
20 changes: 15 additions & 5 deletions frontend/src/app/shared/helpers/dom-helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,20 @@ export function queryVisible<T extends HTMLElement = HTMLElement>(selector:strin
return Array.from(node.querySelectorAll<T>(selector)).filter(isVisible);
}

const idSalt = Math.random().toString(36).slice(2, 6);
let elementId = 0;

/**
* Generates a unique and stable ID for use with `HTMLElement`.
*
* @param {string} [prefix='el'] - The prefix to use for the generated ID.
* @returns {string} The newly generated element ID.
*/
export function generateId(prefix = 'el'):string {
// eslint-disable-next-line no-plusplus
return `${prefix}-${idSalt}-${elementId++}`;
}

/**
* Ensures that the given HTMLElement has a unique and stable `id` attribute.
*
Expand All @@ -123,13 +137,9 @@ export function queryVisible<T extends HTMLElement = HTMLElement>(selector:strin
* @param {string} [prefix='el'] - The prefix to use for the generated ID.
* @returns {string} The existing or newly generated element ID.
*/
const idSalt = Math.random().toString(36).slice(2, 6);
let elementId = 0;

export function ensureId(el:HTMLElement, prefix = 'el'):string {
if (!el.id) {
// eslint-disable-next-line no-plusplus
el.id = `${prefix}-${idSalt}-${elementId++}`;
el.id = generateId(prefix);
}
return el.id;
}
1 change: 0 additions & 1 deletion frontend/src/global_styles/content/_index.sass
Original file line number Diff line number Diff line change
Expand Up @@ -68,7 +68,6 @@
@import search
@import security_badge
@import contextual
@import tooltip
@import grid
@import grid_mobile
@import resizer
Expand Down
Loading
Loading