import { Component, onWillRender, onWillStart, onWillUpdateProps, reactive, useEffect, useExternalListener, useRef, markup, } from "@odoo/owl"; import { hasTouch, isMobileOS } from "@web/core/browser/feature_detection"; import { Domain } from "@web/core/domain"; import { getStartOfLocalWeek, is24HourFormat, serializeDate, serializeDateTime, } from "@web/core/l10n/dates"; import { localization } from "@web/core/l10n/localization"; import { _t } from "@web/core/l10n/translation"; import { usePopover } from "@web/core/popover/popover_hook"; import { evaluateBooleanExpr } from "@web/core/py_js/py"; import { user } from "@web/core/user"; import { useService } from "@web/core/utils/hooks"; import { omit, pick } from "@web/core/utils/objects"; import { debounce, throttleForAnimation } from "@web/core/utils/timing"; import { url } from "@web/core/utils/urls"; import { escape } from "@web/core/utils/strings"; import { useVirtualGrid } from "@web/core/virtual_grid_hook"; import { formatFloatTime } from "@web/views/fields/formatters"; import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog"; import { GanttConnector } from "./gantt_connector"; import { dateAddFixedOffset, diffColumn, getCellColor, getColorIndex, localEndOf, localStartOf, useGanttConnectorDraggable, useGanttDraggable, useGanttResizable, useGanttSelectable, useGanttUndraggable, useMultiHover, } from "./gantt_helpers"; import { GanttPopover } from "./gantt_popover"; import { GanttRendererControls } from "./gantt_renderer_controls"; import { GanttResizeBadge } from "./gantt_resize_badge"; import { GanttRowProgressBar } from "./gantt_row_progress_bar"; import { clamp } from "@web/core/utils/numbers"; const { DateTime } = luxon; /** * @typedef {`__column__${number}`} ColumnId * @typedef {`__connector__${number | "new"}`} ConnectorId * @typedef {import("./gantt_connector").ConnectorProps} ConnectorProps * @typedef {luxon.DateTime} DateTime * @typedef {"copy" | "reschedule"} DragActionMode * @typedef {"drag" | "locked" | "resize"} InteractionMode * @typedef {`__pill__${number}`} PillId * @typedef {import("./gantt_model").RowId} RowId * * @typedef Column * @property {ColumnId} id * @property {GridPosition} grid * @property {boolean} [isToday] * @property {DateTime} start * @property {DateTime} stop * * @typedef GridPosition * @property {number | number[]} [row] * @property {number | number[]} [column] * * @typedef Group * @property {boolean} break * @property {number} col * @property {Pill[]} pills * @property {number} aggregateValue * @property {GridPosition} grid * * @typedef GanttRendererProps * @property {import("./gantt_model").GanttModel} model * @property {Document} arch * @property {string} class * @property {(context: Record)} create * @property {{ content?: Point }} [scrollPosition] * @property {{ el: HTMLDivElement | null }} [contentRef] * * @typedef HoveredInfo * @property {Element | null} connector * @property {HTMLElement | null} hoverable * @property {HTMLElement | null} pill * * @typedef Interaction * @property {InteractionMode | null} mode * @property {DragActionMode} dragAction * * @typedef Pill * @property {PillId} id * @property {boolean} disableStartResize * @property {boolean} disableStopResize * @property {boolean} highlighted * @property {number} leftMargin * @property {number} level * @property {string} name * @property {DateTime} startDate * @property {DateTime} stopDate * @property {GridPosition} grid * @property {RelationalRecord} record * @property {number} _color * @property {number} _progress * * @typedef Point * @property {number} [x] * @property {number} [y] * * @typedef {Record} RelationalRecord * @property {number | false} id * * @typedef ResizeBadge * @property {Point & { right?: number }} position * @property {number} diff * @property {string} scale * * @typedef {import("./gantt_model").Row & { * grid: GridPosition, * pills: Pill[], * cellColors?: Record, * thumbnailUrl?: string * }} Row * * @typedef SubColumn * @property {ColumnId} columnId * @property {boolean} [isToday] * @property {DateTime} start * @property {DateTime} stop */ /** @type {[Omit | DragActionMode, string][]} */ const INTERACTION_CLASSNAMES = [ ["connect", "o_connect"], ["copy", "o_copying"], ["locked", "o_grabbing_locked"], ["reschedule", "o_grabbing"], ["resize", "o_resizing"], ]; const NEW_CONNECTOR_ID = "__connector__new"; /** * Gantt Renderer * * @extends {Component} */ export class GanttRenderer extends Component { static components = { GanttConnector, GanttRendererControls, GanttResizeBadge, GanttRowProgressBar, Popover: GanttPopover, }; static props = [ "model", "arch", "class", "create", "openDialog", "scrollPosition?", "contentRef?", ]; static template = "web_gantt.GanttRenderer"; static connectorCreatorTemplate = "web_gantt.GanttRenderer.ConnectorCreator"; static headerTemplate = "web_gantt.GanttRenderer.Header"; static pillTemplate = "web_gantt.GanttRenderer.Pill"; static groupPillTemplate = "web_gantt.GanttRenderer.GroupPill"; static rowContentTemplate = "web_gantt.GanttRenderer.RowContent"; static rowHeaderTemplate = "web_gantt.GanttRenderer.RowHeader"; static totalRowTemplate = "web_gantt.GanttRenderer.TotalRow"; static getRowHeaderWidth = (width) => 100 / (width > 768 ? 6 : 3); setup() { this.model = this.props.model; this.gridRef = useRef("grid"); this.cellContainerRef = useRef("cellContainer"); this.actionService = useService("action"); this.dialogService = useService("dialog"); this.notificationService = useService("notification"); this.is24HourFormat = is24HourFormat(); /** @type {HoveredInfo} */ this.hovered = { connector: null, hoverable: null, pill: null, }; /** @type {Interaction} */ this.interaction = reactive( { mode: null, dragAction: "reschedule", }, () => this.onInteractionChange() ); this.onInteractionChange(); // Used to hook into "interaction" /** @type {Record} */ this.connectors = reactive({}); this.progressBarsReactive = reactive({ hoveredRowId: null }); /** @type {ResizeBadge} */ this.resizeBadgeReactive = reactive({}); /** @type {Object[]} */ this.columnsGroups = []; /** @type {Column[]} */ this.columns = []; /** @type {Pill[]} */ this.extraPills = []; /** @type {Record} */ this.pills = {}; // mapping to retrieve pills from pill ids /** @type {Row[]} */ this.rows = []; /** @type {SubColumn[]} */ this.subColumns = []; /** @type {Record} */ this.rowPills = {}; this.mappingColToColumn = new Map(); this.mappingColToSubColumn = new Map(); this.cursorPosition = { x: 0, y: 0, }; const position = "bottom"; this.popover = usePopover(this.constructor.components.Popover, { position, onPositioned: (el, { direction }) => { if (direction !== position) { return; } const { left, right } = el.getBoundingClientRect(); if ((0 <= left && right <= window.innerWidth) || window.innerWidth < right - left) { return; } const { left: pillLeft, right: pillRight } = this.popover.target.getBoundingClientRect(); const middle = (clamp(pillLeft, 0, window.innerWidth) + clamp(pillRight, 0, window.innerWidth)) / 2; el.style.left = `0px`; const { width } = el.getBoundingClientRect(); el.style.left = `${middle - width / 2}px`; }, onClose: () => { delete this.popover.target; }, }); this.throttledComputeHoverParams = throttleForAnimation((ev) => this.computeHoverParams(ev) ); useExternalListener(window, "keydown", (ev) => this.onWindowKeyDown(ev)); useExternalListener(window, "keyup", (ev) => this.onWindowKeyUp(ev)); useExternalListener( window, "resize", debounce(() => { this.shouldComputeSomeWidths = true; this.render(); }, 100) ); useMultiHover({ ref: this.gridRef, selector: ".o_gantt_group", related: ["data-row-id"], className: "o_gantt_group_hovered", }); // Draggable pills this.cellForDrag = { el: null, part: 0 }; const dragState = useGanttDraggable({ enable: () => Boolean(this.cellForDrag.el), // Refs and selectors ref: this.gridRef, hoveredCell: this.cellForDrag, elements: ".o_draggable", ignore: ".o_resize_handle,.o_connector_creator_bullet", cells: ".o_gantt_cell", // Style classes cellDragClassName: "o_gantt_cell o_drag_hover", ghostClassName: "o_dragged_pill_ghost", addStickyCoordinates: (rows, columns) => { this.stickyGridRows = Object.assign({}, ...rows.map((row) => ({ [row]: true }))); this.stickyGridColumns = Object.assign( {}, ...columns.map((column) => ({ [column]: true })) ); this.setSomeGridStyleProperties(); }, // Handlers onDragStart: ({ pill }) => { this.popover.close(); this.setStickyPill(pill); this.interaction.mode = "drag"; }, onDragEnd: () => { this.setStickyPill(); this.interaction.mode = null; }, onDrop: (params) => this.dragPillDrop(params), }); // Un-draggable pills const unDragState = useGanttUndraggable({ // Refs and selectors ref: this.gridRef, elements: ".o_undraggable", ignore: ".o_resize_handle,.o_connector_creator_bullet", edgeScrolling: { enabled: false }, // Handlers onDragStart: () => { this.interaction.mode = "locked"; }, onDragEnd: () => { this.interaction.mode = null; }, }); // Cells selection const selectState = useGanttSelectable({ enable: () => { const { canCellCreate, canPlan } = this.model.metaData; return Boolean(this.cellForDrag.el) && (canCellCreate || canPlan); }, ref: this.gridRef, hoveredCell: this.cellForDrag, elements: ".o_gantt_cell:not(.o_gantt_group)", edgeScrolling: { speed: 40, threshold: 150, direction: "horizontal" }, rtl: () => localization.direction === "rtl", onDrop: ({ rowId, startCol, stopCol }) => { const { canPlan } = this.model.metaData; if (canPlan) { this.onPlan(rowId, startCol, stopCol); } else { this.onCreate(rowId, startCol, stopCol); } }, }); // Resizable pills const resizeState = useGanttResizable({ // Refs and selectors ref: this.gridRef, hoveredCell: this.cellForDrag, elements: ".o_resizable", innerPills: ".o_gantt_pill", cells: ".o_gantt_cell", // Other params handles: "o_resize_handle", edgeScrolling: { speed: 40, threshold: 150, direction: "horizontal" }, showHandles: (pillEl) => { const pill = this.pills[pillEl.dataset.pillId]; const hideHandles = this.connectorDragState.dragging; return { start: !pill.disableStartResize && !hideHandles, end: !pill.disableStopResize && !hideHandles, }; }, rtl: () => localization.direction === "rtl", precision: () => this.model.metaData.scale.cellPart, // Handlers onDragStart: ({ pill, addClass }) => { this.popover.close(); this.setStickyPill(pill); addClass(pill, "o_resized"); this.interaction.mode = "resize"; }, onDrag: ({ pill, grabbedHandle, diff }) => { const rect = pill.getBoundingClientRect(); const position = { top: rect.y + rect.height }; if (grabbedHandle === "left") { position.left = rect.x; } else { position.right = document.body.offsetWidth - rect.x - rect.width; } const { cellTime, unitDescription } = this.model.metaData.scale; Object.assign(this.resizeBadgeReactive, { position, diff: diff * cellTime, scale: unitDescription, }); }, onDragEnd: ({ pill, removeClass }) => { delete this.resizeBadgeReactive.position; delete this.resizeBadgeReactive.diff; delete this.resizeBadgeReactive.scale; this.setStickyPill(); removeClass(pill, "o_resized"); this.interaction.mode = null; }, onDrop: (params) => this.resizePillDrop(params), }); // Draggable connector let initialPillId; this.connectorDragState = useGanttConnectorDraggable({ ref: this.gridRef, elements: ".o_connector_creator_bullet", parentWrapper: ".o_gantt_cells .o_gantt_pill_wrapper", onDragStart: ({ sourcePill, x, y, addClass }) => { this.popover.close(); initialPillId = sourcePill.dataset.pillId; addClass(sourcePill, "o_connector_creator_lock"); this.setConnector({ id: NEW_CONNECTOR_ID, highlighted: true, sourcePoint: { left: x, top: y }, targetPoint: { left: x, top: y }, }); this.setStickyPill(sourcePill); this.interaction.mode = "connect"; }, onDrag: ({ connectorCenter, x, y }) => { this.setConnector({ id: NEW_CONNECTOR_ID, sourcePoint: { left: connectorCenter.x, top: connectorCenter.y }, targetPoint: { left: x, top: y }, }); }, onDragEnd: () => { this.setConnector({ id: NEW_CONNECTOR_ID, sourcePoint: null, targetPoint: null }); this.setStickyPill(); this.interaction.mode = null; }, onDrop: ({ target }) => { if (initialPillId === target.dataset.pillId) { return; } const { id: masterId } = this.pills[initialPillId].record; const { id: slaveId } = this.pills[target.dataset.pillId].record; this.model.createDependency(masterId, slaveId); }, }); this.dragStates = [dragState, unDragState, resizeState, selectState]; onWillStart(this.computeDerivedParams); onWillUpdateProps(this.computeDerivedParams); this.virtualGrid = useVirtualGrid({ scrollableRef: this.props.contentRef, initialScroll: this.props.scrollPosition, bufferCoef: 0.1, onChange: (changed) => { if ("columnsIndexes" in changed) { this.shouldComputeGridColumns = true; } if ("rowsIndexes" in changed) { this.shouldComputeGridRows = true; } this.render(); }, }); onWillRender(this.onWillRender); useEffect( (content) => { content.addEventListener("scroll", this.throttledComputeHoverParams); return () => { content.removeEventListener("scroll", this.throttledComputeHoverParams); }; }, () => [this.gridRef.el?.parentElement] ); useEffect(() => { if (this.useFocusDate) { this.useFocusDate = false; this.focusDate(this.model.metaData.focusDate); } }); this.env.getCurrentFocusDateCallBackRecorder.add(this, this.getCurrentFocusDate.bind(this)); } //------------------------------------------------------------------------- // Getters //------------------------------------------------------------------------- get controlsProps() { return { displayExpandCollapseButtons: this.rows[0]?.isGroup, // all rows on same level have same type model: this.model, focusToday: () => this.focusToday(), getCurrentFocusDate: () => this.getCurrentFocusDate(), }; } /** * @returns {boolean} */ get hasRowHeaders() { const { groupedBy } = this.model.metaData; const { displayMode } = this.model.displayParams; return groupedBy.length || displayMode === "sparse"; } get isDragging() { return this.dragStates.some((s) => s.dragging); } /** * @returns {boolean} */ get isTouchDevice() { return isMobileOS() || hasTouch(); } //------------------------------------------------------------------------- // Methods //------------------------------------------------------------------------- /** * * @param {Object} param * @param {Object} param.grid */ addCoordinatesToCoarseGrid({ grid }) { if (grid.row) { this.coarseGridRows[this.getFirstGridRow({ grid })] = true; this.coarseGridRows[this.getLastGridRow({ grid })] = true; } if (grid.column) { this.coarseGridCols[this.getFirstGridCol({ grid })] = true; this.coarseGridCols[this.getLastGridCol({ grid })] = true; } } /** * @param {Pill} pill * @param {Group} group */ addTo(pill, group) { group.pills.push(pill); group.aggregateValue++; // pill count return true; } /** * Conditional function for aggregating pills when grouping the gantt view * The first, unused parameter is added in case it's needed when overwriting the method. * @param {Row} row * @param {Group} group * @returns {boolean} */ shouldAggregate(row, group) { return Boolean(group.pills.length); } /** * Aggregates overlapping pills in group rows. * * @param {Pill[]} pills * @param {Row} row */ aggregatePills(pills, row) { /** @type {Record} */ const groups = {}; function getGroup(col) { if (!(col in groups)) { groups[col] = { break: false, col, pills: [], aggregateValue: 0, grid: { column: [col, col + 1] }, }; // group.break = true means that the group cannot be merged with the previous one // We will merge groups that can be merged together (if this.shouldMergeGroups returns true) } return groups[col]; } for (const pill of pills) { let addedInPreviousCol = false; let col; for (col = this.getFirstGridCol(pill); col < this.getLastGridCol(pill); col++) { const group = getGroup(col); const added = this.addTo(pill, group); if (addedInPreviousCol !== added) { group.break = true; } addedInPreviousCol = added; } // here col = this.getLastGridCol(pill) if (addedInPreviousCol && col <= this.columnCount) { const group = getGroup(col); group.break = true; } } const filteredGroups = Object.values(groups).filter((g) => this.shouldAggregate(row, g)); if (this.shouldMergeGroups()) { return this.mergeGroups(filteredGroups); } return filteredGroups; } /** * Compute minimal levels required to display all pills without overlapping. * Side effect: level key is modified in pills. * * @param {Pill[]} pills */ calculatePillsLevel(pills) { const firstPill = pills[0]; firstPill.level = 0; const levels = [ { pills: [firstPill], maxCol: this.getLastGridCol(firstPill) - 1, }, ]; for (const currentPill of pills.slice(1)) { const lastCol = this.getLastGridCol(currentPill) - 1; for (let l = 0; l < levels.length; l++) { const level = levels[l]; if (this.getFirstGridCol(currentPill) > level.maxCol) { currentPill.level = l; level.pills.push(currentPill); level.maxCol = lastCol; break; } } if (isNaN(currentPill.level)) { currentPill.level = levels.length; levels.push({ pills: [currentPill], maxCol: lastCol, }); } } return levels.length; } makeSubColumn(start, delta, cellTime, time) { const subCellStart = dateAddFixedOffset(start, { [time]: delta * cellTime }); const subCellStop = dateAddFixedOffset(start, { [time]: (delta + 1) * cellTime, seconds: -1, }); return { start: subCellStart, stop: subCellStop }; } computeVisibleColumns() { const [firstIndex, lastIndex] = this.virtualGrid.columnsIndexes; this.columnsGroups = []; this.columns = []; this.subColumns = []; this.coarseGridCols = { 1: true, [this.columnCount * this.model.metaData.scale.cellPart + 1]: true, }; const { globalStart, globalStop, scale } = this.model.metaData; const { cellPart, interval, unit } = scale; const now = DateTime.local(); const nowStart = now.startOf(interval); const nowEnd = now.endOf(interval); const groupsLeftBound = DateTime.max( globalStart, localStartOf(globalStart.plus({ [interval]: firstIndex }), unit) ); const groupsRightBound = DateTime.min( localEndOf(globalStart.plus({ [interval]: lastIndex }), unit), globalStop ); let currentGroup = null; for (let j = firstIndex; j <= lastIndex; j++) { const columnId = `__column__${j + 1}`; const col = j * cellPart + 1; const { start, stop } = this.getColumnFromColNumber(col); const column = { id: columnId, grid: { column: [col, col + cellPart] }, start, stop, }; const isToday = nowStart <= start && start <= nowEnd; if (isToday) { column.isToday = true; } this.columns.push(column); for (let i = 0; i < cellPart; i++) { const subColumn = this.getSubColumnFromColNumber(col + i); this.subColumns.push({ ...subColumn, isToday, columnId }); this.coarseGridCols[col + i] = true; } const groupStart = localStartOf(start, unit); if (!currentGroup || !groupStart.equals(currentGroup.start)) { const groupId = `__group__${this.columnsGroups.length + 1}`; const startingBound = DateTime.max(groupsLeftBound, groupStart); const endingBound = DateTime.min(groupsRightBound, localEndOf(groupStart, unit)); const [groupFirstCol, groupLastCol] = this.getGridColumnFromDates( startingBound, endingBound ); currentGroup = { id: groupId, grid: { column: [groupFirstCol, groupLastCol] }, start: groupStart, }; this.columnsGroups.push(currentGroup); this.coarseGridCols[groupFirstCol] = true; this.coarseGridCols[groupLastCol] = true; } } } computeVisibleRows() { this.coarseGridRows = { 1: true, [this.getLastGridRow(this.rows[this.rows.length - 1])]: true, }; const [rowStart, rowEnd] = this.virtualGrid.rowsIndexes; this.rowsToRender = new Set(); for (const row of this.rows) { const [first, last] = row.grid.row; if (last <= rowStart + 1 || first > rowEnd + 1) { continue; } this.addToRowsToRender(row); } } getFirstGridCol({ grid }) { const [first] = grid.column; return first; } getLastGridCol({ grid }) { const [, last] = grid.column; return last; } getFirstGridRow({ grid }) { const [first] = grid.row; return first; } getLastGridRow({ grid }) { const [, last] = grid.row; return last; } addToPillsToRender(pill) { this.pillsToRender.add(pill); this.addCoordinatesToCoarseGrid(pill); } addToRowsToRender(row) { this.rowsToRender.add(row); const [first, last] = row.grid.row; for (let i = first; i <= last; i++) { this.coarseGridRows[i] = true; } } /** * give bounds only */ getVisibleCols() { const [columnStart, columnEnd] = this.virtualGrid.columnsIndexes; const { cellPart } = this.model.metaData.scale; const firstVisibleCol = 1 + cellPart * columnStart; const lastVisibleCol = 1 + cellPart * (columnEnd + 1); return [firstVisibleCol, lastVisibleCol]; } /** * give bounds only */ getVisibleRows() { const [rowStart, rowEnd] = this.virtualGrid.rowsIndexes; const firstVisibleRow = rowStart + 1; const lastVisibleRow = rowEnd + 1; return [firstVisibleRow, lastVisibleRow]; } computeVisiblePills() { this.pillsToRender = new Set(); const [firstVisibleCol, lastVisibleCol] = this.getVisibleCols(); const [firstVisibleRow, lastVisibleRow] = this.getVisibleRows(); const isOut = (pill, filterOnRow = true) => this.getFirstGridCol(pill) > lastVisibleCol || this.getLastGridCol(pill) < firstVisibleCol || (filterOnRow && (this.getFirstGridRow(pill) > lastVisibleRow || this.getLastGridRow(pill) - 1 < firstVisibleRow)); const getRowPills = (row, filterOnRow) => (this.rowPills[row.id] || []).filter((pill) => !isOut(pill, filterOnRow)); for (const row of this.rowsToRender) { for (const rowPill of getRowPills(row)) { this.addToPillsToRender(rowPill); } if (!row.isGroup && row.unavailabilities?.length) { row.cellColors = this.getRowCellColors(row); } } if (this.stickyPillId) { this.addToPillsToRender(this.pills[this.stickyPillId]); } if (this.totalRow) { this.totalRow.pills = getRowPills(this.totalRow, false); for (const pill of this.totalRow.pills) { this.addCoordinatesToCoarseGrid({ grid: omit(pill.grid, "row") }); } } } computeVisibleConnectors() { const visibleConnectorIds = new Set([NEW_CONNECTOR_ID]); for (const pill of this.pillsToRender) { const row = this.getRowFromPill(pill); if (row.isGroup) { continue; } for (const connectorId of this.mappingPillToConnectors[pill.id] || []) { visibleConnectorIds.add(connectorId); } } this.connectorsToRender = []; for (const connectorId in this.connectors) { if (!visibleConnectorIds.has(connectorId)) { continue; } this.connectorsToRender.push(this.connectors[connectorId]); const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId]; if (sourcePillId) { this.addToPillsToRender(this.pills[sourcePillId]); } if (targetPillId) { this.addToPillsToRender(this.pills[targetPillId]); } } } getRowFromPill(pill) { return this.rowByIds[pill.rowId]; } getColInCoarseGridKeys() { return Object.keys({ ...this.coarseGridCols, ...this.stickyGridColumns }); } getRowInCoarseGridKeys() { return Object.keys({ ...this.coarseGridRows, ...this.stickyGridRows }); } computeColsTemplate() { const colsTemplate = []; const colInCoarseGridKeys = this.getColInCoarseGridKeys(); for (let i = 0; i < colInCoarseGridKeys.length - 1; i++) { const x = +colInCoarseGridKeys[i]; const y = +colInCoarseGridKeys[i + 1]; const colName = `c${x}`; const width = (y - x) * this.cellPartWidth; colsTemplate.push(`[${colName}]minmax(${width}px,1fr)`); } colsTemplate.push(`[c${colInCoarseGridKeys.at(-1)}]`); return colsTemplate.join(""); } computeRowsTemplate() { const rowsTemplate = []; const rowInCoarseGridKeys = this.getRowInCoarseGridKeys(); for (let i = 0; i < rowInCoarseGridKeys.length - 1; i++) { const x = +rowInCoarseGridKeys[i]; const y = +rowInCoarseGridKeys[i + 1]; const rowName = `r${x}`; const height = this.gridRows.slice(x - 1, y - 1).reduce((a, b) => a + b, 0); rowsTemplate.push(`[${rowName}]${height}px`); } rowsTemplate.push(`[r${rowInCoarseGridKeys.at(-1)}]`); return rowsTemplate.join(""); } computeSomeWidths() { const { cellPart, minimalColumnWidth } = this.model.metaData.scale; this.contentRefWidth = this.props.contentRef.el?.clientWidth ?? document.body.clientWidth; const rowHeaderWidthPercentage = this.hasRowHeaders ? this.constructor.getRowHeaderWidth(this.contentRefWidth) : 0; this.rowHeaderWidth = this.hasRowHeaders ? Math.round((rowHeaderWidthPercentage * this.contentRefWidth) / 100) : 0; const cellContainerWidth = this.contentRefWidth - this.rowHeaderWidth; const columnWidth = Math.floor(cellContainerWidth / this.columnCount); const rectifiedColumnWidth = Math.max(columnWidth, minimalColumnWidth); this.cellPartWidth = Math.floor(rectifiedColumnWidth / cellPart); this.columnWidth = this.cellPartWidth * cellPart; if (columnWidth <= minimalColumnWidth) { // overflow this.totalWidth = this.rowHeaderWidth + this.columnWidth * this.columnCount; } else { this.totalWidth = null; } } computeDerivedParams() { const { rows: modelRows } = this.model.data; if (this.shouldRenderConnectors()) { /** @type {Record }>} */ this.mappingRecordToPillsByRow = {}; /** @type {Record>} */ this.mappingRowToPillsByRecord = {}; /** @type {Record} */ this.mappingConnectorToPills = {}; /** @type {Record} */ this.mappingPillToConnectors = {}; } const { globalStart, globalStop, scale, startDate, stopDate } = this.model.metaData; this.columnCount = diffColumn(globalStart, globalStop, scale.interval); if ( !this.currentStartDate || diffColumn(this.currentStartDate, startDate, "day") || diffColumn(this.currentStopDate, stopDate, "day") || this.currentScaleId !== scale.id ) { this.useFocusDate = true; this.mappingColToColumn = new Map(); this.mappingColToSubColumn = new Map(); } this.currentStartDate = startDate; this.currentStopDate = stopDate; this.currentScaleId = scale.id; this.currentGridRow = 1; this.gridRows = []; this.nextPillId = 1; this.pills = {}; // mapping to retrieve pills from pill ids this.rows = []; this.rowPills = {}; this.rowByIds = {}; const prePills = this.getPills(); let pillsToProcess = [...prePills]; for (const row of modelRows) { const result = this.processRow(row, pillsToProcess); this.rows.push(...result.rows); pillsToProcess = result.pillsToProcess; } const { displayTotalRow } = this.model.metaData; if (displayTotalRow) { this.totalRow = this.getTotalRow(prePills); } if (this.shouldRenderConnectors()) { this.initializeConnectors(); this.generateConnectors(); } this.shouldComputeSomeWidths = true; this.shouldComputeGridColumns = true; this.shouldComputeGridRows = true; } computeDerivedParamsFromHover() { const { scale } = this.model.metaData; const { connector, hoverable, pill } = this.hovered; // Update cell in drag const isCellHovered = hoverable?.matches(".o_gantt_cell"); this.cellForDrag.el = isCellHovered ? hoverable : null; this.cellForDrag.part = 0; if (isCellHovered && scale.cellPart > 1) { const rect = hoverable.getBoundingClientRect(); const x = Math.floor(rect.x); const width = Math.floor(rect.width); this.cellForDrag.part = Math.floor( (this.cursorPosition.x - x) / (width / scale.cellPart) ); if (localization.direction === "rtl") { this.cellForDrag.part = scale.cellPart - 1 - this.cellForDrag.part; } } if (this.isDragging) { this.progressBarsReactive.hoveredRowId = null; return; } if (!this.connectorDragState.dragging) { // Highlight connector const hoveredConnectorId = connector?.dataset.connectorId; for (const connectorId in this.connectors) { if (connectorId !== hoveredConnectorId) { this.toggleConnectorHighlighting(connectorId, false); } } if (hoveredConnectorId) { this.progressBarsReactive.hoveredRowId = null; return this.toggleConnectorHighlighting(hoveredConnectorId, true); } } // Highlight pill const hoveredPillId = pill?.dataset.pillId; for (const pillId in this.pills) { if (pillId !== hoveredPillId) { this.togglePillHighlighting(pillId, false); } } this.togglePillHighlighting(hoveredPillId, true); // Update progress bars this.progressBarsReactive.hoveredRowId = hoverable ? hoverable.dataset.rowId : null; } /** * @param {ConnectorId} connectorId */ deleteConnector(connectorId) { delete this.connectors[connectorId]; delete this.mappingConnectorToPills[connectorId]; } /** * @param {Object} params * @param {Element} params.pill * @param {Element} params.cell * @param {number} params.diff */ async dragPillDrop({ pill, cell, diff }) { const { rowId } = cell.dataset; const { dateStartField, dateStopField, scale } = this.model.metaData; const { cellTime, time } = scale; const { record } = this.pills[pill.dataset.pillId]; const params = this.getScheduleParams(pill); params.start = diff && dateAddFixedOffset(record[dateStartField], { [time]: cellTime * diff }); params.stop = diff && dateAddFixedOffset(record[dateStopField], { [time]: cellTime * diff }); params.rowId = rowId; const schedule = this.model.getSchedule(params); if (this.interaction.dragAction === "copy") { await this.model.copy(record.id, schedule, this.openPlanDialogCallback); } else { await this.model.reschedule(record.id, schedule, this.openPlanDialogCallback); } // If the pill lands on a closed group -> open it if (cell.classList.contains("o_gantt_group") && this.model.isClosed(rowId)) { this.model.toggleRow(rowId); } } /** * @param {Partial} pill * @returns {Pill} */ enrichPill(pill) { const { colorField, fields, pillDecorations, progressField } = this.model.metaData; pill.displayName = this.getDisplayName(pill); const classes = []; if (pillDecorations) { const pillContext = Object.assign({}, user.context); for (const [fieldName, value] of Object.entries(pill.record)) { const field = fields[fieldName]; switch (field.type) { case "date": { pillContext[fieldName] = value ? serializeDate(value) : false; break; } case "datetime": { pillContext[fieldName] = value ? serializeDateTime(value) : false; break; } default: { pillContext[fieldName] = value; } } } for (const decoration in pillDecorations) { const expr = pillDecorations[decoration]; if (evaluateBooleanExpr(expr, pillContext)) { classes.push(decoration); } } } if (colorField) { pill._color = getColorIndex(pill.record[colorField]); classes.push(`o_gantt_color_${pill._color}`); } if (progressField) { pill._progress = pill.record[progressField] || 0; } pill.className = classes.join(" "); return pill; } focusDate(date, ifInBounds) { const { globalStart, globalStop } = this.model.metaData; const diff = date.diff(globalStart); const totalDiff = globalStop.diff(globalStart); const factor = diff / totalDiff; if (ifInBounds && (factor < 0 || 1 < factor)) { return false; } const rtlFactor = localization.direction === "rtl" ? -1 : 1; const scrollLeft = factor * this.cellContainerRef.el.clientWidth + this.rowHeaderWidth - (this.contentRefWidth + this.rowHeaderWidth) / 2; this.props.contentRef.el.scrollLeft = rtlFactor * scrollLeft; return true; } focusFirstPill(rowId) { const pill = this.rowPills[rowId][0]; if (pill) { const col = this.getFirstGridCol(pill); const { start: date } = this.getColumnFromColNumber(col); this.focusDate(date); } } focusToday() { return this.focusDate(DateTime.local().startOf("day"), true); } generateConnectors() { this.nextConnectorId = 1; this.setConnector({ id: NEW_CONNECTOR_ID, highlighted: true, sourcePoint: null, targetPoint: null, }); for (const slaveId in this.mappingRecordToPillsByRow) { const { masterIds, pills: slavePills } = this.mappingRecordToPillsByRow[slaveId]; for (const masterId of masterIds) { if (!(masterId in this.mappingRecordToPillsByRow)) { continue; } const { pills: masterPills } = this.mappingRecordToPillsByRow[masterId]; for (const [slaveRowId, targetPill] of Object.entries(slavePills)) { for (const [masterRowId, sourcePill] of Object.entries(masterPills)) { if ( masterRowId === slaveRowId || !( slaveId in this.mappingRowToPillsByRecord[masterRowId] || masterId in this.mappingRowToPillsByRecord[slaveRowId] ) || Object.keys(this.mappingRecordToPillsByRow[slaveId].pills).every( (rowId) => rowId !== masterRowId && masterId in this.mappingRowToPillsByRecord[rowId] ) || Object.keys(this.mappingRecordToPillsByRow[masterId].pills).every( (rowId) => rowId !== slaveRowId && slaveId in this.mappingRowToPillsByRecord[rowId] ) ) { const masterRecord = sourcePill.record; const slaveRecord = targetPill.record; this.setConnector( { alert: this.getConnectorAlert(masterRecord, slaveRecord) }, sourcePill.id, targetPill.id ); } } } } } } /** * @param {Group} group * @param {Group} previousGroup */ getAggregateValue(group, previousGroup) { // both groups have the same pills by construction // here the aggregateValue is the pill count return group.aggregateValue; } /** * @param {number} startCol * @param {number} stopCol * @param {boolean} [roundUpStop=true] */ getColumnStartStop(startCol, stopCol, roundUpStop = true) { const { start } = this.getColumnFromColNumber(startCol); let { stop } = this.getColumnFromColNumber(stopCol); if (roundUpStop) { stop = stop.plus({ millisecond: 1 }); } return { start, stop }; } /** * * @param {number} masterRecord * @param {number} slaveRecord * @returns {import("./gantt_connector").ConnectorAlert | null} */ getConnectorAlert(masterRecord, slaveRecord) { const { dateStartField, dateStopField } = this.model.metaData; if (slaveRecord[dateStartField] < masterRecord[dateStopField]) { if (slaveRecord[dateStartField] < masterRecord[dateStartField]) { return "error"; } else { return "warning"; } } return null; } /** * @param {Row} row * @param {Column} column * @return {Object} */ ganttCellAttClass(row, column) { return { o_sample_data_disabled: this.isDisabled(row), o_gantt_today: column.isToday, o_gantt_group: row.isGroup, o_gantt_hoverable: this.isHoverable(row), o_group_open: !this.model.isClosed(row.id), }; } getCurrentFocusDate() { const { globalStart, globalStop } = this.model.metaData; const rtlFactor = localization.direction === "rtl" ? -1 : 1; const cellGridMiddleX = rtlFactor * this.props.contentRef.el.scrollLeft + (this.contentRefWidth + this.rowHeaderWidth) / 2; const factor = (cellGridMiddleX - this.rowHeaderWidth) / this.cellContainerRef.el.clientWidth; const totalDiff = globalStop.diff(globalStart); const diff = factor * totalDiff; const focusDate = globalStart.plus(diff); return focusDate; } /** * @param {"top"|"bottom"} vertical the vertical alignment of the connector creator * @returns {{ vertical: "top"|"bottom", horizontal: "left"|"right" }} */ getConnectorCreatorAlignment(vertical) { const alignment = { vertical }; if (localization.direction === "rtl") { alignment.horizontal = vertical === "top" ? "right" : "left"; } else { alignment.horizontal = vertical === "top" ? "left" : "right"; } return alignment; } /** * Get schedule parameters * * @param {Element} pill * @returns {Object} - An object containing parameters needed for scheduling the pill. */ getScheduleParams(pill) { return {}; } /** * This function will add a 'label' property to each * non-consolidated pill included in the pills list. * This new property is a string meant to replace * the text displayed on a pill. * * @param {Pill} pill */ getDisplayName(pill) { const { computePillDisplayName, dateStartField, dateStopField, scale } = this.model.metaData; const { id: scaleId } = scale; const { record } = pill; if (!computePillDisplayName) { return record.display_name; } const startDate = record[dateStartField]; const stopDate = record[dateStopField]; const yearlessDateFormat = omit(DateTime.DATE_SHORT, "year"); const spanAccrossDays = stopDate.startOf("day") > startDate.startOf("day") && startDate.endOf("day").diff(startDate, "hours").toObject().hours >= 3 && stopDate.diff(stopDate.startOf("day"), "hours").toObject().hours >= 3; const spanAccrossWeeks = getStartOfLocalWeek(stopDate) > getStartOfLocalWeek(startDate); const spanAccrossMonths = stopDate.startOf("month") > startDate.startOf("month"); /** @type {string[]} */ const labelElements = []; // Start & End Dates if (scaleId === "year" && !spanAccrossDays) { labelElements.push(startDate.toLocaleString(yearlessDateFormat)); } else if ( (scaleId === "day" && spanAccrossDays) || (scaleId === "week" && spanAccrossWeeks) || (scaleId === "month" && spanAccrossMonths) || (scaleId === "year" && spanAccrossDays) ) { labelElements.push(startDate.toLocaleString(yearlessDateFormat)); labelElements.push(stopDate.toLocaleString(yearlessDateFormat)); } // Start & End Times if (record.allocated_hours && !spanAccrossDays && ["week", "month"].includes(scaleId)) { const durationStr = this.getDurationStr(record); labelElements.push(startDate.toFormat("t"), `${stopDate.toFormat("t")}${durationStr}`); } // Original Display Name if (scaleId !== "month" || !record.allocated_hours || spanAccrossDays) { labelElements.push(record.display_name); } return labelElements.filter((el) => !!el).join(" - "); } /** * @param {RelationalRecord} record */ getDurationStr(record) { const durationStr = formatFloatTime(record.allocated_hours, { noLeadingZeroHour: true, }).replace(/(:00|:)/g, "h"); return ` (${durationStr})`; } /** * @param {Pill} pill */ getGroupPillDisplayName(pill) { return pill.aggregateValue; } /** * @param {{ column?: [number, number], row?: [number, number] }} position */ getGridPosition(position) { const style = []; const keys = Object.keys(pick(position, "column", "row")); for (const key of keys) { const prefix = key.slice(0, 1); const [first, last] = position[key]; style.push(`grid-${key}:${prefix}${first}/${prefix}${last}`); } return style.join(";"); } setSomeGridStyleProperties() { const rowsTemplate = this.computeRowsTemplate(); const colsTemplate = this.computeColsTemplate(); this.gridRef.el.style.setProperty("--Gantt__GridRows-grid-template-rows", rowsTemplate); this.gridRef.el.style.setProperty( "--Gantt__GridColumns-grid-template-columns", colsTemplate ); } getGridStyle() { const rowsTemplate = this.computeRowsTemplate(); const colsTemplate = this.computeColsTemplate(); const style = { "--Gantt__RowHeader-width": `${this.rowHeaderWidth}px`, "--Gantt__Pill-height": "35px", "--Gantt__Thumbnail-max-height": "16px", "--Gantt__GridRows-grid-template-rows": rowsTemplate, "--Gantt__GridColumns-grid-template-columns": colsTemplate, }; if (this.totalWidth !== null) { style.width = `${this.totalWidth}px`; } return Object.entries(style) .map((entry) => entry.join(":")) .join(";"); } /** * @param {RelationalRecord} record * @returns {Partial} */ getPill(record) { const { canEdit, dateStartField, dateStopField, disableDrag, globalStart, globalStop } = this.model.metaData; const startOutside = record[dateStartField] < globalStart; let recordDateStopField = record[dateStopField]; if (this.model.dateStopFieldIsDate()) { recordDateStopField = recordDateStopField.plus({ day: 1 }); } const stopOutside = recordDateStopField > globalStop; /** @type {DateTime} */ const pillStartDate = startOutside ? globalStart : record[dateStartField]; /** @type {DateTime} */ const pillStopDate = stopOutside ? globalStop : recordDateStopField; const disableStartResize = !canEdit || startOutside; const disableStopResize = !canEdit || stopOutside; /** @type {Partial} */ const pill = { disableDrag: disableDrag || disableStartResize || disableStopResize, disableStartResize, disableStopResize, grid: { column: this.getGridColumnFromDates(pillStartDate, pillStopDate) }, record, }; return pill; } getGridColumnFromDates(startDate, stopDate) { const { globalStart, scale } = this.model.metaData; const { cellPart, interval } = scale; const { column: column1, delta: delta1 } = this.getSubColumnFromDate(startDate); const { column: column2, delta: delta2 } = this.getSubColumnFromDate(stopDate, false); const firstCol = 1 + diffColumn(globalStart, column1, interval) * cellPart + delta1; const span = diffColumn(column1, column2, interval) * cellPart + delta2 - delta1; return [firstCol, firstCol + span]; } getSubColumnFromDate(date, onLeft = true) { const { interval, cellPart, cellTime, time } = this.model.metaData.scale; const column = date.startOf(interval); let delta; if (onLeft) { delta = 0; for (let i = 1; i < cellPart; i++) { const subCellStart = dateAddFixedOffset(column, { [time]: i * cellTime }); if (subCellStart <= date) { delta += 1; } else { break; } } } else { delta = cellPart; for (let i = cellPart - 1; i >= 0; i--) { const subCellStart = dateAddFixedOffset(column, { [time]: i * cellTime }); if (subCellStart >= date) { delta -= 1; } else { break; } } } return { column, delta }; } getSubColumnFromColNumber(col) { let subColumn = this.mappingColToSubColumn.get(col); if (!subColumn) { const { globalStart, scale } = this.model.metaData; const { interval, cellPart, cellTime, time } = scale; const delta = (col - 1) % cellPart; const columnIndex = (col - 1 - delta) / cellPart; const start = globalStart.plus({ [interval]: columnIndex }); subColumn = this.makeSubColumn(start, delta, cellTime, time); this.mappingColToSubColumn.set(col, subColumn); } return subColumn; } getColumnFromColNumber(col) { let column = this.mappingColToColumn.get(col); if (!column) { const { globalStart, scale } = this.model.metaData; const { interval, cellPart } = scale; const delta = (col - 1) % cellPart; const columnIndex = (col - 1 - delta) / cellPart; const start = globalStart.plus({ [interval]: columnIndex }); const stop = start.endOf(interval); column = { start, stop }; this.mappingColToColumn.set(col, column); } return column; } /** * @param {PillId} pillId */ getPillEl(pillId) { return this.getPillWrapperEl(pillId).querySelector(".o_gantt_pill"); } /** * @param {Object} group * @param {number} maxAggregateValue * @param {boolean} consolidate */ getPillFromGroup(group, maxAggregateValue, consolidate) { const { excludeField, field, maxValue } = this.model.metaData.consolidationParams; const minColor = 215; const maxColor = 100; const newPill = { id: `__pill__${this.nextPillId++}`, level: 0, aggregateValue: group.aggregateValue, grid: group.grid, }; // Enrich the aggregates with consolidation data if (consolidate && field) { newPill.consolidationValue = 0; for (const pill of group.pills) { if (!pill.record[excludeField]) { newPill.consolidationValue += pill.record[field]; } } newPill.consolidationMaxValue = maxValue; newPill.consolidationExceeded = newPill.consolidationValue > newPill.consolidationMaxValue; } if (consolidate && maxValue) { const status = newPill.consolidationExceeded ? "danger" : "success"; newPill.className = `bg-${status} border-${status}`; newPill.displayName = newPill.consolidationValue; } else { const color = minColor - Math.round((newPill.aggregateValue - 1) / maxAggregateValue) * (minColor - maxColor); newPill.style = `background-color:rgba(${color},${color},${color},0.6)`; newPill.displayName = this.getGroupPillDisplayName(newPill); } return newPill; } /** * There are two forms of pills: pills comming from fetched records * and pills that are some kind of aggregation of the previous. * * Here we create the pills of the firs type. * * The basic properties (independent of rows,...) of the pills of * the first type should be computed here. * * @returns {Partial[]} */ getPills() { const { records } = this.model.data; const { dateStartField } = this.model.metaData; const pills = []; for (const record of records) { const pill = this.getPill(record); pills.push(this.enrichPill(pill)); } return pills.sort( (p1, p2) => p1.grid.column[0] - p2.grid.column[0] || p1.record[dateStartField] - p2.record[dateStartField] ); } /** * @param {PillId} pillId */ getPillWrapperEl(pillId) { const pillSelector = `:scope > [data-pill-id="${pillId}"]`; return this.cellContainerRef.el?.querySelector(pillSelector); } /** * Get domain of records for plan dialog in the gantt view. * * @param {Object} state * @returns {any[][]} */ getPlanDialogDomain() { const { dateStartField, dateStopField } = this.model.metaData; const newDomain = Domain.removeDomainLeaves(this.env.searchModel.globalDomain, [ dateStartField, dateStopField, ]); return Domain.and([ newDomain, ["|", [dateStartField, "=", false], [dateStopField, "=", false]], ]).toList({}); } /** * @param {PillId} pillId * @param {boolean} onRight */ getPoint(pillId, onRight) { if (localization.direction === "rtl") { onRight = !onRight; } const pillEl = this.getPillEl(pillId); const pillRect = pillEl.getBoundingClientRect(); return { left: pillRect.left + (onRight ? pillRect.width : 0), top: pillRect.top + pillRect.height / 2, }; } /** * @param {Pill} pill */ getPopoverProps(pill) { const { record } = pill; const { id: resId, display_name: displayName } = record; const { canEdit, dateStartField, dateStopField, popoverArchParams, resModel } = this.model.metaData; const context = popoverArchParams.bodyTemplate ? { ...record } : /* Default context */ { name: displayName, start: record[dateStartField].toFormat("f"), stop: record[dateStopField].toFormat("f"), }; return { ...popoverArchParams, title: displayName, context, resId, resModel, reload: () => this.model.fetchData(), buttons: [ { id: "open_view_edit_dialog", text: canEdit ? _t("Edit") : _t("View"), class: "btn btn-sm btn-primary", // Sync with the mutex to wait for potential changes on the view onClick: () => this.model.mutex.exec( () => this.props.openDialog({ resId }) // (canEdit is also considered in openDialog) ), }, ], }; } /** * @param {Row} row */ getProgressBarProps(row) { return { progressBar: row.progressBar, reactive: this.progressBarsReactive, rowId: row.id, }; } /** * @param {Row} row */ getRowCellColors(row) { const { unavailabilities } = row; const { cellPart } = this.model.metaData.scale; // We assume that the unavailabilities have been normalized // (i.e. are naturally ordered and are pairwise disjoint). // A subCell is considered unavailable (and greyed) when totally covered by // an unavailability. let index = 0; let j = 0; /** @type {Record} */ const cellColors = {}; const subSlotUnavailabilities = []; for (const subColumn of this.subColumns) { const { isToday, start, stop, columnId } = subColumn; if (index < unavailabilities.length) { let subSlotUnavailable = 0; for (let i = index; i < unavailabilities.length; i++) { const u = unavailabilities[i]; if (stop > u.stop) { index++; continue; } else if (u.start <= start) { subSlotUnavailable = 1; } break; } subSlotUnavailabilities.push(subSlotUnavailable); if ((j + 1) % cellPart === 0) { const style = getCellColor(cellPart, subSlotUnavailabilities, isToday); subSlotUnavailabilities.splice(0, cellPart); if (style) { cellColors[columnId] = style; } } j++; } } return cellColors; } getFromData(groupedByField, resId, key, defaultVal) { const values = this.model.data[key]; if (groupedByField) { return values[groupedByField]?.[resId ?? false] || defaultVal; } return values.__default?.false || defaultVal; } /** * @param {string} [groupedByField] * @param {false|number} [resId] * @returns {Object} */ getRowProgressBar(groupedByField, resId) { return this.getFromData(groupedByField, resId, "progressBars", null); } /** * @param {string} [groupedByField] * @param {false|number} [resId] * @returns {{ start: DateTime, stop: DateTime }[]} */ getRowUnavailabilities(groupedByField, resId) { return this.getFromData(groupedByField, resId, "unavailabilities", []); } /** * @param {"t0" | "t1" | "t2"} type * @returns {number} */ getRowTypeHeight(type) { return { t0: 24, t1: 36, t2: 16, }[type]; } getRowTitleStyle(row) { return `grid-column: ${row.groupLevel + 2} / -1`; } openPlanDialogCallback() {} getSelectCreateDialogProps(params) { const domain = this.getPlanDialogDomain(); const schedule = this.model.getDialogContext(params); return { title: _t("Plan"), resModel: this.model.metaData.resModel, context: schedule, domain, noCreate: !this.model.metaData.canCellCreate, onSelected: (resIds) => { if (resIds.length) { this.model.reschedule(resIds, schedule, this.openPlanDialogCallback.bind(this)); } }, }; } /** * @param {Pill[]} pills */ getTotalRow(pills) { const preRow = { groupLevel: 0, id: "[]", rows: [], name: _t("Total"), recordIds: pills.map(({ record }) => record.id), }; this.currentGridRow = 1; const result = this.processRow(preRow, pills); const [totalRow] = result.rows; const allPills = this.rowPills[totalRow.id] || []; const maxAggregateValue = Math.max(...allPills.map((p) => p.aggregateValue)); totalRow.factor = maxAggregateValue ? 90 / maxAggregateValue : 0; return totalRow; } highlightPill(pillId, highlighted) { const pill = this.pills[pillId]; if (!pill) { return; } pill.highlighted = highlighted; const pillWrapper = this.getPillWrapperEl(pillId); pillWrapper?.classList.toggle("highlight", highlighted); pillWrapper?.classList.toggle( "o_connector_creator_highlight", highlighted && this.connectorDragState.dragging ); } initializeConnectors() { for (const connectorId in this.connectors) { this.deleteConnector(connectorId); } } isPillSmall(pill) { return this.cellPartWidth * pill.grid.column[1] < pill.displayName.length * 10; } /** * @param {Row} row */ isDisabled(row = null) { return this.model.useSampleModel; } /** * @param {Row} row */ isHoverable(row) { return !this.model.useSampleModel; } /** * @param {Group[]} groups * @returns {Group[]} */ mergeGroups(groups) { if (groups.length <= 1) { return groups; } const index = Math.floor(groups.length / 2); const left = this.mergeGroups(groups.slice(0, index)); const right = this.mergeGroups(groups.slice(index)); const group = right[0]; if (!group.break) { const previousGroup = left.pop(); group.break = previousGroup.break; group.grid.column[0] = previousGroup.grid.column[0]; group.aggregateValue = this.getAggregateValue(group, previousGroup); } return [...left, ...right]; } onWillRender() { if (this.noDisplayedConnectors && this.shouldRenderConnectors()) { delete this.noDisplayedConnectors; this.computeDerivedParams(); } if (this.shouldComputeSomeWidths) { this.computeSomeWidths(); } if (this.shouldComputeSomeWidths || this.shouldComputeGridColumns) { this.virtualGrid.setColumnsWidths(new Array(this.columnCount).fill(this.columnWidth)); this.computeVisibleColumns(); } if (this.shouldComputeGridRows) { this.virtualGrid.setRowsHeights(this.gridRows); this.computeVisibleRows(); } if ( this.shouldComputeSomeWidths || this.shouldComputeGridColumns || this.shouldComputeGridRows ) { delete this.shouldComputeSomeWidths; delete this.shouldComputeGridColumns; delete this.shouldComputeGridRows; this.computeVisiblePills(); if (this.shouldRenderConnectors()) { this.computeVisibleConnectors(); } else { this.noDisplayedConnectors = true; } } delete this.shouldComputeSomeWidths; delete this.shouldComputeGridColumns; delete this.shouldComputeGridRows; } pushGridRows(gridRows) { for (const key of ["t0", "t1", "t2"]) { if (key in gridRows) { const types = new Array(gridRows[key]).fill(this.getRowTypeHeight(key)); this.gridRows.push(...types); } } } processPillsAsRows(row, pills) { const rows = []; const parsedId = JSON.parse(row.id); if (pills.length) { for (const pill of pills) { const { id: resId, display_name: name } = pill.record; const subRow = { id: JSON.stringify([...parsedId, { id: resId }]), resId, name, groupLevel: row.groupLevel + 1, recordIds: [resId], fromServer: row.fromServer, parentResId: row.resId ?? row.parentResId, parentGroupedField: row.groupedByField || row.parentGroupedField, }; const res = this.processRow(subRow, [pill], false); rows.push(...res.rows); } } else { const subRow = { id: JSON.stringify([...parsedId, {}]), resId: false, name: "", groupLevel: row.groupLevel + 1, recordIds: [], fromServer: row.fromServer, parentResId: row.resId ?? row.parentResId, parentGroupedField: row.groupedByField || row.parentGroupedField, }; const res = this.processRow(subRow, [], false); rows.push(...res.rows); } return rows; } /** * @param {Row} row * @param {Pill[]} pills * @param {boolean} [processAsGroup=false] */ processRow(row, pills, processAsGroup = true) { const { dependencyField, displayUnavailability, fields } = this.model.metaData; const { displayMode } = this.model.displayParams; const { consolidate, fromServer, groupedByField, groupLevel, id, name, parentResId, parentGroupedField, resId, rows, recordIds, __extra__, } = row; // compute the subset pills at row level const remainingPills = []; let rowPills = []; const groupPills = []; const isMany2many = groupedByField && fields[groupedByField].type === "many2many"; for (const pill of pills) { const { record } = pill; const pushPill = recordIds.includes(record.id); let keepPill = false; if (pushPill && isMany2many) { const value = record[groupedByField]; if (Array.isArray(value) && value.length > 1) { keepPill = true; } } if (pushPill) { const rowPill = { ...pill }; rowPills.push(rowPill); groupPills.push(pill); } if (!pushPill || keepPill) { remainingPills.push(pill); } } if (displayMode === "sparse" && __extra__) { const rows = this.processPillsAsRows(row, groupPills); return { rows, pillsToProcess: remainingPills }; } const isGroup = displayMode === "sparse" ? processAsGroup : Boolean(rows); const gridRowTypes = isGroup ? { t0: 1 } : { t1: 1 }; if (rowPills.length) { if (isGroup) { if (this.shouldComputeAggregateValues(row)) { const groups = this.aggregatePills(rowPills, row); const maxAggregateValue = Math.max( ...groups.map((group) => group.aggregateValue) ); rowPills = groups.map((group) => this.getPillFromGroup(group, maxAggregateValue, consolidate) ); } else { rowPills = []; } } else { const level = this.calculatePillsLevel(rowPills); gridRowTypes.t1 = level; if (!this.isTouchDevice) { gridRowTypes.t2 = 1; } } } const progressBar = this.getRowProgressBar(groupedByField, resId); if (progressBar && this.isTouchDevice && (!gridRowTypes.t1 || gridRowTypes.t1 === 1)) { // In mobile: rows span over 2 rows to alllow progressbars to properly display gridRowTypes.t1 = (gridRowTypes.t1 || 0) + 1; } if (row.id !== "[]") { this.pushGridRows(gridRowTypes); } for (const rowPill of rowPills) { rowPill.id = `__pill__${this.nextPillId++}`; const pillFirstRow = this.currentGridRow + rowPill.level; rowPill.grid = { ...rowPill.grid, // rowPill is a shallow copy of a prePill (possibly copied several times) row: [pillFirstRow, pillFirstRow + 1], }; if (!isGroup) { const { record } = rowPill; if (this.shouldRenderRecordConnectors(record)) { if (!this.mappingRecordToPillsByRow[record.id]) { this.mappingRecordToPillsByRow[record.id] = { masterIds: record[dependencyField], pills: {}, }; } this.mappingRecordToPillsByRow[record.id].pills[id] = rowPill; if (!this.mappingRowToPillsByRecord[id]) { this.mappingRowToPillsByRecord[id] = {}; } this.mappingRowToPillsByRecord[id][record.id] = rowPill; } } rowPill.rowId = id; this.pills[rowPill.id] = rowPill; } this.rowPills[id] = rowPills; // all row pills const subRowsCount = Object.values(gridRowTypes).reduce((acc, val) => acc + val, 0); /** @type {Row} */ const processedRow = { cellColors: {}, fromServer, groupedByField, groupLevel, id, isGroup, name, progressBar, resId, grid: { row: [this.currentGridRow, this.currentGridRow + subRowsCount], }, }; if (displayUnavailability && !isGroup) { processedRow.unavailabilities = this.getRowUnavailabilities( parentGroupedField || groupedByField, parentResId ?? resId ); } this.rowByIds[id] = processedRow; this.currentGridRow += subRowsCount; const field = this.model.metaData.thumbnails[groupedByField]; if (field) { const model = this.model.metaData.fields[groupedByField].relation; processedRow.thumbnailUrl = url("/web/image", { model, id: resId, field, }); } const result = { rows: [processedRow], pillsToProcess: remainingPills }; if (!this.model.isClosed(id)) { if (rows) { let pillsToProcess = groupPills; for (const subRow of rows) { const res = this.processRow(subRow, pillsToProcess); result.rows.push(...res.rows); pillsToProcess = res.pillsToProcess; } } else if (displayMode === "sparse" && processAsGroup) { const rows = this.processPillsAsRows(row, groupPills); result.rows.push(...rows); } } return result; } /** * @param {string} [groupedByField] * @param {false|number} [resId] * @returns {{ start: DateTime, stop: DateTime }[]} */ _getRowUnavailabilities(groupedByField, resId) { const { unavailabilities } = this.model.data; if (groupedByField) { return unavailabilities[groupedByField]?.[resId ?? false] || []; } return unavailabilities.__default?.false || []; } /** * @param {Object} params * @param {Element} params.pill * @param {number} params.diff * @param {"start" | "end"} params.direction */ async resizePillDrop({ pill, diff, direction }) { const { dateStartField, dateStopField, scale } = this.model.metaData; const { cellTime, time } = scale; const { record } = this.pills[pill.dataset.pillId]; const params = this.getScheduleParams(pill); if (direction === "start") { params.start = dateAddFixedOffset(record[dateStartField], { [time]: cellTime * diff }); } else { params.stop = dateAddFixedOffset(record[dateStopField], { [time]: cellTime * diff }); } const schedule = this.model.getSchedule(params); await this.model.reschedule(record.id, schedule, this.openPlanDialogCallback); } /** * @param {Partial} params * @param {PillId | null} [sourceId=null] * @param {PillId | null} [targetId=null] */ setConnector(params, sourceId = null, targetId = null) { const connectorParams = { ...params }; const connectorId = params.id || `__connector__${this.nextConnectorId++}`; if (sourceId) { connectorParams.sourcePoint = () => this.getPoint(sourceId, true); } if (targetId) { connectorParams.targetPoint = () => this.getPoint(targetId, false); } if (this.connectors[connectorId]) { Object.assign(this.connectors[connectorId], connectorParams); } else { this.connectors[connectorId] = { id: connectorId, highlighted: false, displayButtons: false, ...connectorParams, }; this.mappingConnectorToPills[connectorId] = { sourcePillId: sourceId, targetPillId: targetId, }; } if (sourceId) { if (!this.mappingPillToConnectors[sourceId]) { this.mappingPillToConnectors[sourceId] = []; } this.mappingPillToConnectors[sourceId].push(connectorId); } if (targetId) { if (!this.mappingPillToConnectors[targetId]) { this.mappingPillToConnectors[targetId] = []; } this.mappingPillToConnectors[targetId].push(connectorId); } } /** * @param {HTMLElement} [pillEl] */ setStickyPill(pillEl) { this.stickyPillId = pillEl ? pillEl.dataset.pillId : null; } /** * @param {Row} row */ shouldComputeAggregateValues(row) { return true; } shouldMergeGroups() { return true; } /** * Returns whether connectors should be rendered or not. * The connectors won't be rendered on sampleData as we can't be sure that data are coherent. * The connectors won't be rendered on mobile as the usability is not guarantied. * * @return {boolean} */ shouldRenderConnectors() { return ( this.model.metaData.dependencyField && !this.model.useSampleModel && !this.env.isSmall ); } /** * Returns whether connectors should be rendered on particular records or not. * This method is intended to be overridden in particular modules in order to set particular record's condition. * * @param {RelationalRecord} record * @return {boolean} */ shouldRenderRecordConnectors(record) { return this.shouldRenderConnectors(); } /** * @param {ConnectorId | null} connectorId * @param {boolean} highlighted */ toggleConnectorHighlighting(connectorId, highlighted) { const connector = this.connectors[connectorId]; if (!connector || (!connector.highlighted && !highlighted)) { return; } connector.highlighted = highlighted; connector.displayButtons = highlighted; const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId]; this.highlightPill(sourcePillId, highlighted); this.highlightPill(targetPillId, highlighted); } /** * @param {PillId} pillId * @param {boolean} highlighted */ togglePillHighlighting(pillId, highlighted) { const pill = this.pills[pillId]; if (!pill || pill.highlighted === highlighted) { return; } const { record } = pill; const pillIdsToHighlight = new Set([pillId]); if (record && this.shouldRenderRecordConnectors(record)) { // Find other related pills const { pills: relatedPills } = this.mappingRecordToPillsByRow[record.id]; for (const pill of Object.values(relatedPills)) { pillIdsToHighlight.add(pill.id); } // Highlight related connectors for (const [connectorId, connector] of Object.entries(this.connectors)) { const ids = Object.values(this.getRecordIds(connectorId)); if (ids.includes(record.id)) { connector.highlighted = highlighted; connector.displayButtons = false; } } } // Highlight pills from found IDs for (const id of pillIdsToHighlight) { this.highlightPill(id, highlighted); } } //------------------------------------------------------------------------- // Handlers //------------------------------------------------------------------------- onCellClicked(rowId, col) { if (!this.preventClick) { this.preventClick = true; setTimeout(() => (this.preventClick = false), 1000); const { canCellCreate, canPlan } = this.model.metaData; if (canPlan) { this.onPlan(rowId, col, col); } else if (canCellCreate) { this.onCreate(rowId, col, col); } } } onCreate(rowId, startCol, stopCol) { const { start, stop } = this.getColumnStartStop(startCol, stopCol); const context = this.model.getDialogContext({ rowId, start, stop, withDefault: true, }); this.props.create(context); } onInteractionChange() { let { dragAction, mode } = this.interaction; if (mode === "drag") { mode = dragAction; } if (this.gridRef.el) { for (const [action, className] of INTERACTION_CLASSNAMES) { this.gridRef.el.classList.toggle(className, mode === action); } } } onPointerLeave() { this.throttledComputeHoverParams.cancel(); if (!this.isDragging) { const hoveredConnectorId = this.hovered.connector?.dataset.connectorId; this.toggleConnectorHighlighting(hoveredConnectorId, false); const hoveredPillId = this.hovered.pill?.dataset.pillId; this.togglePillHighlighting(hoveredPillId, false); } this.hovered.connector = null; this.hovered.pill = null; this.hovered.hoverable = null; this.computeDerivedParamsFromHover(); } /** * Updates all hovered elements, then calls "computeDerivedParamsFromHover". * * @see computeDerivedParamsFromHover * @param {Event} ev */ computeHoverParams(ev) { // Lazily compute elements from point as it is a costly operation let els = null; let position = {}; if (ev.type === "scroll") { position = this.cursorPosition; } else { position.x = ev.clientX; position.y = ev.clientY; this.cursorPosition = position; } const pointedEls = () => els || (els = document.elementsFromPoint(position.x, position.y)); // To find hovered elements, also from pointed elements const find = (selector) => ev.target.closest?.(selector) || pointedEls().find((el) => el.matches(selector)) || null; this.hovered.connector = find(".o_gantt_connector"); this.hovered.hoverable = find(".o_gantt_hoverable"); this.hovered.pill = find(".o_gantt_pill_wrapper"); this.computeDerivedParamsFromHover(); } /** * @param {PointerEvent} ev * @param {Pill} pill */ onPillClicked(ev, pill) { if (this.popover.isOpen) { return; } this.popover.target = ev.target.closest(".o_gantt_pill_wrapper"); this.popover.open(this.popover.target, this.getPopoverProps(pill)); } onPlan(rowId, startCol, stopCol) { const { start, stop } = this.getColumnStartStop(startCol, stopCol); this.dialogService.add( SelectCreateDialog, this.getSelectCreateDialogProps({ rowId, start, stop, withDefault: true }) ); } getRecordIds(connectorId) { const { sourcePillId, targetPillId } = this.mappingConnectorToPills[connectorId]; return { masterId: this.pills[sourcePillId]?.record.id, slaveId: this.pills[targetPillId]?.record.id, }; } /** * * @param {Object} params * @param {ConnectorId} connectorId */ onRemoveButtonClick(connectorId) { const { masterId, slaveId } = this.getRecordIds(connectorId); this.model.removeDependency(masterId, slaveId); } rescheduleAccordingToDependencyCallback(result) { if (result["type"] !== "warning" && "old_vals_per_pill_id" in result) { this.model.toggleHighlightPlannedFilter( Object.keys(result["old_vals_per_pill_id"]).map(Number) ); } this.notificationFn?.(); this.notificationFn = this.notificationService.add( markup( `${escape( result["message"] )}` ), { type: result["type"], sticky: true, buttons: result["type"] === "warning" ? [] : [ { name: "Undo", icon: "fa-undo", onClick: async () => { const ids = Object.keys(result["old_vals_per_pill_id"]).map( Number ); await this.orm.call( this.model.metaData.resModel, "action_rollback_scheduling", [ids, result["old_vals_per_pill_id"]] ); this.notificationFn(); await this.model.fetchData(); }, }, ], } ); } /** * * @param {"forward" | "backward"} direction * @param {ConnectorId} connectorId */ async onRescheduleButtonClick(direction, connectorId) { const { masterId, slaveId } = this.getRecordIds(connectorId); await this.model.rescheduleAccordingToDependency( direction, masterId, slaveId, this.rescheduleAccordingToDependencyCallback.bind(this) ); } /** * @param {KeyboardEvent} ev */ onWindowKeyDown(ev) { if (ev.key === "Control") { this.prevDragAction = this.interaction.dragAction === "copy" ? "reschedule" : this.interaction.dragAction; this.interaction.dragAction = "copy"; } } /** * @param {KeyboardEvent} ev */ onWindowKeyUp(ev) { if (ev.key === "Control") { this.interaction.dragAction = this.prevDragAction || "reschedule"; } } }