581 lines
19 KiB
JavaScript
581 lines
19 KiB
JavaScript
import {
|
|
click,
|
|
hover,
|
|
queryAll,
|
|
queryAllTexts,
|
|
queryFirst,
|
|
queryOne,
|
|
queryText,
|
|
setInputRange,
|
|
} from "@odoo/hoot-dom";
|
|
import { advanceTime, animationFrame, runAllTimers } from "@odoo/hoot-mock";
|
|
import { getPickerCell, zoomOut } from "@web/../tests/core/datetime/datetime_test_helpers";
|
|
import { contains, mountView } from "@web/../tests/web_test_helpers";
|
|
|
|
/**
|
|
* @typedef CellHelperOptions
|
|
* @property {number} [part=1] -- starts at 1
|
|
* @property {boolean} [ignoreHoverableClass=false]
|
|
*/
|
|
|
|
/**
|
|
* @typedef PillHelperOptions
|
|
* @property {number} [nth=1] -- starts at 1
|
|
*/
|
|
|
|
/**
|
|
* @typedef DragPillHelpers
|
|
* @property {() => Promise<void>} cancel
|
|
* @property {(params: DragParams) => Promise<void>} drop
|
|
* @property {(params: DragParams) => Promise<void>} moveTo
|
|
*/
|
|
|
|
/**
|
|
* @template T
|
|
* @typedef {(columnHeader: string, rowHeader: string, options: CellHelperOptions) => T} CellHelper
|
|
*/
|
|
|
|
/**
|
|
* @template T
|
|
* @typedef {(text: string, options: PillHelperOptions) => T} PillHelper
|
|
*/
|
|
|
|
/** @typedef {CellHelperOptions & { row: number, column: number }} DragGridParams */
|
|
|
|
/** @typedef {PillHelperOptions & { pill: string }} DragPillParams */
|
|
|
|
/** @typedef {DragGridParams | DragPillParams} DragParams */
|
|
|
|
/**
|
|
* @template {String} T
|
|
* @param {T} key
|
|
* @returns {`.${T}`}
|
|
*/
|
|
function makeClassSelector(key) {
|
|
return `.${key}`;
|
|
}
|
|
|
|
export const CLASSES = {
|
|
draggable: "o_draggable",
|
|
group: "o_gantt_group",
|
|
highlightedPill: "highlight",
|
|
resizable: "o_resizable",
|
|
|
|
// Connectors
|
|
highlightedConnector: "o_connector_highlighted",
|
|
highlightedConnectorCreator: "o_connector_creator_highlight",
|
|
lockedConnectorCreator: "o_connector_creator_lock", // Connector creators highlight for initial pill
|
|
};
|
|
|
|
export const SELECTORS = {
|
|
addButton: ".o_gantt_button_add",
|
|
cell: ".o_gantt_cell",
|
|
cellContainer: ".o_gantt_cells",
|
|
collapseButton: ".o_gantt_button_collapse_rows",
|
|
dense: ".fa-compress",
|
|
sparse: ".fa-expand",
|
|
draggable: makeClassSelector(CLASSES.draggable),
|
|
expandButton: ".o_gantt_button_expand_rows",
|
|
expandCollapseButtons: ".o_gantt_button_expand_rows, .o_gantt_button_collapse_rows",
|
|
group: makeClassSelector(CLASSES.group),
|
|
groupHeader: ".o_gantt_header_title",
|
|
columnHeader: ".o_gantt_header_cell",
|
|
highlightedPill: makeClassSelector(CLASSES.highlightedPill),
|
|
hoverable: ".o_gantt_hoverable",
|
|
noContentHelper: ".o_view_nocontent",
|
|
pill: ".o_gantt_pill",
|
|
pillWrapper: ".o_gantt_pill_wrapper",
|
|
progressBar: ".o_gantt_row_header .o_gantt_progress_bar",
|
|
progressBarBackground: ".o_gantt_row_header .o_gantt_progress_bar > span.bg-opacity-25",
|
|
progressBarForeground:
|
|
".o_gantt_row_header .o_gantt_progress_bar > span > .o_gantt_group_hours",
|
|
progressBarWarning:
|
|
".o_gantt_row_header .o_gantt_progress_bar > .o_gantt_group_hours > .fa-exclamation-triangle",
|
|
renderer: ".o_gantt_renderer",
|
|
resizable: makeClassSelector(CLASSES.resizable),
|
|
resizeBadge: ".o_gantt_pill_resize_badge",
|
|
resizeEndHandle: ".o_handle_end",
|
|
resizeHandle: ".o_resize_handle",
|
|
resizeStartHandle: ".o_handle_start",
|
|
rowHeader: ".o_gantt_row_header",
|
|
rowTitle: ".o_gantt_row_title",
|
|
rowTotal: ".o_gantt_row_total",
|
|
startDatePicker: ".o_gantt_picker:nth-child(2)",
|
|
stopDatePicker: ".o_gantt_picker:nth-child(4)",
|
|
thumbnail: ".o_gantt_row_thumbnail",
|
|
rangeMenu: ".o_gantt_range_menu",
|
|
rangeMenuToggler: ".o_gantt_renderer_controls div.dropdown:nth-child(2)",
|
|
todayButton: ".o_gantt_button_today",
|
|
toolbar: ".o_gantt_renderer_controls div[name='ganttToolbar']",
|
|
undraggable: ".o_undraggable",
|
|
view: ".o_gantt_view",
|
|
viewContent: ".o_gantt_view .o_content",
|
|
previousButton: ".o_gantt_renderer_controls button:has(> .fa-arrow-left)",
|
|
nextButton: ".o_gantt_renderer_controls button:has(> .fa-arrow-right)",
|
|
minusButton: ".o_gantt_renderer_controls button:has(> .fa-search-minus)",
|
|
plusButton: ".o_gantt_renderer_controls button:has(> .fa-search-plus)",
|
|
|
|
// Connectors
|
|
connector: ".o_gantt_connector",
|
|
connectorCreatorBullet: ".o_connector_creator_bullet",
|
|
connectorCreatorRight: ".o_connector_creator_right",
|
|
connectorCreatorWrapper: ".o_connector_creator_wrapper",
|
|
connectorRemoveButton: ".o_connector_stroke_remove_button",
|
|
connectorRescheduleButton: ".o_connector_stroke_reschedule_button",
|
|
connectorStroke: ".o_connector_stroke",
|
|
connectorStrokeButton: ".o_connector_stroke_button",
|
|
highlightedConnector: makeClassSelector(CLASSES.highlightedConnector),
|
|
};
|
|
|
|
export async function mountGanttView(params) {
|
|
const gantt = await mountView({ ...params, type: "gantt" });
|
|
await animationFrame();
|
|
return gantt;
|
|
}
|
|
|
|
export async function ganttControlsChanges() {
|
|
await runAllTimers();
|
|
await animationFrame();
|
|
await animationFrame(); // for potential focusDate
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
* @param {DateTime} datetime
|
|
*/
|
|
async function selectDateInDatePicker(selector, datetime) {
|
|
await contains(selector).click();
|
|
for (let i = 0; i < 3; i++) {
|
|
await zoomOut();
|
|
}
|
|
await contains(getPickerCell(datetime.year - (datetime.year % 10))).click();
|
|
await contains(getPickerCell(datetime.year)).click();
|
|
await contains(getPickerCell(datetime.monthShort)).click();
|
|
await contains(getPickerCell(datetime.day, true)).click();
|
|
}
|
|
|
|
/**
|
|
* @param {Object} param0
|
|
* @param {string} [param0.startDate]
|
|
* @param {string} [param0.stopDate]
|
|
*/
|
|
export async function selectGanttRange({ startDate, stopDate }) {
|
|
const {
|
|
startDatePicker: START_SELECTOR,
|
|
stopDatePicker: STOP_SELECTOR,
|
|
rangeMenuToggler,
|
|
} = SELECTORS;
|
|
await click(rangeMenuToggler);
|
|
await animationFrame();
|
|
if (startDate) {
|
|
await selectDateInDatePicker(START_SELECTOR, luxon.DateTime.fromISO(startDate));
|
|
}
|
|
if (stopDate) {
|
|
await selectDateInDatePicker(STOP_SELECTOR, luxon.DateTime.fromISO(stopDate));
|
|
}
|
|
await click(".dropdown-item button:contains(Apply)");
|
|
await ganttControlsChanges();
|
|
}
|
|
|
|
export async function selectRange(label) {
|
|
await click(SELECTORS.rangeMenuToggler);
|
|
await animationFrame();
|
|
await click(`${SELECTORS.rangeMenu} .dropdown-item:contains(/^${label}$/)`);
|
|
await ganttControlsChanges();
|
|
}
|
|
|
|
export function getActiveScale() {
|
|
return Number(queryFirst(".o_gantt_renderer_controls input").value);
|
|
}
|
|
|
|
/**
|
|
* @param {Number} scale
|
|
*/
|
|
export async function setScale(scale) {
|
|
await setInputRange(".o_gantt_renderer_controls input", scale);
|
|
}
|
|
|
|
export async function focusToday() {
|
|
await click(SELECTORS.todayButton);
|
|
}
|
|
|
|
/** @type {PillHelper<Promise<DragPillHelpers>>} */
|
|
export async function dragPill(text, options) {
|
|
/**
|
|
* @param {DragParams} [params]
|
|
*/
|
|
const drop = async (params) => {
|
|
if (params) {
|
|
await moveTo(params);
|
|
}
|
|
await dragActions.drop();
|
|
};
|
|
|
|
/**
|
|
* @param {DragParams} params
|
|
*/
|
|
const moveTo = async (params) => {
|
|
let cell;
|
|
if (params?.column) {
|
|
cell = await hoverGridCell(params.column, params.row, params);
|
|
} else if (params?.pill) {
|
|
({ cell } = await hoverPillCell(getPillWrapper(params.pill, params)));
|
|
}
|
|
return dragActions.moveTo(cell, {
|
|
position: getCellPositionOffset(cell, params.part),
|
|
relative: true,
|
|
});
|
|
};
|
|
|
|
const pill = getPillWrapper(text, options);
|
|
pill.scrollIntoView({ behavior: "instant", inline: "center" });
|
|
const { cell, part } = await hoverPillCell(pill);
|
|
const dragActions = await contains(pill).drag({
|
|
// D&D needs the correct initial position since it will attempt an implicit
|
|
// hover on the pill.
|
|
position: getCellPositionOffset(cell, part - 1),
|
|
relative: true,
|
|
});
|
|
|
|
return { ...dragActions, drop, moveTo };
|
|
}
|
|
|
|
/** @type {PillHelper<Promise<void>>} */
|
|
export async function editPill(text, options) {
|
|
await contains(getPill(text, options)).click();
|
|
await contains(".o_popover .popover-footer .btn-primary").click();
|
|
}
|
|
|
|
/**
|
|
* @param {string} header
|
|
*/
|
|
function findColumnFromHeader(header) {
|
|
const columnHeaders = getHeaders(SELECTORS.columnHeader);
|
|
const groupHeaders = getHeaders(SELECTORS.groupHeader);
|
|
const columnHeader = header.substring(0, header.indexOf(" "));
|
|
const groupHeader = header.substring(header.indexOf(" ") + 1);
|
|
const groupRange = groupHeaders.find((header) => header.title === groupHeader).range;
|
|
return columnHeaders.find(
|
|
(header) =>
|
|
header.title === columnHeader &&
|
|
header.range[0] >= groupRange[0] &&
|
|
header.range[1] <= groupRange[1]
|
|
).range[0];
|
|
}
|
|
|
|
/** @type {CellHelper<HTMLElement>} */
|
|
export function getCell(columnHeader, rowHeader = null, options) {
|
|
const columnIndex = findColumnFromHeader(columnHeader);
|
|
const cells = queryAll(`${SELECTORS.cell}[data-col='${columnIndex}']`);
|
|
if (!cells.length) {
|
|
throw new Error(`Could not find cell at column ${columnHeader}`);
|
|
}
|
|
if (rowHeader === null) {
|
|
return cells[0];
|
|
}
|
|
const row = queryAll(`.o_gantt_row_header:contains(${rowHeader})`)?.[(options?.num || 1) - 1];
|
|
if (!row) {
|
|
throw new Error(`Could not find row ${rowHeader}`);
|
|
}
|
|
const rowId = row.getAttribute("data-row-id");
|
|
return cells.find((cell) => cell.getAttribute("data-row-id") === rowId);
|
|
}
|
|
|
|
/** @type {CellHelper<string[]>} */
|
|
export function getCellColorProperties(columnHeader, rowHeader = null, options) {
|
|
const cell = getCell(columnHeader, rowHeader, options);
|
|
const cssVarRegex = /(--[\w-]+)/g;
|
|
|
|
if (cell.style.background) {
|
|
return cell.style.background.match(cssVarRegex);
|
|
} else if (cell.style.backgroundColor) {
|
|
return cell.style.backgroundColor.match(cssVarRegex);
|
|
} else if (cell.style.backgroundImage) {
|
|
return cell.style.backgroundImage.match(cssVarRegex);
|
|
}
|
|
|
|
return [];
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} pill
|
|
* @returns {HTMLElement}
|
|
*/
|
|
export function getCellFromPill(pill) {
|
|
if (!pill.matches(SELECTORS.pillWrapper)) {
|
|
pill = pill.closest(SELECTORS.pillWrapper);
|
|
}
|
|
const { row, column } = getGridStyle(pill);
|
|
for (const cell of queryAll(SELECTORS.cell)) {
|
|
const { row: cellRow, column: cellColumn } = getGridStyle(cell);
|
|
if (row[0] < cellRow[1] && column[0] < cellColumn[1]) {
|
|
return cell;
|
|
}
|
|
}
|
|
throw new Error(`Could not find hoverable cell for pill "${queryText(pill)}".`);
|
|
}
|
|
|
|
/**
|
|
* @param {string} str
|
|
*/
|
|
function parseNumber(str) {
|
|
return parseInt(str.match(/\d+/)?.[0]) || 1;
|
|
}
|
|
|
|
/**
|
|
* @param {string} selector
|
|
*/
|
|
function getHeaders(selector) {
|
|
const groupHeaders = [];
|
|
for (const el of queryAll(selector)) {
|
|
const { column: range } = getGridStyle(el);
|
|
groupHeaders.push({
|
|
range,
|
|
title: el.textContent,
|
|
});
|
|
}
|
|
return groupHeaders;
|
|
}
|
|
|
|
export function getGridContent() {
|
|
const columnHeaders = getHeaders(SELECTORS.columnHeader);
|
|
const groupHeaders = getHeaders(SELECTORS.groupHeader);
|
|
const range = queryAllTexts(SELECTORS.rangeMenuToggler)[0] || null;
|
|
const viewTitle = queryAllTexts(".o_gantt_title")[0] || null;
|
|
const colsRange = queryFirst(SELECTORS.columnHeader)
|
|
.style.getPropertyValue("grid-column")
|
|
.split("/");
|
|
const cellParts = parseNumber(colsRange[1]) - parseNumber(colsRange[0]);
|
|
const pillEls = new Set(queryAll(`${SELECTORS.cellContainer} ${SELECTORS.pillWrapper}`));
|
|
const rowEls = queryAll(`.o_gantt_row_headers > ${SELECTORS.rowHeader}`);
|
|
const singleRowMode = rowEls.length === 0;
|
|
if (singleRowMode) {
|
|
rowEls.push(document.createElement("div"));
|
|
}
|
|
const totalRow = queryFirst(SELECTORS.rowTotal);
|
|
const totalPillEls = new Set(queryAll(`.o_gantt_row_total ${SELECTORS.pillWrapper}`));
|
|
if (totalRow) {
|
|
totalRow._isTotal = true;
|
|
rowEls.push(totalRow);
|
|
}
|
|
const rows = [];
|
|
for (const rowEl of rowEls) {
|
|
const isGroup = rowEl.classList.contains(CLASSES.group);
|
|
const { row: gridRow } = getGridStyle(rowEl);
|
|
const row = singleRowMode ? {} : { title: queryText(rowEl) };
|
|
if (isGroup) {
|
|
row.isGroup = true;
|
|
}
|
|
if (rowEl._isTotal) {
|
|
row.isTotalRow = true;
|
|
}
|
|
const pills = [];
|
|
for (const pillEl of rowEl._isTotal ? totalPillEls : pillEls) {
|
|
const pillRowLevel = parseNumber(pillEl.style.gridRowStart);
|
|
const { column: gridColumn } = getGridStyle(pillEl);
|
|
const pillInRow = pillRowLevel >= gridRow[0] && pillRowLevel < gridRow[1];
|
|
if (singleRowMode || pillInRow || rowEl._isTotal) {
|
|
let start = columnHeaders.find(
|
|
(header) => gridColumn[0] >= header.range[0] && gridColumn[0] < header.range[1]
|
|
)?.title;
|
|
let end = columnHeaders.find(
|
|
(header) => gridColumn[1] > header.range[0] && gridColumn[1] <= header.range[1]
|
|
)?.title;
|
|
const startPart = (gridColumn[0] - 1) % cellParts;
|
|
const endPart = (gridColumn[1] - 1) % cellParts;
|
|
if (startPart && start) {
|
|
start += ` (${startPart}/${cellParts})`;
|
|
}
|
|
if (endPart && end) {
|
|
end += ` (${endPart}/${cellParts})`;
|
|
}
|
|
const pill = {
|
|
title: queryText(pillEl),
|
|
colSpan: `${start || "Out of bounds (" + gridColumn[0] + ")"} ${
|
|
start
|
|
? groupHeaders.find(
|
|
(header) =>
|
|
gridColumn[0] >= header.range[0] &&
|
|
gridColumn[0] < header.range[1]
|
|
).title
|
|
: ""
|
|
} -> ${end || "Out of bounds (" + gridColumn[1] + ")"} ${
|
|
end
|
|
? groupHeaders.find(
|
|
(header) =>
|
|
gridColumn[1] > header.range[0] &&
|
|
gridColumn[1] <= header.range[1]
|
|
).title
|
|
: ""
|
|
}`,
|
|
};
|
|
if (!isGroup) {
|
|
pill.level = singleRowMode ? pillRowLevel - 1 : pillRowLevel - gridRow[0];
|
|
}
|
|
pills.push(pill);
|
|
pillEls.delete(pillEl);
|
|
}
|
|
}
|
|
if (pills.length) {
|
|
row.pills = pills;
|
|
}
|
|
rows.push(row);
|
|
}
|
|
|
|
return { columnHeaders, groupHeaders, range, rows, viewTitle };
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} el
|
|
*/
|
|
export function getGridStyle(el) {
|
|
/**
|
|
* @param {"row" | "column"} prop
|
|
* @returns {[number, number]}
|
|
*/
|
|
const getGridProp = (prop) => {
|
|
return [
|
|
parseNumber(style.getPropertyValue(`grid-${prop}-start`)),
|
|
parseNumber(style.getPropertyValue(`grid-${prop}-end`)),
|
|
];
|
|
};
|
|
|
|
const style = getComputedStyle(el);
|
|
|
|
return {
|
|
row: getGridProp("row"),
|
|
column: getGridProp("column"),
|
|
};
|
|
}
|
|
|
|
function getCellPositionOffset(cell, part) {
|
|
const position = { x: 1 };
|
|
if (part > 1) {
|
|
const rect = cell.getBoundingClientRect();
|
|
// Calculate cell parts
|
|
const colsRange = queryFirst(SELECTORS.columnHeader)
|
|
.style.getPropertyValue("grid-column")
|
|
.split("/");
|
|
const cellParts = parseNumber(colsRange[1]) - parseNumber(colsRange[0]);
|
|
const partWidth = rect.width / cellParts;
|
|
position.x += Math.ceil(partWidth * (part - 1));
|
|
}
|
|
return position;
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} cell
|
|
* @param {CellHelperOptions} [options]
|
|
*/
|
|
async function hoverCell(cell, options) {
|
|
const part = options?.part ?? 1;
|
|
await hover(cell, { position: getCellPositionOffset(cell, part), relative: true });
|
|
await animationFrame();
|
|
await advanceTime(1000);
|
|
}
|
|
|
|
/**
|
|
* Hovers a cell found from given grid coordinates.
|
|
* @type {CellHelper<Promise<HTMLElement>>}
|
|
*/
|
|
export async function hoverGridCell(columnHeader, rowHeader = null, options) {
|
|
const cell = getCell(columnHeader, rowHeader, options);
|
|
await hoverCell(cell, options);
|
|
return cell;
|
|
}
|
|
|
|
/**
|
|
* Click on a cell found from given grid coordinates.
|
|
* @type {CellHelper<Promise<HTMLElement>>}
|
|
*/
|
|
export async function clickCell(columnHeader, rowHeader = null, options) {
|
|
const cell = getCell(columnHeader, rowHeader, options);
|
|
await contains(cell).click();
|
|
}
|
|
|
|
/**
|
|
* Hovers a cell found from a pill element.
|
|
* @param {HTMLElement} pill
|
|
*/
|
|
async function hoverPillCell(pill) {
|
|
const cell = getCellFromPill(pill);
|
|
const pStart = getGridStyle(pill).column[0];
|
|
const cellStyle = getGridStyle(cell).column[0];
|
|
const part = pStart - cellStyle + 1;
|
|
await hoverCell(cell, { part });
|
|
return { cell, part };
|
|
}
|
|
|
|
/**
|
|
* @param {HTMLElement} pill
|
|
* @param {"start" | "end"} side
|
|
* @param {number | { x: number }} deltaOrPosition
|
|
* @param {boolean} [shouldDrop=true]
|
|
*/
|
|
export async function resizePill(pill, side, deltaOrPosition, shouldDrop = true) {
|
|
await hover(pill);
|
|
|
|
const { row, column } = getGridStyle(pill);
|
|
|
|
// Calculate cell parts
|
|
const colsRange = queryFirst(SELECTORS.columnHeader)
|
|
.style.getPropertyValue("grid-column")
|
|
.split("/");
|
|
const cellParts = parseNumber(colsRange[1]) - parseNumber(colsRange[0]);
|
|
|
|
// Calculate delta or position
|
|
const delta = typeof deltaOrPosition === "object" ? 0 : deltaOrPosition;
|
|
const position = typeof deltaOrPosition === "object" ? deltaOrPosition : {};
|
|
const targetColumn = (side === "start" ? column[0] : column[1]) + delta * cellParts;
|
|
|
|
let targetCell;
|
|
let targetPart;
|
|
for (const cell of queryAll(SELECTORS.cell)) {
|
|
const { row: cRow, column: cCol } = getGridStyle(cell);
|
|
if (cRow[0] > row[0] || cRow[1] < row[1]) {
|
|
continue;
|
|
}
|
|
if (cCol[1] < targetColumn) {
|
|
continue;
|
|
}
|
|
|
|
if (targetColumn < cCol[0]) {
|
|
break;
|
|
}
|
|
|
|
targetCell = cell;
|
|
targetPart = targetColumn - cCol[0];
|
|
}
|
|
|
|
// Assign position if delta
|
|
if (!position.x) {
|
|
const { width } = targetCell.getBoundingClientRect();
|
|
position.x = targetPart * Math.floor(width / cellParts);
|
|
}
|
|
|
|
// Actual drag actions
|
|
const { moveTo, drop } = await contains(
|
|
pill.querySelector(
|
|
side === "start" ? SELECTORS.resizeStartHandle : SELECTORS.resizeEndHandle
|
|
)
|
|
).drag();
|
|
|
|
await moveTo(targetCell, { position, relative: true });
|
|
|
|
if (shouldDrop) {
|
|
await drop();
|
|
} else {
|
|
return drop;
|
|
}
|
|
}
|
|
|
|
/** @type {PillHelper<HTMLElement>} */
|
|
export function getPill(text, options) {
|
|
return queryOne(`${SELECTORS.pill}:contains(${text}):eq(${(options?.nth ?? 1) - 1})`);
|
|
}
|
|
|
|
/** @type {PillHelper<HTMLElement>} */
|
|
export function getPillWrapper(text, options) {
|
|
return getPill(text, options).closest(SELECTORS.pillWrapper);
|
|
}
|