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
Original file line number Diff line number Diff line change
Expand Up @@ -822,20 +822,25 @@ const TgEntityCentreBehaviorImpl = {
}
},

/**
* Pushes the current page of run data into the EGI and synchronises centre-level `retrievedEntities` / `renderingHints`.
*
* The EGI side arrays are primed before `retrievedEntities` is assigned, so the single `egiModel` rebuild (triggered by the EGI `entities` assignment inside the `retrievedEntities` observer) consumes fresh values in one pass.
* Assigning them after `retrievedEntities` (as was done previously) made the rebuild read stale values and then triggered a redundant per-row refresh pass for each array.
* `renderingHints` is assigned the same array reference that was primed, so the EGI-forwarding observer dirty-checks it into a no-op.
*/
_setPageData: function (startIdx, endIdx) {
if (typeof startIdx === 'undefined') {
this.retrievedEntities = this.allFilteredEntities;
this.renderingHints = this.allFilteredRenderingHints;
this.$.egi.primaryActionIndices = this.allFilteredPrimaryActionIndices;
this.$.egi.secondaryActionIndices = this.allFilteredSecondaryActionIndices;
this.$.egi.propertyActionIndices = this.allFilteredPropertyActionIndices;
} else {
this.retrievedEntities = this.allFilteredEntities.slice(startIdx, endIdx);
this.renderingHints = this.allFilteredRenderingHints.slice(startIdx, endIdx);
this.$.egi.primaryActionIndices = this.allFilteredPrimaryActionIndices.slice(startIdx, endIdx);
this.$.egi.secondaryActionIndices = this.allFilteredSecondaryActionIndices.slice(startIdx, endIdx);
this.$.egi.propertyActionIndices = this.allFilteredPropertyActionIndices.slice(startIdx, endIdx);
}
const page = arr => typeof startIdx === 'undefined' ? arr : arr.slice(startIdx, endIdx);
const entities = page(this.allFilteredEntities);
const renderingHints = page(this.allFilteredRenderingHints);
this.$.egi.primeGridData({
renderingHints: renderingHints,
primaryActionIndices: page(this.allFilteredPrimaryActionIndices),
secondaryActionIndices: page(this.allFilteredSecondaryActionIndices),
propertyActionIndices: page(this.allFilteredPropertyActionIndices)
});
this.retrievedEntities = entities;
this.renderingHints = renderingHints;
},

_setPageNumber: function (number) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,10 @@ export class TgFullcalendar extends mixinBehaviors([IronResizableBehavior], Poly
super();
this._reflector = new TgReflector();
this._appConfig = new TgAppConfig();
// Raised when an event source rebuild is requested while the calendar is hidden; flushed by `_resizeEventListener` once the calendar becomes visible.
this._pendingEventSourceUpdate = false;
// Latches whether a deferred rebuild still owes a navigate-to-first-event: a run deferred while hidden must navigate on flush even if a later refresh arrives before the flush.
this._pendingNavigate = false;
}

ready () {
Expand Down Expand Up @@ -309,94 +313,134 @@ export class TgFullcalendar extends mixinBehaviors([IronResizableBehavior], Poly
}

/**
* Updates calendar data; moves it to the date of the chronologically first event (if any); re-renders calendar.
* Updates calendar data; moves it to the date of the chronologically first event (if any).
*
* While the calendar is hidden (`offsetParent === null`, e.g. a non-selected alternative view that `iron-pages` hides via `display: none`),
* the rebuild is skipped — a pending flag is raised instead and `_resizeEventListener` flushes it upon the `iron-resize` event that accompanies becoming visible.
* This avoids rebuilding the whole event source on every centre run while the calendar cannot be seen.
*/
_updateEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar) {
if (!_calendar) {
return;
}
if (this.offsetParent === null) {
this._pendingEventSourceUpdate = true;
// Latch the navigation intent at defer time: `dataChangeReason` reflects only the latest update, so a refresh arriving later while still hidden must not cancel the navigation owed to an earlier deferred run.
this._pendingNavigate = this._pendingNavigate || this.dataChangeReason !== RunActions.refresh;
return;
}
// This rebuild supersedes any deferred one, consuming a latched navigation if such is owed.
this._pendingEventSourceUpdate = false;
const shouldNavigate = this._pendingNavigate || this.dataChangeReason !== RunActions.refresh;
this._pendingNavigate = false;
this._rebuildEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar, shouldNavigate);
}

/**
* Rebuilds calendar events from `entities`, navigating to the chronologically first event (if any) when `shouldNavigate` is true; the calendar re-renders exactly once.
*
* All mutations are grouped under `batchRendering` because FullCalendar otherwise re-renders synchronously after every single mutation
* (each removed event, each added event, each navigation) — an unbatched rebuild triggers thousands of re-renders for large result sets.
* The single re-render happens when the batch completes, hence no explicit `render()` call.
*/
_rebuildEventSource(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty, _calendar, shouldNavigate) {
// Sentinels for open-ended events. Far enough outside any plausible calendar view to behave like ±infinity.
const FAR_PAST = moment('1900-01-01').toDate();
const FAR_FUTURE = moment('2200-01-01').toDate();
if (allDefined(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty) && _calendar) {
_calendar.getEvents().forEach(event => event.remove());
let startTime = Infinity;
// Month view shows a single continuous bar per entity; time-grid views split into timed + allDay segments.
const isMonth = this.currentView === 'dayGridMonth';
entities.forEach(entity => {
if (startTime > entity.get(eventFromProperty)) {
startTime = entity.get(eventFromProperty);
}
const eventColor = this.colorProperty ? entity.get(this.colorProperty) : undefined;
const startVal = entity.get(eventFromProperty);
const endVal = entity.get(eventToProperty);
const common = {
extendedProps: { entity: entity },
title: entity.get(eventKeyProperty) + (eventDescProperty && entity.get(eventDescProperty) ? " - "+ entity.get(eventDescProperty) : ""),
backgroundColor: eventColor ? '#' + eventColor["hashlessUppercasedColourValue"] : "#3788d8",
groupId: 'evt-' + entity.get('id')
};
if (isMonth) {
if (!startVal && !endVal) {
if (allDefined(entities, eventKeyProperty, eventDescProperty, eventFromProperty, eventToProperty)) {
_calendar.batchRendering(() => {
_calendar.removeAllEvents();
let startTime = Infinity;
// Month view shows a single continuous bar per entity; time-grid views split into timed + allDay segments.
const isMonth = this.currentView === 'dayGridMonth';
entities.forEach(entity => {
const startVal = entity.get(eventFromProperty);
const endVal = entity.get(eventToProperty);
// Events without a start date must not participate in `startTime`, otherwise a single such event would suppress the navigation below.
if (startVal && startTime > startVal) {
startTime = startVal;
}
const eventColor = this.colorProperty ? entity.get(this.colorProperty) : undefined;
const common = {
extendedProps: { entity: entity },
title: entity.get(eventKeyProperty) + (eventDescProperty && entity.get(eventDescProperty) ? " - "+ entity.get(eventDescProperty) : ""),
backgroundColor: eventColor ? '#' + eventColor["hashlessUppercasedColourValue"] : "#3788d8",
groupId: 'evt-' + entity.get('id')
};
if (isMonth) {
if (!startVal && !endVal) {
_calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true });
} else if (!startVal) {
_calendar.addEvent({ ...common, start: FAR_PAST, end: endVal, allDay: true });
} else if (!endVal) {
_calendar.addEvent({ ...common, start: startVal, end: FAR_FUTURE, allDay: true });
} else {
_calendar.addEvent({ ...common, start: startVal, end: endVal });
}
} else if (!startVal && !endVal) {
_calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true });
} else if (!startVal) {
_calendar.addEvent({ ...common, start: FAR_PAST, end: endVal, allDay: true });
const end = moment(endVal);
const endDayStart = end.clone().startOf('day');
_calendar.addEvent({ ...common, start: FAR_PAST, end: endDayStart.toDate(), allDay: true });
if (endDayStart.isBefore(end)) {
_calendar.addEvent({ ...common, start: endDayStart.toDate(), end: end.toDate() });
}
} else if (!endVal) {
_calendar.addEvent({ ...common, start: startVal, end: FAR_FUTURE, allDay: true });
} else {
_calendar.addEvent({ ...common, start: startVal, end: endVal });
}
} else if (!startVal && !endVal) {
_calendar.addEvent({ ...common, start: FAR_PAST, end: FAR_FUTURE, allDay: true });
} else if (!startVal) {
const end = moment(endVal);
const endDayStart = end.clone().startOf('day');
_calendar.addEvent({ ...common, start: FAR_PAST, end: endDayStart.toDate(), allDay: true });
if (endDayStart.isBefore(end)) {
_calendar.addEvent({ ...common, start: endDayStart.toDate(), end: end.toDate() });
}
} else if (!endVal) {
const start = moment(startVal);
const allDayStart = start.clone().startOf('day');
if (allDayStart.isBefore(start)) {
allDayStart.add(1, 'day');
}
if (start.isBefore(allDayStart)) {
_calendar.addEvent({ ...common, start: start.toDate(), end: allDayStart.toDate() });
}
_calendar.addEvent({ ...common, start: allDayStart.toDate(), end: FAR_FUTURE, allDay: true });
} else {
const start = moment(startVal);
const end = moment(endVal);
const fullDayStart = start.clone().startOf('day');
if (fullDayStart.isBefore(start)) {
fullDayStart.add(1, 'day');
}
const fullDayEnd = end.clone().startOf('day');
if (!fullDayStart.isBefore(fullDayEnd)) {
_calendar.addEvent({ ...common, start: startVal, end: endVal });
const start = moment(startVal);
const allDayStart = start.clone().startOf('day');
if (allDayStart.isBefore(start)) {
allDayStart.add(1, 'day');
}
if (start.isBefore(allDayStart)) {
_calendar.addEvent({ ...common, start: start.toDate(), end: allDayStart.toDate() });
}
_calendar.addEvent({ ...common, start: allDayStart.toDate(), end: FAR_FUTURE, allDay: true });
} else {
if (start.isBefore(fullDayStart)) {
_calendar.addEvent({ ...common, start: start.toDate(), end: fullDayStart.toDate() });
const start = moment(startVal);
const end = moment(endVal);
const fullDayStart = start.clone().startOf('day');
if (fullDayStart.isBefore(start)) {
fullDayStart.add(1, 'day');
}
// allDay events use exclusive end semantics in FullCalendar.
_calendar.addEvent({ ...common, start: fullDayStart.toDate(), end: fullDayEnd.toDate(), allDay: true });
if (fullDayEnd.isBefore(end)) {
_calendar.addEvent({ ...common, start: fullDayEnd.toDate(), end: end.toDate() });
const fullDayEnd = end.clone().startOf('day');
if (!fullDayStart.isBefore(fullDayEnd)) {
_calendar.addEvent({ ...common, start: startVal, end: endVal });
} else {
if (start.isBefore(fullDayStart)) {
_calendar.addEvent({ ...common, start: start.toDate(), end: fullDayStart.toDate() });
}
// allDay events use exclusive end semantics in FullCalendar.
_calendar.addEvent({ ...common, start: fullDayStart.toDate(), end: fullDayEnd.toDate(), allDay: true });
if (fullDayEnd.isBefore(end)) {
_calendar.addEvent({ ...common, start: fullDayEnd.toDate(), end: end.toDate() });
}
}
}
}
if (this.dataChangeReason !== RunActions.refresh && startTime && startTime < Infinity) {
});
// Navigate once, after the earliest event start across all entities is known.
if (shouldNavigate && startTime < Infinity) {
_calendar.gotoDate(startTime);
}
});
_calendar.render();
} else if (_calendar){
_calendar.gotoDate(new Date());
_calendar.getEvents().forEach(event => event.remove());
_calendar.render();
} else {
_calendar.batchRendering(() => {
_calendar.gotoDate(new Date());
_calendar.removeAllEvents();
});
}
}

_resizeEventListener () {
if (this._calendar) {
// Flushes an event source rebuild that was deferred while the calendar was hidden.
// `iron-resize` is guaranteed on becoming visible: `iron-pages` performs `notifyResize` on every selection change and the notification reaches this component through the resizable chain.
if (this._pendingEventSourceUpdate && this.offsetParent !== null) {
this._pendingEventSourceUpdate = false;
const shouldNavigate = this._pendingNavigate;
this._pendingNavigate = false;
this._rebuildEventSource(this.entities, this.eventKeyProperty, this.eventDescProperty, this.eventFromProperty, this.eventToProperty, this._calendar, shouldNavigate);
}
this._calendar.render();
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@ import '/resources/polymer/@polymer/iron-icons/iron-icons.js';
import '/resources/polymer/@polymer/paper-icon-button/paper-icon-button.js';
import '/resources/polymer/@polymer/paper-styles/color.js';

import '/resources/polymer/@polymer/polymer/lib/elements/dom-if.js';

import { Polymer } from '/resources/polymer/@polymer/polymer/lib/legacy/polymer-fn.js';
import { html } from '/resources/polymer/@polymer/polymer/lib/utils/html-tag.js';

Expand All @@ -17,6 +19,10 @@ import { simplifyRichText } from '/resources/components/rich-text/tg-rich-text-u
export const EGI_CELL_PADDING = "0.6rem";
export const EGI_CELL_PADDING_TEMPLATE = html`0.6rem`;

// The overflow button at the bottom of the template is stamped via dom-if (rather than always stamped and toggled with hidden$) so that a cell whose column has no multi-group property actions never instantiates a paper-icon-button.
// An EGI renders as a non-virtualized dom-repeat of tg-egi-cell (one instance per row per column), so an always-stamped per-cell paper-icon-button would boot tens of thousands of elements on a large centre — a significant, purely wasted rendering cost, since almost all cells keep it hidden.
// _hasOverflow depends only on column configuration (stable per column), so dom-if stamps the button once for the columns that need it and never for those that do not.
// This explanation lives outside the html literal deliberately — a comment inside would be cloned as a comment node into every stamped cell instance.
const template = html`
<style>
:host {
Expand Down Expand Up @@ -130,7 +136,9 @@ const template = html`
<iron-icon class="table-icon" hidden$="[[!_isBooleanProp(_hostComponent, _entity, column)]]" style$="[[_foregroundRendHints]]" icon="[[_value]]"></iron-icon>
<a class="value-container" hidden$="[[!_isHyperlinkProp(_hostComponent, _entity, column)]]" href$="[[_value]]" target="_blank" style$="[[_foregroundRendHints]]">[[_value]]</a>
<div class="value-container" word-wrap$="[[column.wordWrap]]" hidden$="[[!_isNotBooleanOrHyperlinkProp(_hostComponent, _entity, column)]]" style$="[[_foregroundRendHints]]" inner-h-t-m-l="[[_value]]"></div>
<paper-icon-button id="dropdownAction" class="overflow-button" icon="more-vert" hidden$="[[!_hasOverflow(column)]]" on-tap="_openOverflow" tooltip-text="Opens list of available actions"></paper-icon-button>`;
<template is="dom-if" if="[[_hasOverflow(column)]]">
<paper-icon-button id="dropdownAction" class="overflow-button" icon="more-vert" on-tap="_openOverflow" tooltip-text="Opens list of available actions"></paper-icon-button>
</template>`;

Polymer({

Expand Down Expand Up @@ -344,11 +352,12 @@ Polymer({
/**
* Opens the shared EGI dropdown with this column's property-action groups, positioned next to the overflow button.
* Stops event propagation so the click does not also fire the cell-tap (which would re-run the first action).
* The overflow button is stamped inside a dom-if, so it is resolved via `$$` rather than the static `this.$` map (dynamic node ids are not registered in `this.$`).
*/
_openOverflow: function (e) {
e.stopPropagation();
if (this._hostComponent && this._entity && this.column) {
this._hostComponent._openDropDownForPropertyActions(this._entity, this.column, this.$.dropdownAction);
this._hostComponent._openDropDownForPropertyActions(this._entity, this.column, this.$$('#dropdownAction'));
}
}
});
Loading
Loading