odoo18/addons_extensions/web_gantt/static/src/gantt_helpers.js

746 lines
25 KiB
JavaScript

import { onWillUnmount, status, useComponent, useEffect, useEnv } from "@odoo/owl";
import { getEndOfLocalWeek, getStartOfLocalWeek } from "@web/core/l10n/dates";
import { makePopover, usePopover } from "@web/core/popover/popover_hook";
import { makeDraggableHook } from "@web/core/utils/draggable_hook_builder_owl";
import { useService } from "@web/core/utils/hooks";
import { clamp } from "@web/core/utils/numbers";
import { pick } from "@web/core/utils/objects";
import { GanttPopoverInDialog } from "./gantt_popover_in_dialog";
/** @typedef {luxon.DateTime} DateTime */
/**
* @param {number} target
* @param {number[]} values
* @returns {number}
*/
function closest(target, values) {
return values.reduce(
(prev, val) => (Math.abs(val - target) < Math.abs(prev - target) ? val : prev),
Infinity
);
}
/**
* Adds a time diff to a date keeping the same value even if the offset changed
* during the manipulation. This is typically needed with timezones using DayLight
* Saving offset changes.
*
* @example dateAddFixedOffset(luxon.DateTime.local(), { hour: 1 });
* @param {DateTime} date
* @param {Record<string, number>} plusParams
*/
export function dateAddFixedOffset(date, plusParams) {
const shouldApplyOffset = Object.keys(plusParams).some((key) =>
/^(hour|minute|second)s?$/i.test(key)
);
const result = date.plus(plusParams);
if (shouldApplyOffset) {
const initialOffset = date.offset;
const diff = initialOffset - result.offset;
if (diff) {
const adjusted = result.plus({ minute: diff });
return adjusted.offset === initialOffset ? result : adjusted;
}
}
return result;
}
export function diffColumn(col1, col2, unit) {
return col2.diff(col1, unit).values[`${unit}s`];
}
export function getRangeFromDate(rangeId, date) {
const startDate = localStartOf(date, rangeId);
const stopDate = startDate.plus({ [rangeId]: 1 }).minus({ day: 1 });
return { focusDate: date, startDate, stopDate, rangeId };
}
export function localStartOf(date, unit) {
return unit === "week" ? getStartOfLocalWeek(date) : date.startOf(unit);
}
export function localEndOf(date, unit) {
return unit === "week" ? getEndOfLocalWeek(date) : date.endOf(unit);
}
/**
* @param {number} cellPart
* @param {(0 | 1)[]} subSlotUnavailabilities
* @param {boolean} isToday
* @returns {string | null}
*/
export function getCellColor(cellPart, subSlotUnavailabilities, isToday) {
const sum = subSlotUnavailabilities.reduce((acc, d) => acc + d);
if (!sum) {
return null;
}
switch (cellPart) {
case sum: {
return `background-color:${getCellPartColor(sum, isToday)}`;
}
case 2: {
const [c0, c1] = subSlotUnavailabilities.map((d) => getCellPartColor(d, isToday));
return `background:linear-gradient(90deg,${c0}49%,${c1}50%)`;
}
case 4: {
const [c0, c1, c2, c3] = subSlotUnavailabilities.map((d) =>
getCellPartColor(d, isToday)
);
return `background:linear-gradient(90deg,${c0}24%,${c1}25%,${c1}49%,${c2}50%,${c2}74%,${c3}75%)`;
}
}
}
/**
* @param {0 | 1} availability
* @param {boolean} isToday
* @returns {string}
*/
export function getCellPartColor(availability, isToday) {
if (availability) {
return "var(--Gantt__DayOff-background-color)";
} else if (isToday) {
return "var(--Gantt__DayOffToday-background-color)";
} else {
return "var(--Gantt__Day-background-color)";
}
}
/**
* @param {number | [number, string]} value
* @returns {number}
*/
export function getColorIndex(value) {
if (typeof value === "number") {
return Math.round(value) % NB_GANTT_RECORD_COLORS;
} else if (Array.isArray(value)) {
return value[0] % NB_GANTT_RECORD_COLORS;
}
return 0;
}
/**
* Intervals are supposed to intersect (intersection duration >= 1 milliseconds)
*
* @param {[DateTime, DateTime]} interval
* @param {[DateTime, DateTime]} otherInterval
* @returns {[DateTime, DateTime]}
*/
export function getIntersection(interval, otherInterval) {
const [start, end] = interval;
const [otherStart, otherEnd] = otherInterval;
return [start >= otherStart ? start : otherStart, end <= otherEnd ? end : otherEnd];
}
/**
* Computes intersection of a closed interval with a union of closed intervals ordered and disjoint
* = a union of intersections
*
* @param {[DateTime, DateTime]} interval
* @param {[DateTime, DateTime]} intervals
* @returns {[DateTime, DateTime][]}
*/
export function getUnionOfIntersections(interval, intervals) {
const [start, end] = interval;
const intersecting = intervals.filter((otherInterval) => {
const [otheStart, otherEnd] = otherInterval;
return otherEnd > start && end > otheStart;
});
const len = intersecting.length;
if (len === 0) {
return [];
}
const union = [];
const first = getIntersection(interval, intersecting[0]);
union.push(first);
if (len >= 2) {
const last = getIntersection(interval, intersecting[len - 1]);
union.push(...intersecting.slice(1, len - 1), last);
}
return union;
}
/**
* @param {Object} params
* @param {Ref<HTMLElement>} params.ref
* @param {string} params.selector
* @param {string} params.related
* @param {string} params.className
*/
export function useMultiHover({ ref, selector, related, className }) {
/**
* @param {HTMLElement} el
*/
const findSiblings = (el) =>
ref.el.querySelectorAll(
related
.map((attr) => `[${attr}='${el.getAttribute(attr).replace(/'/g, "\\'")}']`)
.join("")
);
/**
* @param {PointerEvent} ev
*/
const onPointerEnter = (ev) => {
for (const sibling of findSiblings(ev.target)) {
sibling.classList.add(...classList);
classedEls.add(sibling);
}
};
/**
* @param {PointerEvent} ev
*/
const onPointerLeave = (ev) => {
for (const sibling of findSiblings(ev.target)) {
sibling.classList.remove(...classList);
classedEls.delete(sibling);
}
};
const classList = className.split(/\s+/g);
const classedEls = new Set();
useEffect(
(...targets) => {
if (targets.length) {
for (const target of targets) {
target.addEventListener("pointerenter", onPointerEnter);
target.addEventListener("pointerleave", onPointerLeave);
}
return () => {
for (const el of classedEls) {
el.classList.remove(...classList);
}
classedEls.clear();
for (const target of targets) {
target.removeEventListener("pointerenter", onPointerEnter);
target.removeEventListener("pointerleave", onPointerLeave);
}
};
}
},
() => [...ref.el.querySelectorAll(selector)]
);
}
const NB_GANTT_RECORD_COLORS = 12;
function getElementCenter(el) {
const { x, y, width, height } = el.getBoundingClientRect();
return {
x: x + width / 2,
y: y + height / 2,
};
}
// Resizable hook handles
const HANDLE_CLASS_START = "o_handle_start";
const HANDLE_CLASS_END = "o_handle_end";
const handles = {
start: document.createElement("div"),
end: document.createElement("div"),
};
// Draggable hooks
export const useGanttConnectorDraggable = makeDraggableHook({
name: "useGanttConnectorDraggable",
acceptedParams: {
parentWrapper: [String],
},
onComputeParams({ ctx, params }) {
ctx.parentWrapper = params.parentWrapper;
ctx.followCursor = false;
},
onDragStart: ({ ctx, addStyle }) => {
const { current } = ctx;
const parent = current.element.closest(ctx.parentWrapper);
if (!parent) {
return;
}
for (const otherParent of ctx.ref.el.querySelectorAll(ctx.parentWrapper)) {
if (otherParent !== parent) {
addStyle(otherParent, { pointerEvents: "auto" });
}
}
return { sourcePill: parent, ...current.connectorCenter };
},
onDrag: ({ ctx }) => {
ctx.current.connectorCenter = getElementCenter(ctx.current.element);
return pick(ctx.current, "connectorCenter");
},
onDragEnd: ({ ctx }) => pick(ctx.current, "element"),
onDrop: ({ ctx, target }) => {
const { current } = ctx;
const parent = current.element.closest(ctx.parentWrapper);
const targetParent = target.closest(ctx.parentWrapper);
if (!targetParent || targetParent === parent) {
return;
}
return { target: targetParent };
},
onWillStartDrag: ({ ctx }) => {
ctx.current.connectorCenter = getElementCenter(ctx.current.element);
},
});
function getCoordinate(style, name) {
return +style.getPropertyValue(name).slice(1);
}
function getColumnStart(style) {
return getCoordinate(style, "grid-column-start");
}
function getColumnEnd(style) {
return getCoordinate(style, "grid-column-end");
}
export const useGanttDraggable = makeDraggableHook({
name: "useGanttDraggable",
acceptedParams: {
cells: [String, Function],
cellDragClassName: [String, Function],
ghostClassName: [String, Function],
hoveredCell: [Object],
addStickyCoordinates: [Function],
},
onComputeParams({ ctx, params }) {
ctx.cellSelector = params.cells;
ctx.ghostClassName = params.ghostClassName;
ctx.cellDragClassName = params.cellDragClassName;
ctx.hoveredCell = params.hoveredCell;
ctx.addStickyCoordinates = params.addStickyCoordinates;
},
onDragStart({ ctx }) {
const { current, ghostClassName } = ctx;
current.element.before(current.placeHolder);
if (ghostClassName) {
current.placeHolder.classList.add(ghostClassName);
}
return { pill: current.element };
},
onDrag({ ctx, addStyle }) {
const { cellSelector, current, hoveredCell } = ctx;
let { el: cell, part } = hoveredCell;
const isDifferentCell = cell !== current.cell.el;
const isDifferentPart = part !== current.cell.part;
if (cell && !cell.matches(cellSelector)) {
cell = null; // Not a cell
}
current.cell.el = cell;
current.cell.part = part;
if (cell) {
// Recompute cell style if in a different cell
if (isDifferentCell) {
const style = getComputedStyle(cell);
current.cell.gridRow = style.getPropertyValue("grid-row");
current.cell.gridColumnStart = getColumnStart(style) + current.gridColumnOffset;
}
// Assign new grid coordinates if in different cell or different cell part
if (isDifferentCell || isDifferentPart) {
const { pillSpan } = current;
const { gridRow, gridColumnStart: start } = current.cell;
const gridColumnStart = clamp(start + part, 1, current.maxGridColumnStart);
const gridColumnEnd = gridColumnStart + pillSpan;
addStyle(current.cellGhost, {
gridRow,
gridColumn: `c${gridColumnStart} / c${gridColumnEnd}`,
});
const [gridRowStart, gridRowEnd] = /r(\d+) \/ r(\d+)/g.exec(gridRow).slice(1);
ctx.addStickyCoordinates(
[gridRowStart, gridRowEnd],
[gridColumnStart, gridColumnEnd]
);
current.cell.col = gridColumnStart;
}
} else {
current.cell.col = null;
}
// Attach or remove cell ghost
if (isDifferentCell) {
if (cell) {
cell.after(current.cellGhost);
} else {
current.cellGhost.remove();
}
}
return { pill: current.element };
},
onDragEnd({ ctx }) {
return { pill: ctx.current.element };
},
onDrop({ ctx }) {
const { cell, element, initialCol } = ctx.current;
if (cell.col !== null) {
return {
pill: element,
cell: cell.el,
diff: cell.col - initialCol,
};
}
},
onWillStartDrag({ ctx, addCleanup, addClass }) {
const { current } = ctx;
const { el: cell, part } = ctx.hoveredCell;
current.placeHolder = current.element.cloneNode(true);
current.cellGhost = document.createElement("div");
current.cellGhost.className = ctx.cellDragClassName;
current.cell = { el: null, index: null, part: 0 };
const gridStyle = getComputedStyle(cell.parentElement);
const pillStyle = getComputedStyle(current.element);
const cellStyle = getComputedStyle(cell);
const gridTemplateColumns = gridStyle.getPropertyValue("grid-template-columns");
const pGridColumnStart = getColumnStart(pillStyle);
const pGridColumnEnd = getColumnEnd(pillStyle);
const cGridColumnStart = getColumnStart(cellStyle) + part;
let highestGridCol;
for (const e of gridTemplateColumns.split(/\s+/).reverse()) {
const res = /\[c(\d+)\]/g.exec(e);
if (res) {
highestGridCol = +res[1];
break;
}
}
const pillSpan = pGridColumnEnd - pGridColumnStart;
current.initialCol = pGridColumnStart;
current.maxGridColumnStart = highestGridCol - pillSpan;
current.gridColumnOffset = pGridColumnStart - cGridColumnStart;
current.pillSpan = pillSpan;
addClass(ctx.ref.el, "pe-auto");
addCleanup(() => {
current.placeHolder.remove();
current.cellGhost.remove();
});
},
});
export const useGanttUndraggable = makeDraggableHook({
name: "useGanttUndraggable",
onDragStart({ ctx }) {
return { pill: ctx.current.element };
},
onDragEnd({ ctx }) {
return { pill: ctx.current.element };
},
onWillStartDrag({ ctx, addCleanup, addClass, addStyle, getRect }) {
const { x, y, width, height } = getRect(ctx.current.element);
ctx.current.container = document.createElement("div");
addClass(ctx.ref.el, "pe-auto");
addStyle(ctx.current.container, {
position: "fixed",
left: `${x}px`,
top: `${y}px`,
width: `${width}px`,
height: `${height}px`,
});
ctx.current.element.after(ctx.current.container);
addCleanup(() => ctx.current.container.remove());
},
});
export const useGanttResizable = makeDraggableHook({
name: "useGanttResizable",
requiredParams: ["handles"],
acceptedParams: {
innerPills: [String, Function],
handles: [String, Function],
hoveredCell: [Object],
rtl: [Boolean, Function],
cells: [String, Function],
precision: [Number, Function],
showHandles: [Function],
},
onComputeParams({ ctx, params, addCleanup, addEffectCleanup, getRect }) {
const onElementPointerEnter = (ev) => {
if (ctx.dragging || ctx.willDrag) {
return;
}
const pill = ev.target;
const innerPill = pill.querySelector(params.innerPills);
const pillRect = getRect(innerPill);
for (const el of Object.values(handles)) {
el.style.height = `${pillRect.height}px`;
}
const showHandles = params.showHandles ? params.showHandles(pill) : {};
if ("start" in showHandles && !showHandles.start) {
handles.start.remove();
} else {
innerPill.appendChild(handles.start);
}
if ("end" in showHandles && !showHandles.end) {
handles.end.remove();
} else {
innerPill.appendChild(handles.end);
}
};
const onElementPointerLeave = () => {
const remove = () => Object.values(handles).forEach((h) => h.remove());
if (ctx.dragging || ctx.current.element) {
addCleanup(remove);
} else {
remove();
}
};
ctx.cellSelector = params.cells;
ctx.hoveredCell = params.hoveredCell;
ctx.precision = params.precision;
ctx.rtl = params.rtl;
for (const el of ctx.ref.el.querySelectorAll(params.elements)) {
el.addEventListener("pointerenter", onElementPointerEnter);
el.addEventListener("pointerleave", onElementPointerLeave);
addEffectCleanup(() => {
el.removeEventListener("pointerenter", onElementPointerEnter);
el.removeEventListener("pointerleave", onElementPointerLeave);
});
}
handles.start.className = `${params.handles} ${HANDLE_CLASS_START}`;
handles.start.style.cursor = `${params.rtl ? "e" : "w"}-resize`;
handles.end.className = `${params.handles} ${HANDLE_CLASS_END}`;
handles.end.style.cursor = `${params.rtl ? "w" : "e"}-resize`;
// Override "full" and "element" selectors: we want the draggable feature
// to apply to the handles
ctx.pillSelector = ctx.elementSelector;
ctx.fullSelector = ctx.elementSelector = `.${params.handles}`;
// Force the handles to stay in place
ctx.followCursor = false;
},
onDragStart({ ctx, addStyle }) {
addStyle(ctx.current.pill, { zIndex: 15 });
return { pill: ctx.current.pill };
},
onDrag({ ctx, addStyle, getRect }) {
const { cellSelector, current, hoveredCell, pointer, precision, rtl, ref } = ctx;
let { el: cell, part } = hoveredCell;
const point = [pointer.x, current.initialPosition.y];
if (!cell) {
let rect;
cell = document.elementsFromPoint(...point).find((el) => el.matches(cellSelector));
if (!cell) {
const cells = Array.from(ref.el.querySelectorAll(".o_gantt_cells .o_gantt_cell"));
if (pointer.x < current.initialPosition.x) {
cell = rtl ? cells.at(-1) : cells[0];
} else {
cell = rtl ? cells[0] : cells.at(-1);
}
rect = getRect(cell);
point[0] = rtl ? rect.right - 1 : rect.left + 1;
} else {
rect = getRect(cell);
}
const x = Math.floor(rect.x);
const width = Math.floor(rect.width);
part = Math.floor((point[0] - x) / (width / precision));
}
const cellStyle = getComputedStyle(cell);
const cGridColStart = getColumnStart(cellStyle);
const { x, width } = getRect(cell);
const coef = ((rtl ? -1 : 1) * width) / precision;
const startBorder = (rtl ? x + width : x) + part * coef;
const endBorder = startBorder + coef;
const theClosest = closest(point[0], [startBorder, endBorder]);
let diff =
cGridColStart +
part +
(theClosest === startBorder ? 0 : 1) -
(current.isStart ? current.firstCol : current.lastCol);
if (diff === current.lastDiff) {
return;
}
if (current.isStart) {
diff = Math.min(diff, current.initialDiff - 1);
addStyle(current.pill, { "grid-column-start": `c${current.firstCol + diff}` });
} else {
diff = Math.max(diff, 1 - current.initialDiff);
addStyle(current.pill, { "grid-column-end": `c${current.lastCol + diff}` });
}
current.lastDiff = diff;
const isLeftHandle = rtl ? !current.isStart : current.isStart;
const grabbedHandle = isLeftHandle ? "left" : "right";
diff = current.isStart ? -diff : diff;
return { pill: current.pill, grabbedHandle, diff };
},
onDragEnd({ ctx }) {
const { current, pillSelector } = ctx;
const pill = current.element.closest(pillSelector);
return { pill };
},
onDrop({ ctx }) {
const { current } = ctx;
if (!current.lastDiff) {
return;
}
const direction = current.isStart ? "start" : "end";
return { pill: current.pill, diff: current.lastDiff, direction };
},
onWillStartDrag({ ctx, addClass }) {
const { current, pillSelector } = ctx;
const pill = ctx.current.element.closest(pillSelector);
current.pill = pill;
const pillStyle = getComputedStyle(pill);
current.firstCol = getColumnStart(pillStyle);
current.lastCol = getColumnEnd(pillStyle);
current.initialDiff = current.lastCol - current.firstCol;
ctx.cursor = getComputedStyle(current.element).cursor;
current.isStart = current.element.classList.contains(HANDLE_CLASS_START);
addClass(ctx.ref.el, "pe-auto");
},
});
function getCellsOnRow(refEl, rowId) {
return refEl.querySelectorAll(
`.o_gantt_cell:not(.o_gantt_group)[data-row-id='${CSS.escape(rowId)}']`
);
}
function getMinMax(a, b) {
return a <= b ? [a, b] : [b, a];
}
export const useGanttSelectable = makeDraggableHook({
name: "useGanttSelectable",
acceptedParams: {
hoveredCell: [Object],
rtl: [Boolean, Function],
},
onComputeParams({ ctx, params }) {
ctx.followCursor = false;
ctx.hoveredCell = params.hoveredCell;
ctx.rtl = params.rtl;
},
onDrag({ ctx, addClass, getRect, removeClass }) {
const { current, hoveredCell, pointer, ref, rtl } = ctx;
let { el: cell } = hoveredCell;
if (!cell) {
const point = [pointer.x, current.initialPosition.y];
cell = document.elementsFromPoint(...point).find((el) => el.matches(".o_gantt_cell"));
if (!cell) {
const cells = Array.from(ref.el.querySelectorAll(".o_gantt_cells .o_gantt_cell"));
if (pointer.x < current.initialPosition.x) {
cell = rtl ? cells.at(-1) : cells[0];
} else {
cell = rtl ? cells[0] : cells.at(-1);
}
}
}
const col = +cell.dataset.col;
const lastSelectedCol = current.lastSelectedCol;
current.lastSelectedCol = col;
if (lastSelectedCol === col) {
return;
}
const [startCol, stopCol] = getMinMax(current.initialCol, col);
for (const cell of getCellsOnRow(ref.el, current.rowId)) {
const cellCol = +cell.dataset.col;
if (cellCol < startCol || cellCol > stopCol) {
removeClass(cell, "o_drag_hover");
} else {
addClass(cell, "o_drag_hover");
}
}
},
onDrop({ ctx }) {
const { current } = ctx;
const { rowId, initialCol, lastSelectedCol } = current;
const [startCol, stopCol] = getMinMax(initialCol, lastSelectedCol);
return { rowId, startCol, stopCol };
},
onWillStartDrag({ ctx, addClass }) {
const { current, hoveredCell, ref } = ctx;
const { el: cell } = hoveredCell;
current.rowId = cell.dataset.rowId;
current.initialCol = +cell.dataset.col;
addClass(ref.el, "pe-auto");
addClass(cell, "pe-auto");
},
});
/**
* Same as usePopover, but replaces the popover by a dialog when display size is small.
*
* @param {typeof import("@odoo/owl").Component} component
* @param {import("@web/core/popover/popover_service").PopoverServiceAddOptions} [options]
* @returns {import("@web/core/popover/popover_hook").PopoverHookReturnType}
*/
export function useGanttResponsivePopover(dialogTitle, component, options = {}) {
const dialogService = useService("dialog");
const env = useEnv();
const owner = useComponent();
const popover = usePopover(component, options);
const onClose = () => {
if (status(owner) !== "destroyed") {
options.onClose?.();
}
};
const dialogAddFn = (_, comp, props, options) => dialogService.add(comp, props, options);
const popoverInDialog = makePopover(dialogAddFn, GanttPopoverInDialog, { onClose });
const ganttReponsivePopover = {
open: (target, props) => {
if (env.isSmall) {
popoverInDialog.open(target, {
component: component,
componentProps: props,
dialogTitle,
});
} else {
popover.open(target, props);
}
},
close: () => {
popover.close();
popoverInDialog.close();
},
get isOpen() {
return popover.isOpen || popoverInDialog.isOpen;
},
};
onWillUnmount(ganttReponsivePopover.close);
return ganttReponsivePopover;
}