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

1122 lines
37 KiB
JavaScript

import { browser } from "@web/core/browser/browser";
import { Domain } from "@web/core/domain";
import { _t } from "@web/core/l10n/translation";
import {
deserializeDate,
deserializeDateTime,
serializeDate,
serializeDateTime,
} from "@web/core/l10n/dates";
import { x2ManyCommands } from "@web/core/orm_service";
import { registry } from "@web/core/registry";
import { groupBy, unique } from "@web/core/utils/arrays";
import { KeepLast, Mutex } from "@web/core/utils/concurrency";
import { pick } from "@web/core/utils/objects";
import { sprintf } from "@web/core/utils/strings";
import { Model } from "@web/model/model";
import { formatFloatTime, formatPercentage } from "@web/views/fields/formatters";
import { getRangeFromDate, localStartOf } from "./gantt_helpers";
const { DateTime } = luxon;
/**
* @typedef {luxon.DateTime} DateTime
* @typedef {`[{${string}}]`} RowId
* @typedef {import("./gantt_arch_parser").Scale} Scale
* @typedef {import("./gantt_arch_parser").ScaleId} ScaleId
*
* @typedef ConsolidationParams
* @property {string} excludeField
* @property {string} field
* @property {string} [maxField]
* @property {string} [maxValue]
*
* @typedef Data
* @property {Record<string, any>[]} records
* @property {Row[]} rows
*
* @typedef Field
* @property {string} name
* @property {string} type
* @property {[any, string][]} [selection]
*
* @typedef MetaData
* @property {ConsolidationParams} consolidationParams
* @property {string} dateStartField
* @property {string} dateStopField
* @property {string[]} decorationFields
* @property {ScaleId} defaultScale
* @property {string} dependencyField
* @property {boolean} dynamicRange
* @property {Record<string, Field>} fields
* @property {DateTime} focusDate
* @property {number | false} formViewId
* @property {string[]} groupedBy
* @property {Element | null} popoverTemplate
* @property {string} resModel
* @property {Scale} scale
* @property {Scale[]} scales
* @property {DateTime} startDate
* @property {DateTime} stopDate
*
* @typedef ProgressBar
* @property {number} value_formatted
* @property {number} max_value_formatted
* @property {number} ratio
* @property {string} warning
*
* @typedef Row
* @property {RowId} id
* @property {boolean} consolidate
* @property {boolean} fromServer
* @property {string[]} groupedBy
* @property {string} groupedByField
* @property {number} groupLevel
* @property {string} name
* @property {number[]} recordIds
* @property {ProgressBar} [progressBar]
* @property {number | false} resId
* @property {Row[]} [rows]
*/
function firstColumnBefore(date, unit) {
return localStartOf(date, unit);
}
function firstColumnAfter(date, unit) {
const start = localStartOf(date, unit);
if (date.equals(start)) {
return date;
}
return start.plus({ [unit]: 1 });
}
/**
* @param {Record<string, Field>} fields
* @param {Record<string, any>} values
*/
export function parseServerValues(fields, values) {
/** @type {Record<string, any>} */
const parsedValues = {};
if (!values) {
return parsedValues;
}
for (const fieldName in values) {
const field = fields[fieldName];
const value = values[fieldName];
switch (field.type) {
case "date": {
parsedValues[fieldName] = value ? deserializeDate(value) : false;
break;
}
case "datetime": {
parsedValues[fieldName] = value ? deserializeDateTime(value) : false;
break;
}
case "selection": {
if (value === false) {
// process selection: convert false to 0, if 0 is a valid key
const hasKey0 = field.selection.some((option) => option[0] === 0);
parsedValues[fieldName] = hasKey0 ? 0 : value;
} else {
parsedValues[fieldName] = value;
}
break;
}
case "many2one": {
parsedValues[fieldName] = value ? [value.id, value.display_name] : false;
break;
}
default: {
parsedValues[fieldName] = value;
}
}
}
return parsedValues;
}
export class GanttModel extends Model {
static services = ["notification"];
setup(params, services) {
this.notification = services.notification;
/** @type {Data} */
this.data = {};
/** @type {MetaData} */
this.metaData = params.metaData;
this.displayParams = params.displayParams;
this.searchParams = null;
/** @type {Set<RowId>} */
this.closedRows = new Set();
// concurrency management
this.keepLast = new KeepLast();
this.mutex = new Mutex();
/** @type {MetaData | null} */
this._nextMetaData = null;
}
/**
* @param {SearchParams} searchParams
*/
async load(searchParams) {
this.searchParams = searchParams;
const metaData = this._buildMetaData();
const params = {
groupedBy: this._getGroupedBy(metaData, searchParams),
pagerOffset: 0,
};
if (!metaData.scale || !metaData.startDate || !metaData.stopDate) {
Object.assign(
params,
this._getInitialRangeParams(this._buildMetaData(params), searchParams)
);
}
await this._fetchData(this._buildMetaData(params));
}
//-------------------------------------------------------------------------
// Public
//-------------------------------------------------------------------------
collapseRows() {
const collapse = (rows) => {
for (const row of rows) {
this.closedRows.add(row.id);
if (row.rows) {
collapse(row.rows);
}
}
};
collapse(this.data.rows);
this.notify();
}
/**
* Create a copy of a task with defaults determined by schedule.
*
* @param {number} id
* @param {Record<string, any>} schedule
* @param {(result: any) => any} [callback]
*/
copy(id, schedule, callback) {
const { resModel } = this.metaData;
const { context } = this.searchParams;
const data = this._scheduleToData(schedule);
return this.mutex.exec(async () => {
const result = await this.orm.call(resModel, "copy", [[id]], {
context,
default: data,
});
if (callback) {
callback(result[0]);
}
this.fetchData();
});
}
/**
* Adds a dependency between masterId and slaveId (slaveId depends
* on masterId).
*
* @param {number} masterId
* @param {number} slaveId
*/
async createDependency(masterId, slaveId) {
const { dependencyField, resModel } = this.metaData;
const writeCommand = {
[dependencyField]: [x2ManyCommands.link(masterId)],
};
await this.mutex.exec(() => this.orm.write(resModel, [slaveId], writeCommand));
await this.fetchData();
}
dateStartFieldIsDate(metaData = this.metaData) {
return metaData?.fields[metaData.dateStartField].type === "date";
}
dateStopFieldIsDate(metaData = this.metaData) {
return metaData?.fields[metaData.dateStopField].type === "date";
}
expandRows() {
this.closedRows.clear();
this.notify();
}
async fetchData(params) {
await this._fetchData(this._buildMetaData(params));
this.useSampleModel = false;
this.notify();
}
/**
* @param {Object} params
* @param {RowId} [params.rowId]
* @param {DateTime} [params.start]
* @param {DateTime} [params.stop]
* @param {boolean} [params.withDefault]
* @returns {Record<string, any>}
*/
getDialogContext(params) {
/** @type {Record<string, any>} */
const context = { ...this.getSchedule(params) };
if (params.withDefault) {
for (const k in context) {
context[sprintf("default_%s", k)] = context[k];
}
}
return Object.assign({}, this.searchParams.context, context);
}
/**
* @param {Object} params
* @param {RowId} [params.rowId]
* @param {DateTime} [params.start]
* @param {DateTime} [params.stop]
* @returns {Record<string, any>}
*/
getSchedule({ rowId, start, stop } = {}) {
const { dateStartField, dateStopField, fields, groupedBy } = this.metaData;
/** @type {Record<string, any>} */
const schedule = {};
if (start) {
schedule[dateStartField] = this.dateStartFieldIsDate()
? serializeDate(start)
: serializeDateTime(start);
}
if (stop && dateStartField !== dateStopField) {
schedule[dateStopField] = this.dateStopFieldIsDate()
? serializeDate(stop)
: serializeDateTime(stop);
}
if (rowId) {
const group = Object.assign({}, ...JSON.parse(rowId));
for (const fieldName of groupedBy) {
if (fieldName in group) {
const value = group[fieldName];
if (Array.isArray(value)) {
const { type } = fields[fieldName];
schedule[fieldName] = type === "many2many" ? [value[0]] : value[0];
} else {
schedule[fieldName] = value;
}
}
}
}
return schedule;
}
/**
* @override
* @returns {boolean}
*/
hasData() {
return Boolean(this.data.records.length);
}
/**
* @param {RowId} rowId
* @returns {boolean}
*/
isClosed(rowId) {
return this.closedRows.has(rowId);
}
/**
* Removes the dependency between masterId and slaveId (slaveId is no
* more dependent on masterId).
*
* @param {number} masterId
* @param {number} slaveId
*/
async removeDependency(masterId, slaveId) {
const { dependencyField, resModel } = this.metaData;
const writeCommand = {
[dependencyField]: [x2ManyCommands.unlink(masterId)],
};
await this.mutex.exec(() => this.orm.write(resModel, [slaveId], writeCommand));
await this.fetchData();
}
/**
* Removes from 'data' the fields holding the same value as the records targetted
* by 'ids'.
*
* @template {Record<string, any>} T
* @param {T} data
* @param {number[]} ids
* @returns {Partial<T>}
*/
removeRedundantData(data, ids) {
const records = this.data.records.filter((rec) => ids.includes(rec.id));
if (!records.length) {
return data;
}
/**
*
* @param {Record<string, any>} record
* @param {Field} field
*/
const isSameValue = (record, { name, type }) => {
const recordValue = record[name];
let newValue = data[name];
if (Array.isArray(newValue)) {
[newValue] = newValue;
}
if (Array.isArray(recordValue)) {
if (type === "many2many") {
return recordValue.includes(newValue);
} else {
return recordValue[0] === newValue;
}
} else if (type === "date") {
return serializeDate(recordValue) === newValue;
} else if (type === "datetime") {
return serializeDateTime(recordValue) === newValue;
} else {
return recordValue === newValue;
}
};
/** @type {Partial<T>} */
const trimmed = { ...data };
for (const fieldName in data) {
const field = this.metaData.fields[fieldName];
if (records.every((rec) => isSameValue(rec, field))) {
// All the records already have the given value.
delete trimmed[fieldName];
}
}
return trimmed;
}
/**
* Reschedule a task to the given schedule.
*
* @param {number | number[]} ids
* @param {Record<string, any>} schedule
* @param {(result: any) => any} [callback]
*/
async reschedule(ids, schedule, callback) {
if (!Array.isArray(ids)) {
ids = [ids];
}
const allData = this._scheduleToData(schedule);
const data = this.removeRedundantData(allData, ids);
const context = this._getRescheduleContext();
return this.mutex.exec(async () => {
try {
const result = await this._reschedule(ids, data, context);
if (callback) {
await callback(result);
}
} finally {
this.fetchData();
}
});
}
async _reschedule(ids, data, context) {
return this.orm.write(this.metaData.resModel, ids, data, {
context,
});
}
toggleHighlightPlannedFilter(ids) {}
/**
* Reschedule masterId or slaveId according to the direction
*
* @param {"forward" | "backward"} direction
* @param {number} masterId
* @param {number} slaveId
* @returns {Promise<any>}
*/
async rescheduleAccordingToDependency(
direction,
masterId,
slaveId,
rescheduleAccordingToDependencyCallback
) {
const {
dateStartField,
dateStopField,
dependencyField,
dependencyInvertedField,
resModel,
} = this.metaData;
return await this.mutex.exec(async () => {
try {
const result = await this.orm.call(resModel, "web_gantt_reschedule", [
direction,
masterId,
slaveId,
dependencyField,
dependencyInvertedField,
dateStartField,
dateStopField,
]);
if (rescheduleAccordingToDependencyCallback) {
await rescheduleAccordingToDependencyCallback(result);
}
} finally {
this.fetchData();
}
});
}
/**
* @param {string} rowId
*/
toggleRow(rowId) {
if (this.isClosed(rowId)) {
this.closedRows.delete(rowId);
} else {
this.closedRows.add(rowId);
}
this.notify();
}
async toggleDisplayMode() {
this.displayParams.displayMode =
this.displayParams.displayMode === "dense" ? "sparse" : "dense";
this.notify();
}
async updatePagerParams({ limit, offset }) {
await this.fetchData({ pagerLimit: limit, pagerOffset: offset });
}
//-------------------------------------------------------------------------
// Protected
//-------------------------------------------------------------------------
/**
* Return a copy of this.metaData or of the last copy, extended with optional
* params. This is useful for async methods that need to modify this.metaData,
* but it can't be done in place directly for the model to be concurrency
* proof (so they work on a copy and commit it at the end).
*
* @protected
* @param {Object} params
* @param {DateTime} [params.focusDate]
* @param {DateTime} [params.startDate]
* @param {DateTime} [params.stopDate]
* @param {string[]} [params.groupedBy]
* @param {ScaleId} [params.scaleId]
* @returns {MetaData}
*/
_buildMetaData(params = {}) {
this._nextMetaData = { ...(this._nextMetaData || this.metaData) };
if (params.groupedBy) {
this._nextMetaData.groupedBy = params.groupedBy;
}
if (params.scaleId) {
browser.localStorage.setItem(this._getLocalStorageKey(), params.scaleId);
this._nextMetaData.scale = { ...this._nextMetaData.scales[params.scaleId] };
}
if (params.focusDate) {
this._nextMetaData.focusDate = params.focusDate;
}
if (params.startDate) {
this._nextMetaData.startDate = params.startDate;
}
if (params.stopDate) {
this._nextMetaData.stopDate = params.stopDate;
}
if (params.rangeId) {
this._nextMetaData.rangeId = params.rangeId;
}
if ("pagerLimit" in params) {
this._nextMetaData.pagerLimit = params.pagerLimit;
}
if ("pagerOffset" in params) {
this._nextMetaData.pagerOffset = params.pagerOffset;
}
if ("scaleId" in params || "startDate" in params || "stopDate" in params) {
// we assume that scale, startDate, and stopDate are already set in this._nextMetaData
let exchange = false;
if (this._nextMetaData.startDate > this._nextMetaData.stopDate) {
exchange = true;
const temp = this._nextMetaData.startDate;
this._nextMetaData.startDate = this._nextMetaData.stopDate;
this._nextMetaData.stopDate = temp;
}
const { interval } = this._nextMetaData.scale;
const rightLimit = this._nextMetaData.startDate.plus({ year: 10, day: -1 });
if (this._nextMetaData.stopDate > rightLimit) {
if (exchange) {
this._nextMetaData.startDate = this._nextMetaData.stopDate.minus({
year: 10,
day: -1,
});
} else {
this._nextMetaData.stopDate = this._nextMetaData.startDate.plus({
year: 10,
day: -1,
});
}
}
this._nextMetaData.globalStart = firstColumnBefore(
this._nextMetaData.startDate,
interval
);
this._nextMetaData.globalStop = firstColumnAfter(
this._nextMetaData.stopDate.plus({ day: 1 }),
interval
);
if (params.currentFocusDate) {
this._nextMetaData.focusDate = params.currentFocusDate;
if (this._nextMetaData.focusDate < this._nextMetaData.startDate) {
this._nextMetaData.focusDate = this._nextMetaData.startDate;
} else if (this._nextMetaData.stopDate < this._nextMetaData.focusDate) {
this._nextMetaData.focusDate = this._nextMetaData.stopDate;
}
}
}
return this._nextMetaData;
}
/**
* Fetches records to display (and groups if necessary).
*
* @protected
* @param {MetaData} metaData
* @param {Object} [additionalContext]
*/
async _fetchData(metaData, additionalContext) {
const { globalStart, globalStop, groupedBy, pagerLimit, pagerOffset, resModel, scale } =
metaData;
const context = {
...this.searchParams.context,
group_by: groupedBy,
...additionalContext,
};
const domain = this._getDomain(metaData);
const fields = this._getFields(metaData);
const specification = {};
for (const fieldName of fields) {
specification[fieldName] = {};
if (metaData.fields[fieldName].type === "many2one") {
specification[fieldName].fields = { display_name: {} };
}
}
const { length, groups, records, progress_bars, unavailabilities } =
await this.keepLast.add(
this.orm.call(resModel, "get_gantt_data", [], {
domain,
groupby: groupedBy,
read_specification: specification,
scale: scale.unit,
start_date: serializeDateTime(globalStart),
stop_date: serializeDateTime(globalStop),
unavailability_fields: this._getUnavailabilityFields(metaData),
progress_bar_fields: this._getProgressBarFields(metaData),
context,
limit: pagerLimit,
offset: pagerOffset,
})
);
groups.forEach((g) => (g.fromServer = true));
const data = { count: length };
data.records = this._parseServerData(metaData, records);
data.rows = this._generateRows(metaData, {
groupedBy,
groups,
parentGroup: [],
});
data.unavailabilities = this._processUnavailabilities(unavailabilities);
data.progressBars = this._processProgressBars(progress_bars);
await this.keepLast.add(this._fetchDataPostProcess(metaData, data));
this.data = data;
this.metaData = metaData;
this._nextMetaData = null;
}
/**
* @protected
* @param {MetaData} metaData
* @param {Data} data
*/
async _fetchDataPostProcess(metaData, data) {}
/**
* Remove date in groupedBy field
*
* @protected
* @param {MetaData} metaData
* @param {string[]} groupedBy
* @returns {string[]}
*/
_filterDateIngroupedBy(metaData, groupedBy) {
return groupedBy.filter((gb) => {
const [fieldName] = gb.split(":");
const { type } = metaData.fields[fieldName];
return !["date", "datetime"].includes(type);
});
}
/**
* @protected
* @param {number} floatVal
* @param {string}
*/
_formatTime(floatVal) {
const timeStr = formatFloatTime(floatVal, { noLeadingZeroHour: true });
const [hourStr, minuteStr] = timeStr.split(":");
const hour = parseInt(hourStr, 10);
const minute = parseInt(minuteStr, 10);
return minute ? _t("%(hour)sh%(minute)s", { hour, minute }) : _t("%sh", hour);
}
/**
* Process groups to generate a recursive structure according
* to groupedBy fields. Note that there might be empty groups (filled by
* read_goup with group_expand) that also need to be processed.
*
* @protected
* @param {MetaData} metaData
* @param {Object} params
* @param {Object[]} params.groups
* @param {string[]} params.groupedBy
* @param {Object[]} params.parentGroup
* @returns {Row[]}
*/
_generateRows(metaData, params) {
const groupedBy = params.groupedBy;
const groups = params.groups;
const groupLevel = metaData.groupedBy.length - groupedBy.length;
const parentGroup = params.parentGroup;
if (!groupedBy.length || !groups.length) {
const recordIds = [];
for (const g of groups) {
recordIds.push(...(g.__record_ids || []));
}
const part = parentGroup.at(-1);
const [[parentGroupedField, value]] = part ? Object.entries(part) : [[]];
return [
{
groupLevel,
id: JSON.stringify([...parentGroup, {}]),
name: "",
recordIds: unique(recordIds),
parentGroupedField,
parentResId: Array.isArray(value) ? value[0] : value,
__extra__: true,
},
];
}
/** @type {Row[]} */
const rows = [];
// Some groups might be empty (thanks to expand_groups), so we can't
// simply group the data, we need to keep all returned groups
const groupedByField = groupedBy[0];
const currentLevelGroups = groupBy(groups, (g) => {
if (g[groupedByField] === undefined) {
// we want to group the groups with undefined values for groupedByField with the ones
// with false value for the same field.
// we also want to be sure that stringification keeps groupedByField:
// JSON.stringify({ key: undefined }) === "{}"
// see construction of id below.
g[groupedByField] = false;
}
return g[groupedByField];
});
const { maxField } = metaData.consolidationParams;
const consolidate = groupLevel === 0 && groupedByField === maxField;
const generateSubRow = maxField ? true : groupedBy.length > 1;
for (const key in currentLevelGroups) {
const subGroups = currentLevelGroups[key];
const value = subGroups[0][groupedByField];
const part = {};
part[groupedByField] = value;
const fakeGroup = [...parentGroup, part];
const id = JSON.stringify(fakeGroup);
const resId = Array.isArray(value) ? value[0] : value; // not really a resId
const fromServer = subGroups.some((g) => g.fromServer);
const recordIds = [];
for (const g of subGroups) {
recordIds.push(...(g.__record_ids || []));
}
const row = {
consolidate,
fromServer,
groupedBy,
groupedByField,
groupLevel,
id,
name: this._getRowName(metaData, groupedByField, value),
resId, // not really a resId
recordIds: unique(recordIds),
};
if (generateSubRow) {
row.rows = this._generateRows(metaData, {
...params,
groupedBy: groupedBy.slice(1),
groups: subGroups,
parentGroup: fakeGroup,
});
}
if (resId === false) {
rows.unshift(row);
} else {
rows.push(row);
}
}
return rows;
}
/**
* Get domain of records to display in the gantt view.
*
* @protected
* @param {MetaData} metaData
* @returns {any[]}
*/
_getDomain(metaData) {
const { dateStartField, dateStopField, globalStart, globalStop } = metaData;
const domain = Domain.and([
this.searchParams.domain,
[
"&",
[
dateStartField,
"<",
this.dateStopFieldIsDate(metaData)
? serializeDate(globalStop)
: serializeDateTime(globalStop),
],
[
dateStopField,
this.dateStartFieldIsDate(metaData) ? ">=" : ">",
this.dateStartFieldIsDate(metaData)
? serializeDate(globalStart)
: serializeDateTime(globalStart),
],
],
]);
return domain.toList();
}
/**
* Format field value to display purpose.
*
* @protected
* @param {any} value
* @param {Object} field
* @returns {string} formatted field value
*/
_getFieldFormattedValue(value, field) {
if (field.type === "boolean") {
return value ? "True" : "False";
} else if (!value) {
return _t("Undefined %s", field.string);
} else if (field.type === "many2many") {
return value[1];
}
const formatter = registry.category("formatters").get(field.type);
return formatter(value, field);
}
/**
* Get all the fields needed.
*
* @protected
* @param {MetaData} metaData
* @returns {string[]}
*/
_getFields(metaData) {
const fields = new Set([
"display_name",
metaData.dateStartField,
metaData.dateStopField,
...metaData.groupedBy,
...metaData.decorationFields,
]);
if (metaData.colorField) {
fields.add(metaData.colorField);
}
if (metaData.consolidationParams.field) {
fields.add(metaData.consolidationParams.field);
}
if (metaData.consolidationParams.excludeField) {
fields.add(metaData.consolidationParams.excludeField);
}
if (metaData.dependencyField) {
fields.add(metaData.dependencyField);
}
if (metaData.progressField) {
fields.add(metaData.progressField);
}
return [...fields];
}
/**
* @protected
* @param {MetaData} metaData
* @param {{ groupBy: string[] }} searchParams
* @returns {string[]}
*/
_getGroupedBy(metaData, searchParams) {
let groupedBy = [...searchParams.groupBy];
groupedBy = groupedBy.filter((gb) => {
const [fieldName] = gb.split(".");
const field = metaData.fields[fieldName];
return field?.type !== "properties";
});
groupedBy = this._filterDateIngroupedBy(metaData, groupedBy);
if (!groupedBy.length) {
groupedBy = metaData.defaultGroupBy;
}
return groupedBy;
}
_getDefaultFocusDate(metaData, searchParams, scaleId) {
const { context } = searchParams;
let focusDate =
"initialDate" in context ? deserializeDateTime(context.initialDate) : DateTime.local();
focusDate = focusDate.startOf("day");
if (metaData.offset) {
const { unit } = metaData.scales[scaleId];
focusDate = focusDate.plus({ [unit]: metaData.offset });
}
return focusDate;
}
/**
* @protected
* @param {MetaData} metaData
* @param {{ context: Record<string, any> }} searchParams
* @returns {{ focusDate: DateTime, scaleId: ScaleId, startDate: DateTime, stopDate: DateTime }}
*/
_getInitialRangeParams(metaData, searchParams) {
const { context } = searchParams;
const localScaleId = this._getScaleIdFromLocalStorage(metaData);
/** @type {ScaleId} */
const scaleId = localScaleId || context.default_scale || metaData.defaultScale;
const { defaultRange } = metaData.scales[scaleId];
const rangeId =
context.default_range in metaData.ranges
? context.range_type
: metaData.defaultRange || "custom";
let focusDate;
if (rangeId in metaData.ranges) {
focusDate = this._getDefaultFocusDate(metaData, searchParams, scaleId);
return { scaleId, ...getRangeFromDate(rangeId, focusDate) };
}
let startDate = context.default_start_date && deserializeDate(context.default_start_date);
let stopDate = context.default_stop_date && deserializeDate(context.default_stop_date);
if (!startDate && !stopDate) {
/** @type {DateTime} */
focusDate = this._getDefaultFocusDate(metaData, searchParams, scaleId);
startDate = firstColumnBefore(focusDate, defaultRange.unit);
stopDate = startDate
.plus({ [defaultRange.unit]: defaultRange.count })
.minus({ day: 1 });
} else if (startDate && !stopDate) {
const column = firstColumnBefore(startDate, defaultRange.unit);
focusDate = startDate;
stopDate = column.plus({ [defaultRange.unit]: defaultRange.count }).minus({ day: 1 });
} else if (!startDate && stopDate) {
const column = firstColumnAfter(stopDate, defaultRange.unit);
focusDate = stopDate;
startDate = column.minus({ [defaultRange.unit]: defaultRange.count });
} else {
focusDate = DateTime.local();
if (focusDate < startDate) {
focusDate = startDate;
} else if (focusDate > stopDate) {
focusDate = stopDate;
}
}
return { focusDate, scaleId, startDate, stopDate, rangeId };
}
_getLocalStorageKey() {
return `scaleOf-viewId-${this.env.config.viewId}`;
}
_getProgressBarFields(metaData) {
if (metaData.progressBarFields && !this.orm.isSample) {
return metaData.progressBarFields.filter(
(fieldName) =>
metaData.groupedBy.includes(fieldName) &&
["many2many", "many2one"].includes(metaData.fields[fieldName]?.type)
);
}
return [];
}
_getRescheduleContext() {
return { ...this.searchParams.context };
}
/**
* @protected
* @param {MetaData} metaData
* @param {string} groupedByField
* @param {any} value
* @returns {string}
*/
_getRowName(metaData, groupedByField, value) {
const field = metaData.fields[groupedByField];
return this._getFieldFormattedValue(value, field);
}
_getScaleIdFromLocalStorage(metaData) {
const { scales } = metaData;
const localScaleId = browser.localStorage.getItem(this._getLocalStorageKey());
return localScaleId in scales ? localScaleId : null;
}
/**
* @protected
* @param {MetaData} metaData
* @returns {string[]}
*/
_getUnavailabilityFields(metaData) {
if (metaData.displayUnavailability && !this.orm.isSample && metaData.groupedBy.length) {
const lastGroupBy = metaData.groupedBy.at(-1);
const { type } = metaData.fields[lastGroupBy] || {};
if (["many2many", "many2one"].includes(type)) {
return [lastGroupBy];
}
}
return [];
}
/**
* @protected
* @param {MetaData} metaData
* @param {Record<string, any>[]} records the server records to parse
* @returns {Record<string, any>[]}
*/
_parseServerData(metaData, records) {
const { dateStartField, dateStopField, fields, globalStart, globalStop } = metaData;
/** @type {Record<string, any>[]} */
const parsedRecords = [];
for (const record of records) {
const parsedRecord = parseServerValues(fields, record);
const dateStart = parsedRecord[dateStartField];
const dateStop = parsedRecord[dateStopField];
if (this.orm.isSample) {
// In sample mode, we want enough data to be displayed, so we
// swap the dates as the records are randomly generated anyway.
if (dateStart > dateStop) {
parsedRecord[dateStartField] = dateStop;
parsedRecord[dateStopField] = dateStart;
}
// Record could also be outside the displayed range since the
// sample server doesn't take the domain into account
if (parsedRecord[dateStopField] < globalStart) {
parsedRecord[dateStopField] = globalStart;
}
if (parsedRecord[dateStartField] > globalStop) {
parsedRecord[dateStartField] = globalStop;
}
parsedRecords.push(parsedRecord);
} else if (dateStart <= dateStop) {
parsedRecords.push(parsedRecord);
}
}
return parsedRecords;
}
_processProgressBar(progressBar, warning) {
const processedProgressBar = {
...progressBar,
value_formatted: this._formatTime(progressBar.value),
max_value_formatted: this._formatTime(progressBar.max_value),
ratio: progressBar.max_value ? (progressBar.value / progressBar.max_value) * 100 : 0,
warning,
};
if (processedProgressBar?.max_value) {
processedProgressBar.ratio_formatted = formatPercentage(
processedProgressBar.ratio / 100
);
}
return processedProgressBar;
}
_processProgressBars(progressBars) {
const processedProgressBars = {};
for (const fieldName in progressBars) {
processedProgressBars[fieldName] = {};
const progressBarInfo = progressBars[fieldName];
for (const [resId, progressBar] of Object.entries(progressBarInfo)) {
processedProgressBars[fieldName][resId] = this._processProgressBar(
progressBar,
progressBarInfo.warning
);
}
}
return processedProgressBars;
}
_processUnavailabilities(unavailabilities) {
const processedUnavailabilities = {};
for (const fieldName in unavailabilities) {
processedUnavailabilities[fieldName] = {};
for (const [resId, resUnavailabilities] of Object.entries(
unavailabilities[fieldName]
)) {
processedUnavailabilities[fieldName][resId] = resUnavailabilities.map((u) => ({
start: deserializeDateTime(u.start),
stop: deserializeDateTime(u.stop),
}));
}
}
return processedUnavailabilities;
}
/**
* @template {Record<string, any>} T
* @param {T} schedule
* @returns {Partial<T>}
*/
_scheduleToData(schedule) {
const allowedFields = [
this.metaData.dateStartField,
this.metaData.dateStopField,
...this.metaData.groupedBy,
];
return pick(schedule, ...allowedFields);
}
}