Eliminate Python dependency: embed frontend assets in odoo-go
- Copy all OWL frontend assets (JS/CSS/XML/fonts/images) into frontend/ directory (2925 files, 43MB) — no more runtime reads from Python Odoo - Replace OdooAddonsPath config with FrontendDir pointing to local frontend/ - Rewire bundle.go, static.go, templates.go, webclient.go to read from frontend/ instead of external Python Odoo addons directory - Auto-detect frontend/ and build/ dirs relative to binary in main.go - Delete obsolete Python helper scripts (tools/*.py) The Go server is now fully self-contained: single binary + frontend/ folder. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
249
frontend/web/static/src/model/model.js
Normal file
249
frontend/web/static/src/model/model.js
Normal file
@@ -0,0 +1,249 @@
|
||||
import { RPCError } from "@web/core/network/rpc";
|
||||
import { user } from "@web/core/user";
|
||||
import { Deferred, Race } from "@web/core/utils/concurrency";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { useSetupAction } from "@web/search/action_hook";
|
||||
import { SEARCH_KEYS } from "@web/search/with_search/with_search";
|
||||
import { buildSampleORM } from "./sample_server";
|
||||
|
||||
import {
|
||||
EventBus,
|
||||
onWillStart,
|
||||
onWillUnmount,
|
||||
onWillUpdateProps,
|
||||
status,
|
||||
useComponent,
|
||||
} from "@odoo/owl";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/env").OdooEnv} OdooEnv
|
||||
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
|
||||
* @typedef {import("services").ServiceFactories} Services
|
||||
*/
|
||||
|
||||
export class Model {
|
||||
static services = [];
|
||||
|
||||
/**
|
||||
* @param {OdooEnv} env
|
||||
* @param {SearchParams} params
|
||||
* @param {Services} services
|
||||
*/
|
||||
constructor(env, params, services) {
|
||||
this.env = env;
|
||||
this.orm = services.orm;
|
||||
this.bus = new EventBus();
|
||||
this.isReady = false;
|
||||
this.whenReady = new Deferred();
|
||||
this.whenReady.then(() => {
|
||||
this.isReady = true;
|
||||
this.notify();
|
||||
});
|
||||
this.setup(params, services);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {SearchParams} params
|
||||
* @param {Services} services
|
||||
*/
|
||||
setup(/* params, services */) {}
|
||||
|
||||
/**
|
||||
* @param {Partial<SearchParams>} _params
|
||||
*/
|
||||
async load(_params) {}
|
||||
|
||||
/**
|
||||
* This function is meant to be overriden by models that want to implement
|
||||
* the sample data feature. It should return true iff the last loaded state
|
||||
* actually contains data. If not, another load will be done (if the sample
|
||||
* feature is enabled) with the orm service substituted by another using the
|
||||
* SampleServer, to have sample data to display instead of an empty screen.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
hasData() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is meant to be overriden by models that want to combine
|
||||
* sample data with real groups that exist on the server.
|
||||
*
|
||||
* @returns {boolean}
|
||||
*/
|
||||
getGroups() {
|
||||
return null;
|
||||
}
|
||||
|
||||
notify() {
|
||||
this.bus.trigger("update");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} props
|
||||
* @returns {SearchParams}
|
||||
*/
|
||||
function getSearchParams(props) {
|
||||
const params = {};
|
||||
for (const key of SEARCH_KEYS) {
|
||||
params[key] = props[key];
|
||||
}
|
||||
return params;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {typeof Model} T
|
||||
* @param {T} ModelClass
|
||||
* @param {Object} params
|
||||
* @param {Object} [options]
|
||||
* @param {Function} [options.beforeFirstLoad]
|
||||
* @returns {InstanceType<T>}
|
||||
*/
|
||||
export function useModel(ModelClass, params, options = {}) {
|
||||
const component = useComponent();
|
||||
const services = {};
|
||||
for (const key of ModelClass.services) {
|
||||
services[key] = useService(key);
|
||||
}
|
||||
services.orm = services.orm || useService("orm");
|
||||
const model = new ModelClass(component.env, params, services);
|
||||
onWillStart(async () => {
|
||||
await options.beforeFirstLoad?.();
|
||||
await model.load(getSearchParams(component.props));
|
||||
model.whenReady.resolve();
|
||||
});
|
||||
onWillUpdateProps((nextProps) => model.load(getSearchParams(nextProps)));
|
||||
return model;
|
||||
}
|
||||
|
||||
/**
|
||||
* @template {typeof Model} T
|
||||
* @param {T} ModelClass
|
||||
* @param {Object} params
|
||||
* @param {Object} [options]
|
||||
* @param {Function} [options.lazy=false]
|
||||
* @returns {InstanceType<T>}
|
||||
*/
|
||||
export function useModelWithSampleData(ModelClass, params, options = {}) {
|
||||
const component = useComponent();
|
||||
if (!(ModelClass.prototype instanceof Model)) {
|
||||
throw new Error(`the model class should extend Model`);
|
||||
}
|
||||
const services = {};
|
||||
for (const key of ModelClass.services) {
|
||||
services[key] = useService(key);
|
||||
}
|
||||
services.orm = services.orm || useService("orm");
|
||||
|
||||
if (!("isAlive" in params)) {
|
||||
params.isAlive = () => status(component) !== "destroyed";
|
||||
}
|
||||
|
||||
const model = new ModelClass(component.env, params, services);
|
||||
|
||||
const onUpdate = () => component.render(true);
|
||||
model.bus.addEventListener("update", onUpdate);
|
||||
onWillUnmount(() => model.bus.removeEventListener("update", onUpdate));
|
||||
|
||||
const globalState = component.props.globalState || {};
|
||||
const localState = component.props.state || {};
|
||||
let useSampleModel =
|
||||
component.props.useSampleModel &&
|
||||
(!("useSampleModel" in globalState) || globalState.useSampleModel);
|
||||
model.useSampleModel = false;
|
||||
const orm = model.orm;
|
||||
let sampleORM = localState.sampleORM;
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} props
|
||||
*/
|
||||
async function _load(props) {
|
||||
const searchParams = getSearchParams(props);
|
||||
await model.load(searchParams);
|
||||
if (useSampleModel && !model.hasData()) {
|
||||
sampleORM =
|
||||
sampleORM || buildSampleORM(component.props.resModel, component.props.fields, user);
|
||||
// Load data with sampleORM then restore real ORM.
|
||||
model.orm = sampleORM;
|
||||
await model.load(searchParams);
|
||||
model.orm = orm;
|
||||
model.useSampleModel = true;
|
||||
} else {
|
||||
useSampleModel = false;
|
||||
model.useSampleModel = useSampleModel;
|
||||
}
|
||||
model.whenReady.resolve(); // resolve after the first successful load
|
||||
if (status(component) === "mounted") {
|
||||
model.notify();
|
||||
}
|
||||
}
|
||||
const race = new Race();
|
||||
const load = (props) => race.add(_load(props));
|
||||
onWillStart(() => {
|
||||
const prom = load(component.props);
|
||||
if (options.lazy) {
|
||||
// in-house error handling as we're out of willStart
|
||||
prom.catch((e) => {
|
||||
if (e instanceof RPCError) {
|
||||
component.env.config.historyBack();
|
||||
}
|
||||
throw e;
|
||||
});
|
||||
} else {
|
||||
return prom;
|
||||
}
|
||||
});
|
||||
onWillUpdateProps((nextProps) => {
|
||||
useSampleModel = false;
|
||||
load(nextProps);
|
||||
});
|
||||
|
||||
useSetupAction({
|
||||
getGlobalState() {
|
||||
if (component.props.useSampleModel) {
|
||||
return { useSampleModel };
|
||||
}
|
||||
},
|
||||
getLocalState: () => ({ sampleORM }),
|
||||
});
|
||||
|
||||
return model;
|
||||
}
|
||||
|
||||
export function _makeFieldFromPropertyDefinition(name, definition, relatedPropertyField) {
|
||||
return {
|
||||
...definition,
|
||||
name,
|
||||
propertyName: definition.name,
|
||||
relation: definition.comodel,
|
||||
relatedPropertyField,
|
||||
};
|
||||
}
|
||||
|
||||
export async function addPropertyFieldDefs(orm, resModel, context, fields, groupBy) {
|
||||
const proms = [];
|
||||
for (const gb of groupBy) {
|
||||
if (gb in fields) {
|
||||
continue;
|
||||
}
|
||||
const [fieldName] = gb.split(".");
|
||||
const field = fields[fieldName];
|
||||
if (field?.type === "properties") {
|
||||
proms.push(
|
||||
orm
|
||||
.call(resModel, "get_property_definition", [gb], {
|
||||
context,
|
||||
})
|
||||
.then((definition) => {
|
||||
fields[gb] = _makeFieldFromPropertyDefinition(gb, definition, field);
|
||||
})
|
||||
.catch(() => {
|
||||
fields[gb] = _makeFieldFromPropertyDefinition(gb, {}, field);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
return Promise.all(proms);
|
||||
}
|
||||
204
frontend/web/static/src/model/record.js
Normal file
204
frontend/web/static/src/model/record.js
Normal file
@@ -0,0 +1,204 @@
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { isObject, pick } from "@web/core/utils/objects";
|
||||
import { RelationalModel } from "@web/model/relational_model/relational_model";
|
||||
import { getFieldsSpec } from "@web/model/relational_model/utils";
|
||||
import { Component, xml, onWillStart, onWillUpdateProps, useState } from "@odoo/owl";
|
||||
|
||||
const defaultActiveField = { attrs: {}, options: {}, domain: "[]", string: "" };
|
||||
|
||||
class StandaloneRelationalModel extends RelationalModel {
|
||||
load(params = {}) {
|
||||
if (params.values) {
|
||||
const data = params.values;
|
||||
const config = this._getNextConfig(this.config, params);
|
||||
this.root = this._createRoot(config, data);
|
||||
this.config = config;
|
||||
this.hooks.onRootLoaded(this.root);
|
||||
return Promise.resolve();
|
||||
}
|
||||
return super.load(params);
|
||||
}
|
||||
}
|
||||
|
||||
class _Record extends Component {
|
||||
static template = xml`<t t-slot="default" record="model.root"/>`;
|
||||
static props = ["slots", "info", "fields", "values?"];
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
const resModel = this.props.info.resModel;
|
||||
const activeFields = this.getActiveFields();
|
||||
const modelParams = {
|
||||
config: {
|
||||
resModel,
|
||||
fields: this.props.fields,
|
||||
isMonoRecord: true,
|
||||
activeFields,
|
||||
resId: this.props.info.resId,
|
||||
mode: this.props.info.mode,
|
||||
context: this.props.info.context,
|
||||
},
|
||||
hooks: this.props.info.hooks,
|
||||
};
|
||||
const modelServices = Object.fromEntries(
|
||||
StandaloneRelationalModel.services.map((servName) => [servName, useService(servName)])
|
||||
);
|
||||
modelServices.orm = this.orm;
|
||||
this.model = useState(new StandaloneRelationalModel(this.env, modelParams, modelServices));
|
||||
|
||||
const prepareLoadWithValues = async (values) => {
|
||||
values = pick(values, ...Object.keys(modelParams.config.activeFields));
|
||||
const proms = [];
|
||||
for (const fieldName in values) {
|
||||
if (["one2many", "many2many"].includes(this.props.fields[fieldName].type)) {
|
||||
if (values[fieldName].length && typeof values[fieldName][0] === "number") {
|
||||
const resModel = this.props.fields[fieldName].relation;
|
||||
const resIds = values[fieldName];
|
||||
const activeField = modelParams.config.activeFields[fieldName];
|
||||
if (activeField.related) {
|
||||
const { activeFields, fields } = activeField.related;
|
||||
const fieldSpec = getFieldsSpec(activeFields, fields, {});
|
||||
const kwargs = {
|
||||
context: activeField.context || {},
|
||||
specification: fieldSpec,
|
||||
};
|
||||
proms.push(
|
||||
this.orm.webRead(resModel, resIds, kwargs).then((records) => {
|
||||
values[fieldName] = records;
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (this.props.fields[fieldName].type === "many2one") {
|
||||
const loadDisplayName = async (resId) => {
|
||||
const resModel = this.props.fields[fieldName].relation;
|
||||
const activeField = modelParams.config.activeFields[fieldName];
|
||||
const kwargs = {
|
||||
context: activeField.context || {},
|
||||
specification: { display_name: {} },
|
||||
};
|
||||
const records = await this.orm.webRead(resModel, [resId], kwargs);
|
||||
return records[0].display_name;
|
||||
};
|
||||
if (typeof values[fieldName] === "number") {
|
||||
const prom = loadDisplayName(values[fieldName]);
|
||||
prom.then((displayName) => {
|
||||
values[fieldName] = {
|
||||
id: values[fieldName],
|
||||
display_name: displayName,
|
||||
};
|
||||
});
|
||||
proms.push(prom);
|
||||
} else if (Array.isArray(values[fieldName])) {
|
||||
if (values[fieldName][1] === undefined) {
|
||||
const prom = loadDisplayName(values[fieldName][0]);
|
||||
prom.then((displayName) => {
|
||||
values[fieldName] = {
|
||||
id: values[fieldName][0],
|
||||
display_name: displayName,
|
||||
};
|
||||
});
|
||||
proms.push(prom);
|
||||
}
|
||||
values[fieldName] = {
|
||||
id: values[fieldName][0],
|
||||
display_name: values[fieldName][1],
|
||||
};
|
||||
} else if (isObject(values[fieldName])) {
|
||||
if (values[fieldName].display_name === undefined) {
|
||||
const prom = loadDisplayName(values[fieldName].id);
|
||||
prom.then((displayName) => {
|
||||
values[fieldName] = {
|
||||
id: values[fieldName].id,
|
||||
display_name: displayName,
|
||||
};
|
||||
});
|
||||
proms.push(prom);
|
||||
}
|
||||
values[fieldName] = {
|
||||
id: values[fieldName].id,
|
||||
display_name: values[fieldName].display_name,
|
||||
};
|
||||
}
|
||||
}
|
||||
await Promise.all(proms);
|
||||
}
|
||||
return values;
|
||||
};
|
||||
onWillStart(async () => {
|
||||
if (this.props.values) {
|
||||
const values = await prepareLoadWithValues(this.props.values);
|
||||
await this.model.load({ values });
|
||||
} else {
|
||||
await this.model.load();
|
||||
}
|
||||
this.model.whenReady.resolve();
|
||||
});
|
||||
onWillUpdateProps(async (nextProps) => {
|
||||
const params = {};
|
||||
if (nextProps.info.resId !== this.model.root.resId) {
|
||||
params.resId = nextProps.info.resId;
|
||||
}
|
||||
if (nextProps.values) {
|
||||
params.values = await prepareLoadWithValues(nextProps.values);
|
||||
}
|
||||
if (Object.keys(params).length) {
|
||||
return this.model.load(params);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
getActiveFields() {
|
||||
if (this.props.info.activeFields) {
|
||||
const activeFields = {};
|
||||
for (const [fName, fInfo] of Object.entries(this.props.info.activeFields)) {
|
||||
activeFields[fName] = { ...defaultActiveField, ...fInfo };
|
||||
}
|
||||
return activeFields;
|
||||
}
|
||||
return Object.fromEntries(
|
||||
this.props.info.fieldNames.map((f) => [f, { ...defaultActiveField }])
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export class Record extends Component {
|
||||
static template = xml`<_Record fields="fields" slots="props.slots" values="props.values" info="props" />`;
|
||||
static components = { _Record };
|
||||
static props = [
|
||||
"slots",
|
||||
"resModel?",
|
||||
"fieldNames?",
|
||||
"activeFields?",
|
||||
"fields?",
|
||||
"resId?",
|
||||
"mode?",
|
||||
"values?",
|
||||
"context?",
|
||||
"hooks?",
|
||||
];
|
||||
static defaultProps = {
|
||||
context: {},
|
||||
};
|
||||
setup() {
|
||||
const { activeFields, fieldNames, fields, resModel } = this.props;
|
||||
if (!activeFields && !fieldNames) {
|
||||
throw Error(
|
||||
`Record props should have either a "activeFields" key or a "fieldNames" key`
|
||||
);
|
||||
}
|
||||
if (!fields && (!fieldNames || !resModel)) {
|
||||
throw Error(
|
||||
`Record props should have either a "fields" key or a "fieldNames" and a "resModel" key`
|
||||
);
|
||||
}
|
||||
if (fields) {
|
||||
this.fields = fields;
|
||||
} else {
|
||||
const fieldService = useService("field");
|
||||
onWillStart(async () => {
|
||||
this.fields = await fieldService.loadFields(resModel, { fieldNames });
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
64
frontend/web/static/src/model/relational_model/datapoint.js
Normal file
64
frontend/web/static/src/model/relational_model/datapoint.js
Normal file
@@ -0,0 +1,64 @@
|
||||
import { markRaw } from "@odoo/owl";
|
||||
import { Reactive } from "@web/core/utils/reactive";
|
||||
import { getId } from "./utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/search/search_model").Field} Field
|
||||
* @typedef {import("@web/search/search_model").FieldInfo} FieldInfo
|
||||
* @typedef {import("./relational_model").RelationalModel} RelationalModel
|
||||
* @typedef {import("./relational_model").RelationalModelConfig} RelationalModelConfig
|
||||
*/
|
||||
|
||||
export class DataPoint extends Reactive {
|
||||
/**
|
||||
* @param {RelationalModel} model
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Record<string, unknown>} data
|
||||
* @param {unknown} [options]
|
||||
*/
|
||||
constructor(model, config, data, options) {
|
||||
super(...arguments);
|
||||
this.id = getId("datapoint");
|
||||
this.model = model;
|
||||
markRaw(config.activeFields);
|
||||
markRaw(config.fields);
|
||||
/** @type {RelationalModelConfig} */
|
||||
this._config = config;
|
||||
this.setup(config, data, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
* @template [O={}]
|
||||
* @param {RelationalModelConfig} _config
|
||||
* @param {Record<string, unknown>} _data
|
||||
* @param {O | undefined} _options
|
||||
*/
|
||||
setup(_config, _data, _options) {}
|
||||
|
||||
get activeFields() {
|
||||
return this.config.activeFields;
|
||||
}
|
||||
|
||||
get fields() {
|
||||
return this.config.fields;
|
||||
}
|
||||
|
||||
get fieldNames() {
|
||||
return Object.keys(this.activeFields).filter(
|
||||
(fieldName) => !this.fields[fieldName].relatedPropertyField
|
||||
);
|
||||
}
|
||||
|
||||
get resModel() {
|
||||
return this.config.resModel;
|
||||
}
|
||||
|
||||
get config() {
|
||||
return this._config;
|
||||
}
|
||||
|
||||
get context() {
|
||||
return this.config.context;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,385 @@
|
||||
//@ts-check
|
||||
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { DynamicList } from "./dynamic_list";
|
||||
import { getGroupServerValue } from "./utils";
|
||||
|
||||
export const MOVABLE_RECORD_TYPES = ["char", "boolean", "integer", "selection", "many2one"];
|
||||
|
||||
/**
|
||||
* @typedef {import("./record").Record} RelationalRecord
|
||||
*/
|
||||
|
||||
export class DynamicGroupList extends DynamicList {
|
||||
static type = "DynamicGroupList";
|
||||
|
||||
/**
|
||||
* @type {DynamicList["setup"]}
|
||||
*/
|
||||
setup(_config, data) {
|
||||
super.setup(...arguments);
|
||||
|
||||
this.isGrouped = true;
|
||||
this._nbRecordsMatchingDomain = null;
|
||||
this._setData(data);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Record<string, unknown>} data
|
||||
*/
|
||||
_setData(data) {
|
||||
/** @type {import("./group").Group[]} */
|
||||
this.groups = data.groups.map((g) => this._createGroupDatapoint(g));
|
||||
this.count = data.length;
|
||||
this._selectDomain(this.isDomainSelected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get groupBy() {
|
||||
return this.config.groupBy;
|
||||
}
|
||||
|
||||
get groupByField() {
|
||||
return this.fields[this.groupBy[0].split(":")[0]];
|
||||
}
|
||||
|
||||
get hasData() {
|
||||
return this.groups.some((group) => group.hasData);
|
||||
}
|
||||
|
||||
get isRecordCountTrustable() {
|
||||
return this.count <= this.limit || this._nbRecordsMatchingDomain !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* List of loaded records inside groups.
|
||||
* @returns {RelationalRecord[]}
|
||||
*/
|
||||
get records() {
|
||||
return this.groups
|
||||
.filter((group) => !group.isFolded)
|
||||
.map((group) => group.records)
|
||||
.flat();
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns {number}
|
||||
*/
|
||||
get recordCount() {
|
||||
if (this._nbRecordsMatchingDomain !== null) {
|
||||
return this._nbRecordsMatchingDomain;
|
||||
}
|
||||
return this.groups.reduce((acc, group) => acc + group.count, 0);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {string} groupName
|
||||
* @param {string} [foldField] if given, will write true on this field to
|
||||
* make the group folded by default
|
||||
*/
|
||||
async createGroup(groupName, foldField) {
|
||||
if (!this.groupByField || this.groupByField.type !== "many2one") {
|
||||
throw new Error("Cannot create a group on a non many2one group field");
|
||||
}
|
||||
|
||||
await this.model.mutex.exec(() => this._createGroup(groupName, foldField));
|
||||
}
|
||||
|
||||
async deleteGroups(groups) {
|
||||
await this.model.mutex.exec(() => this._deleteGroups(groups));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {string} dataRecordId
|
||||
* @param {string} dataGroupId
|
||||
* @param {string} refId
|
||||
* @param {string} targetGroupId
|
||||
*/
|
||||
async moveRecord(dataRecordId, dataGroupId, refId, targetGroupId) {
|
||||
const targetGroup = this.groups.find((g) => g.id === targetGroupId);
|
||||
if (dataGroupId === targetGroupId) {
|
||||
// move a record inside the same group
|
||||
await targetGroup.list._resequence(
|
||||
targetGroup.list.records,
|
||||
this.resModel,
|
||||
dataRecordId,
|
||||
refId
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// move record from a group to another group
|
||||
const sourceGroup = this.groups.find((g) => g.id === dataGroupId);
|
||||
const recordIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
|
||||
const record = sourceGroup.list.records[recordIndex];
|
||||
// step 1: move record to correct position
|
||||
const refIndex = targetGroup.list.records.findIndex((r) => r.id === refId);
|
||||
const oldIndex = sourceGroup.list.records.findIndex((r) => r.id === dataRecordId);
|
||||
|
||||
const sourceList = sourceGroup.list;
|
||||
// if the source contains more records than what's loaded, reload it after moving the record
|
||||
const mustReloadSourceList = sourceList.count > sourceList.offset + sourceList.limit;
|
||||
|
||||
sourceGroup._removeRecords([record.id]);
|
||||
targetGroup._addRecord(record, refIndex + 1);
|
||||
// step 2: update record value
|
||||
let value = targetGroup.value;
|
||||
if (targetGroup.groupByField.type === "many2one") {
|
||||
value = value ? { id: value, display_name: targetGroup.displayName } : false;
|
||||
}
|
||||
|
||||
const revert = () => {
|
||||
targetGroup._removeRecords([record.id]);
|
||||
sourceGroup._addRecord(record, oldIndex);
|
||||
};
|
||||
try {
|
||||
const changes = { [targetGroup.groupByField.name]: value };
|
||||
const res = await record.update(changes, { save: true });
|
||||
if (!res) {
|
||||
return revert();
|
||||
}
|
||||
} catch (e) {
|
||||
// revert changes
|
||||
revert();
|
||||
throw e;
|
||||
}
|
||||
|
||||
const proms = [];
|
||||
if (mustReloadSourceList) {
|
||||
const { offset, limit, orderBy, domain } = sourceGroup.list;
|
||||
proms.push(sourceGroup.list._load(offset, limit, orderBy, domain));
|
||||
}
|
||||
if (!targetGroup.isFolded) {
|
||||
const targetList = targetGroup.list;
|
||||
const records = targetList.records;
|
||||
proms.push(targetList._resequence(records, this.resModel, dataRecordId, refId));
|
||||
}
|
||||
return Promise.all(proms);
|
||||
}
|
||||
|
||||
async resequence(movedGroupId, targetGroupId) {
|
||||
if (!this.groupByField || this.groupByField.type !== "many2one") {
|
||||
throw new Error("Cannot resequence a group on a non many2one group field");
|
||||
}
|
||||
|
||||
return this.model.mutex.exec(async () => {
|
||||
await this._resequence(
|
||||
this.groups,
|
||||
this.groupByField.relation,
|
||||
movedGroupId,
|
||||
targetGroupId
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async selectDomain(value) {
|
||||
return this.model.mutex.exec(async () => {
|
||||
await this._ensureCorrectRecordCount();
|
||||
this._selectDomain(value);
|
||||
});
|
||||
}
|
||||
|
||||
async sortBy(fieldName) {
|
||||
if (!this.groups.length) {
|
||||
return;
|
||||
}
|
||||
if (this.groups.every((group) => group.isFolded)) {
|
||||
// all groups are folded
|
||||
if (this.groupByField.name !== fieldName) {
|
||||
// grouped by another field than fieldName
|
||||
if (!(fieldName in this.groups[0].aggregates)) {
|
||||
// fieldName has no aggregate values
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
return super.sortBy(fieldName);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async _createGroup(groupName, foldField = false) {
|
||||
const [id] = await this.model.orm.call(
|
||||
this.groupByField.relation,
|
||||
"name_create",
|
||||
[groupName],
|
||||
{ context: this.context }
|
||||
);
|
||||
if (foldField) {
|
||||
await this.model.orm.write(
|
||||
this.groupByField.relation,
|
||||
[id],
|
||||
{ [foldField]: true },
|
||||
{ context: this.context }
|
||||
);
|
||||
}
|
||||
const lastGroup = this.groups.at(-1);
|
||||
|
||||
// This is almost a copy/past of the code in relational_model.js
|
||||
// Maybe we can create an addGroup method in relational_model.js
|
||||
// and call it from here and from relational_model.js
|
||||
const commonConfig = {
|
||||
resModel: this.config.resModel,
|
||||
fields: this.config.fields,
|
||||
activeFields: this.config.activeFields,
|
||||
fieldsToAggregate: this.config.fieldsToAggregate,
|
||||
};
|
||||
const context = {
|
||||
...this.context,
|
||||
[`default_${this.groupByField.name}`]: id,
|
||||
};
|
||||
const nextConfigGroups = { ...this.config.groups };
|
||||
const domain = Domain.and([this.domain, [[this.groupByField.name, "=", id]]]).toList();
|
||||
const groupBy = this.groupBy.slice(1);
|
||||
nextConfigGroups[id] = {
|
||||
...commonConfig,
|
||||
context,
|
||||
groupByFieldName: this.groupByField.name,
|
||||
isFolded: Boolean(foldField),
|
||||
value: id,
|
||||
extraDomain: false,
|
||||
initialDomain: domain,
|
||||
list: {
|
||||
...commonConfig,
|
||||
context,
|
||||
domain: domain,
|
||||
groupBy,
|
||||
orderBy: this.orderBy,
|
||||
limit: this.model.initialLimit,
|
||||
offset: 0,
|
||||
},
|
||||
};
|
||||
this.model._updateConfig(this.config, { groups: nextConfigGroups }, { reload: false });
|
||||
|
||||
const data = {
|
||||
aggregates: {},
|
||||
count: 0,
|
||||
length: 0,
|
||||
__domain: domain,
|
||||
[this.groupByField.name]: [id, groupName],
|
||||
value: id,
|
||||
serverValue: getGroupServerValue(this.groupByField, id),
|
||||
displayName: groupName,
|
||||
rawValue: [id, groupName],
|
||||
};
|
||||
if (groupBy.length) {
|
||||
data.groups = [];
|
||||
} else {
|
||||
data.records = [];
|
||||
}
|
||||
|
||||
const group = this._createGroupDatapoint(data);
|
||||
if (lastGroup) {
|
||||
const groups = [...this.groups, group];
|
||||
await this._resequence(groups, this.groupByField.relation, group.id, lastGroup.id);
|
||||
this.groups = groups;
|
||||
} else {
|
||||
this.groups.push(group);
|
||||
}
|
||||
}
|
||||
|
||||
_createGroupDatapoint(data) {
|
||||
return new this.model.constructor.Group(this.model, this.config.groups[data.value], data);
|
||||
}
|
||||
|
||||
async _deleteGroups(groups) {
|
||||
const shouldReload = groups.some((g) => g.count > 0);
|
||||
await this._unlinkGroups(groups);
|
||||
const configGroups = { ...this.config.groups };
|
||||
for (const group of groups) {
|
||||
delete configGroups[group.value];
|
||||
}
|
||||
if (shouldReload) {
|
||||
await this.model._updateConfig(
|
||||
this.config,
|
||||
{ groups: configGroups },
|
||||
{ commit: this._setData.bind(this) }
|
||||
);
|
||||
} else {
|
||||
for (const group of groups) {
|
||||
this._removeGroup(group);
|
||||
}
|
||||
this.model._updateConfig(this.config, { groups: configGroups }, { reload: false });
|
||||
}
|
||||
}
|
||||
|
||||
async _ensureCorrectRecordCount() {
|
||||
if (!this.isRecordCountTrustable) {
|
||||
this._nbRecordsMatchingDomain = await this.model.orm.searchCount(
|
||||
this.resModel,
|
||||
this.domain,
|
||||
{ limit: this.model.initialCountLimit }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
_getDPresId(group) {
|
||||
return group.value;
|
||||
}
|
||||
|
||||
_getDPFieldValue(group, handleField) {
|
||||
return group[handleField];
|
||||
}
|
||||
|
||||
async _load(offset, limit, orderBy, domain) {
|
||||
await this.model._updateConfig(
|
||||
this.config,
|
||||
{ offset, limit, orderBy, domain },
|
||||
{ commit: this._setData.bind(this) }
|
||||
);
|
||||
if (this.isDomainSelected) {
|
||||
await this._ensureCorrectRecordCount();
|
||||
}
|
||||
}
|
||||
|
||||
_removeGroup(group) {
|
||||
const index = this.groups.findIndex((g) => g.id === group.id);
|
||||
this.groups.splice(index, 1);
|
||||
this.count--;
|
||||
}
|
||||
|
||||
_removeRecords(recordIds) {
|
||||
const proms = [];
|
||||
for (const group of this.groups) {
|
||||
proms.push(group._removeRecords(recordIds));
|
||||
}
|
||||
return Promise.all(proms);
|
||||
}
|
||||
|
||||
_selectDomain(value) {
|
||||
for (const group of this.groups) {
|
||||
group.list._selectDomain(value);
|
||||
}
|
||||
super._selectDomain(value);
|
||||
}
|
||||
|
||||
async _toggleSelection() {
|
||||
if (!this.records.length) {
|
||||
// all groups are folded, so there's no visible records => select all domain
|
||||
if (!this.isDomainSelected) {
|
||||
await this._ensureCorrectRecordCount();
|
||||
this._selectDomain(true);
|
||||
} else {
|
||||
this._selectDomain(false);
|
||||
}
|
||||
} else {
|
||||
super._toggleSelection();
|
||||
}
|
||||
}
|
||||
|
||||
_unlinkGroups(groups) {
|
||||
const groupResIds = groups.map((g) => g.value);
|
||||
return this.model.orm.unlink(this.groupByField.relation, groupResIds, {
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
}
|
||||
512
frontend/web/static/src/model/relational_model/dynamic_list.js
Normal file
512
frontend/web/static/src/model/relational_model/dynamic_list.js
Normal file
@@ -0,0 +1,512 @@
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { unique } from "@web/core/utils/arrays";
|
||||
import { DataPoint } from "./datapoint";
|
||||
import { Operation } from "./operation";
|
||||
import { Record as RelationalRecord } from "./record";
|
||||
import { getFieldsSpec, resequence } from "./utils";
|
||||
|
||||
/**
|
||||
* @typedef {import("./record").Record} RelationalRecord
|
||||
*/
|
||||
|
||||
const DEFAULT_HANDLE_FIELD = "sequence";
|
||||
|
||||
/**
|
||||
* @abstract
|
||||
*/
|
||||
export class DynamicList extends DataPoint {
|
||||
/**
|
||||
* @type {DataPoint["setup"]}
|
||||
*/
|
||||
setup() {
|
||||
super.setup(...arguments);
|
||||
this.handleField = Object.keys(this.activeFields).find(
|
||||
(fieldName) => this.activeFields[fieldName].isHandle
|
||||
);
|
||||
if (!this.handleField && DEFAULT_HANDLE_FIELD in this.fields) {
|
||||
this.handleField = DEFAULT_HANDLE_FIELD;
|
||||
}
|
||||
this.isDomainSelected = false;
|
||||
this.evalContext = this.context;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get groupBy() {
|
||||
return [];
|
||||
}
|
||||
|
||||
get orderBy() {
|
||||
return this.config.orderBy;
|
||||
}
|
||||
|
||||
get domain() {
|
||||
return this.config.domain;
|
||||
}
|
||||
|
||||
/**
|
||||
* Be careful that this getter is costly, as it iterates over the whole list
|
||||
* of records. This property should not be accessed in a loop.
|
||||
*/
|
||||
get editedRecord() {
|
||||
return this.records.find((record) => record.isInEdition);
|
||||
}
|
||||
|
||||
get isRecordCountTrustable() {
|
||||
return true;
|
||||
}
|
||||
|
||||
get limit() {
|
||||
return this.config.limit;
|
||||
}
|
||||
|
||||
get offset() {
|
||||
return this.config.offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Be careful that this getter is costly, as it iterates over the whole list
|
||||
* of records. This property should not be accessed in a loop.
|
||||
*/
|
||||
get selection() {
|
||||
return this.records.filter((record) => record.selected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
archive(isSelected) {
|
||||
return this.model.mutex.exec(() => this._toggleArchive(isSelected, true));
|
||||
}
|
||||
|
||||
canResequence() {
|
||||
return !!this.handleField;
|
||||
}
|
||||
|
||||
deleteRecords(records = []) {
|
||||
return this.model.mutex.exec(() => this._deleteRecords(records));
|
||||
}
|
||||
|
||||
duplicateRecords(records = []) {
|
||||
return this.model.mutex.exec(() => this._duplicateRecords(records));
|
||||
}
|
||||
|
||||
async enterEditMode(record) {
|
||||
if (this.editedRecord === record) {
|
||||
return true;
|
||||
}
|
||||
const canProceed = await this.leaveEditMode();
|
||||
if (canProceed) {
|
||||
record._checkValidity();
|
||||
this.model._updateConfig(record.config, { mode: "edit" }, { reload: false });
|
||||
}
|
||||
return canProceed;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [isSelected]
|
||||
* @returns {Promise<number[]>}
|
||||
*/
|
||||
async getResIds(isSelected) {
|
||||
let resIds;
|
||||
if (isSelected) {
|
||||
if (this.isDomainSelected) {
|
||||
resIds = await this.model.orm.search(this.resModel, this.domain, {
|
||||
limit: this.model.activeIdsLimit,
|
||||
context: this.context,
|
||||
});
|
||||
} else {
|
||||
resIds = this.selection.map((r) => r.resId);
|
||||
}
|
||||
} else {
|
||||
resIds = this.records.map((r) => r.resId);
|
||||
}
|
||||
return unique(resIds);
|
||||
}
|
||||
|
||||
async leaveEditMode({ discard } = {}) {
|
||||
let editedRecord = this.editedRecord;
|
||||
if (editedRecord) {
|
||||
let canProceed = true;
|
||||
if (discard) {
|
||||
this._recordToDiscard = editedRecord;
|
||||
await editedRecord.discard();
|
||||
this._recordToDiscard = null;
|
||||
editedRecord = this.editedRecord;
|
||||
if (editedRecord && editedRecord.isNew) {
|
||||
this._removeRecords([editedRecord.id]);
|
||||
}
|
||||
} else {
|
||||
let isValid = true;
|
||||
if (!this.model._urgentSave) {
|
||||
isValid = await editedRecord.checkValidity();
|
||||
editedRecord = this.editedRecord;
|
||||
if (!editedRecord) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
if (editedRecord.isNew && !editedRecord.dirty) {
|
||||
this._removeRecords([editedRecord.id]);
|
||||
} else if (isValid || editedRecord.dirty) {
|
||||
canProceed = await editedRecord.save();
|
||||
}
|
||||
}
|
||||
|
||||
editedRecord = this.editedRecord;
|
||||
if (canProceed && editedRecord) {
|
||||
this.model._updateConfig(
|
||||
editedRecord.config,
|
||||
{ mode: "readonly" },
|
||||
{ reload: false }
|
||||
);
|
||||
} else {
|
||||
return canProceed;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
load(params = {}) {
|
||||
const limit = params.limit === undefined ? this.limit : params.limit;
|
||||
const offset = params.offset === undefined ? this.offset : params.offset;
|
||||
const orderBy = params.orderBy === undefined ? this.orderBy : params.orderBy;
|
||||
const domain = params.domain === undefined ? this.domain : params.domain;
|
||||
return this.model.mutex.exec(() => this._load(offset, limit, orderBy, domain));
|
||||
}
|
||||
|
||||
async multiSave(record, changes) {
|
||||
return this.model.mutex.exec(() => this._multiSave(record, changes));
|
||||
}
|
||||
|
||||
selectDomain(value) {
|
||||
return this.model.mutex.exec(() => this._selectDomain(value));
|
||||
}
|
||||
|
||||
sortBy(fieldName) {
|
||||
return this.model.mutex.exec(() => {
|
||||
let orderBy = [...this.orderBy];
|
||||
if (orderBy.length && orderBy[0].name === fieldName) {
|
||||
if (orderBy[0].asc) {
|
||||
orderBy[0] = { name: orderBy[0].name, asc: false };
|
||||
} else {
|
||||
orderBy = [];
|
||||
}
|
||||
} else {
|
||||
orderBy = orderBy.filter((o) => o.name !== fieldName);
|
||||
orderBy.unshift({
|
||||
name: fieldName,
|
||||
asc: true,
|
||||
});
|
||||
}
|
||||
return this._load(this.offset, this.limit, orderBy, this.domain);
|
||||
});
|
||||
}
|
||||
|
||||
toggleSelection() {
|
||||
return this.model.mutex.exec(() => this._toggleSelection());
|
||||
}
|
||||
|
||||
unarchive(isSelected) {
|
||||
return this.model.mutex.exec(() => this._toggleArchive(isSelected, false));
|
||||
}
|
||||
|
||||
toggleArchiveWithConfirmation(archive, dialogProps = {}) {
|
||||
const isSelected = this.isDomainSelected || this.selection.length > 0;
|
||||
if (archive) {
|
||||
const defaultProps = {
|
||||
body: _t("Are you sure that you want to archive all the selected records?"),
|
||||
cancel: () => {},
|
||||
confirm: () => this.archive(isSelected),
|
||||
confirmLabel: _t("Archive"),
|
||||
};
|
||||
this.model.dialog.add(ConfirmationDialog, { ...defaultProps, ...dialogProps });
|
||||
} else {
|
||||
this.unarchive(isSelected);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async _duplicateRecords(records) {
|
||||
let resIds;
|
||||
if (records.length) {
|
||||
resIds = unique(records.map((r) => r.resId));
|
||||
} else {
|
||||
resIds = await this.getResIds(true);
|
||||
}
|
||||
|
||||
const copy = async (resIds) => {
|
||||
const copiedRecords = await this.model.orm.call(this.resModel, "copy", [resIds], {
|
||||
context: this.context,
|
||||
});
|
||||
|
||||
if (resIds.length > copiedRecords.length) {
|
||||
this.model.notification.add(_t("Some records could not be duplicated"));
|
||||
}
|
||||
return this.model.load();
|
||||
};
|
||||
|
||||
if (resIds.length > 1) {
|
||||
this.model.dialog.add(ConfirmationDialog, {
|
||||
body: _t("Are you sure that you want to duplicate all the selected records?"),
|
||||
confirm: () => copy(resIds),
|
||||
cancel: () => {},
|
||||
confirmLabel: _t("Confirm"),
|
||||
});
|
||||
} else {
|
||||
await copy(resIds);
|
||||
}
|
||||
}
|
||||
|
||||
async _deleteRecords(records) {
|
||||
let resIds;
|
||||
if (records.length) {
|
||||
resIds = unique(records.map((r) => r.resId));
|
||||
} else {
|
||||
resIds = await this.getResIds(true);
|
||||
records = this.records.filter((r) => resIds.includes(r.resId));
|
||||
}
|
||||
const unlinked = await this.model.orm.unlink(this.resModel, resIds, {
|
||||
context: this.context,
|
||||
});
|
||||
if (!unlinked) {
|
||||
return false;
|
||||
}
|
||||
if (
|
||||
this.isDomainSelected &&
|
||||
resIds.length === this.model.activeIdsLimit &&
|
||||
resIds.length < this.count
|
||||
) {
|
||||
const msg = _t(
|
||||
"Only the first %(count)s records have been deleted (out of %(total)s selected)",
|
||||
{ count: resIds.length, total: this.count }
|
||||
);
|
||||
this.model.notification.add(msg);
|
||||
}
|
||||
await this.model.load();
|
||||
return unlinked;
|
||||
}
|
||||
|
||||
async _leaveSampleMode() {
|
||||
if (this.model.useSampleModel) {
|
||||
await this._load(this.offset, this.limit, this.orderBy, this.domain);
|
||||
this.model.useSampleModel = false;
|
||||
}
|
||||
}
|
||||
|
||||
async _multiSave(editedRecord, changes) {
|
||||
if (!Object.keys(changes).length || editedRecord === this._recordToDiscard) {
|
||||
return;
|
||||
}
|
||||
let canProceed = await this.model.hooks.onWillSaveMulti(editedRecord, changes);
|
||||
if (canProceed === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const selectedRecords = this.selection; // costly getter => compute it once
|
||||
|
||||
// special treatment for x2manys: apply commands on all selected record's static lists
|
||||
const proms = [];
|
||||
for (const fieldName in changes) {
|
||||
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
|
||||
const list = editedRecord.data[fieldName];
|
||||
const commands = list._getCommands();
|
||||
if ("display_name" in list.activeFields) {
|
||||
// add display_name to LINK commands to prevent a web_read by selected record
|
||||
for (const command of commands) {
|
||||
if (command[0] === x2ManyCommands.LINK) {
|
||||
const relRecord = list._cache[command[1]];
|
||||
command[2] = { display_name: relRecord.data.display_name };
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const record of selectedRecords) {
|
||||
if (record !== editedRecord) {
|
||||
proms.push(record.data[fieldName]._applyCommands(commands));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
await Promise.all(proms);
|
||||
// apply changes on all selected records (for x2manys, the change is the static list itself)
|
||||
selectedRecords.forEach((record) => {
|
||||
const _changes = Object.assign({}, changes);
|
||||
for (const fieldName in _changes) {
|
||||
if (["one2many", "many2many"].includes(this.fields[fieldName].type)) {
|
||||
_changes[fieldName] = record.data[fieldName];
|
||||
}
|
||||
}
|
||||
record._applyChanges(_changes);
|
||||
});
|
||||
|
||||
// determine valid and invalid records
|
||||
const validRecords = [];
|
||||
const invalidRecords = [];
|
||||
for (const record of selectedRecords) {
|
||||
const isEditedRecord = record === editedRecord;
|
||||
if (
|
||||
Object.keys(changes).every((fieldName) => !record._isReadonly(fieldName)) &&
|
||||
record._checkValidity({ silent: !isEditedRecord })
|
||||
) {
|
||||
validRecords.push(record);
|
||||
} else {
|
||||
invalidRecords.push(record);
|
||||
}
|
||||
}
|
||||
const discardInvalidRecords = () => invalidRecords.forEach((record) => record._discard());
|
||||
|
||||
if (validRecords.length === 0) {
|
||||
editedRecord._displayInvalidFieldNotification();
|
||||
discardInvalidRecords();
|
||||
return false;
|
||||
}
|
||||
|
||||
// generate the save callback with the values to save (must be done before discarding
|
||||
// invalid records, in case the editedRecord is itself invalid)
|
||||
const resIds = unique(validRecords.map((r) => r.resId));
|
||||
const kwargs = {
|
||||
context: this.context,
|
||||
specification: getFieldsSpec(editedRecord.activeFields, editedRecord.fields),
|
||||
};
|
||||
let save;
|
||||
if (Object.values(changes).some((v) => v instanceof Operation)) {
|
||||
// "changes" contains a Field Operation => we must call the web_save_multi method to
|
||||
// save each record individually
|
||||
const changesById = {};
|
||||
for (const record of validRecords) {
|
||||
changesById[record.resId] = changesById[record.resId] || record._getChanges();
|
||||
}
|
||||
const valsList = resIds.map((resId) => changesById[resId]);
|
||||
save = () => this.model.orm.webSaveMulti(this.resModel, resIds, valsList, kwargs);
|
||||
} else {
|
||||
const vals = editedRecord._getChanges();
|
||||
save = () => this.model.orm.webSave(this.resModel, resIds, vals, kwargs);
|
||||
}
|
||||
|
||||
const _changes = Object.assign(changes);
|
||||
for (const fieldName in changes) {
|
||||
if (this.fields[fieldName].type === "many2many") {
|
||||
const list = changes[fieldName];
|
||||
_changes[fieldName] = {
|
||||
add: list._commands
|
||||
.filter((command) => command[0] === x2ManyCommands.LINK)
|
||||
.map((command) => list._cache[command[1]]),
|
||||
remove: list._commands
|
||||
.filter((command) => command[0] === x2ManyCommands.UNLINK)
|
||||
.map((command) => list._cache[command[1]]),
|
||||
};
|
||||
}
|
||||
}
|
||||
discardInvalidRecords();
|
||||
|
||||
// ask confirmation
|
||||
canProceed = await this.model.hooks.onAskMultiSaveConfirmation(_changes, validRecords);
|
||||
if (canProceed === false) {
|
||||
selectedRecords.forEach((record) => record._discard());
|
||||
this.leaveEditMode({ discard: true });
|
||||
return false;
|
||||
}
|
||||
|
||||
// save changes
|
||||
let records = [];
|
||||
try {
|
||||
records = await save();
|
||||
} catch (e) {
|
||||
selectedRecords.forEach((record) => record._discard());
|
||||
this.model._updateConfig(editedRecord.config, { mode: "readonly" }, { reload: false });
|
||||
throw e;
|
||||
}
|
||||
const serverValuesById = Object.fromEntries(records.map((record) => [record.id, record]));
|
||||
for (const record of validRecords) {
|
||||
const serverValues = serverValuesById[record.resId];
|
||||
record._setData(serverValues);
|
||||
this.model._updateSimilarRecords(record, serverValues);
|
||||
}
|
||||
this.model._updateConfig(editedRecord.config, { mode: "readonly" }, { reload: false });
|
||||
this.model.hooks.onSavedMulti(validRecords);
|
||||
return true;
|
||||
}
|
||||
|
||||
async _resequence(originalList, resModel, movedId, targetId) {
|
||||
if (this.resModel === resModel && !this.canResequence()) {
|
||||
return;
|
||||
}
|
||||
const handleField = this.resModel === resModel ? this.handleField : DEFAULT_HANDLE_FIELD;
|
||||
const order = this.orderBy.find((o) => o.name === handleField);
|
||||
const getSequence = (dp) => dp && this._getDPFieldValue(dp, handleField);
|
||||
const getResId = (dp) => this._getDPresId(dp);
|
||||
const resequencedRecords = await resequence({
|
||||
records: originalList,
|
||||
resModel,
|
||||
movedId,
|
||||
targetId,
|
||||
fieldName: handleField,
|
||||
asc: order?.asc,
|
||||
context: this.context,
|
||||
orm: this.model.orm,
|
||||
getSequence,
|
||||
getResId,
|
||||
});
|
||||
for (const dpData of resequencedRecords) {
|
||||
const dp = originalList.find((d) => getResId(d) === dpData.id);
|
||||
if (dp instanceof RelationalRecord) {
|
||||
dp._applyValues(dpData);
|
||||
} else {
|
||||
dp[handleField] = dpData[handleField];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_selectDomain(value) {
|
||||
this.isDomainSelected = value;
|
||||
}
|
||||
|
||||
async _toggleArchive(isSelected, state) {
|
||||
const method = state ? "action_archive" : "action_unarchive";
|
||||
const context = this.context;
|
||||
const resIds = await this.getResIds(isSelected);
|
||||
const action = await this.model.orm.call(this.resModel, method, [resIds], { context });
|
||||
if (
|
||||
this.isDomainSelected &&
|
||||
resIds.length === this.model.activeIdsLimit &&
|
||||
resIds.length < this.count
|
||||
) {
|
||||
const msg = _t(
|
||||
"Of the %(selectedRecord)s selected records, only the first %(firstRecords)s have been archived/unarchived.",
|
||||
{
|
||||
selectedRecords: resIds.length,
|
||||
firstRecords: this.count,
|
||||
}
|
||||
);
|
||||
this.model.notification.add(msg);
|
||||
}
|
||||
const reload = () => this.model.load();
|
||||
if (action && Object.keys(action).length) {
|
||||
this.model.action.doAction(action, {
|
||||
onClose: reload,
|
||||
});
|
||||
} else {
|
||||
return reload();
|
||||
}
|
||||
}
|
||||
|
||||
async _toggleSelection() {
|
||||
if (this.selection.length === this.records.length) {
|
||||
this.records.forEach((record) => {
|
||||
record._toggleSelection(false);
|
||||
});
|
||||
this._selectDomain(false);
|
||||
} else {
|
||||
this.records.forEach((record) => {
|
||||
record._toggleSelection(true);
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { DynamicList } from "./dynamic_list";
|
||||
|
||||
/**
|
||||
* @typedef {import("./record").Record} RelationalRecord
|
||||
*/
|
||||
|
||||
export class DynamicRecordList extends DynamicList {
|
||||
static type = "DynamicRecordList";
|
||||
|
||||
/**
|
||||
* @param {import("./relational_model").Config} config
|
||||
* @param {Object} data
|
||||
*/
|
||||
setup(config, data) {
|
||||
super.setup(config);
|
||||
this._setData(data);
|
||||
}
|
||||
|
||||
_setData(data) {
|
||||
/** @type {RelationalRecord[]} */
|
||||
this.records = data.records.map((r) => this._createRecordDatapoint(r));
|
||||
this._updateCount(data);
|
||||
this._selectDomain(this.isDomainSelected);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getter
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get hasData() {
|
||||
return this.count > 0;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {number} resId
|
||||
* @param {boolean} [atFirstPosition]
|
||||
* @returns {Promise<Record>} the newly created record
|
||||
*/
|
||||
addExistingRecord(resId, atFirstPosition) {
|
||||
return this.model.mutex.exec(async () => {
|
||||
const record = this._createRecordDatapoint({});
|
||||
await record._load({ resId });
|
||||
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
|
||||
return record;
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {boolean} [atFirstPosition=false]
|
||||
* @returns {Promise<Record>}
|
||||
*/
|
||||
addNewRecord(atFirstPosition = false) {
|
||||
return this.model.mutex.exec(async () => {
|
||||
await this._leaveSampleMode();
|
||||
return this._addNewRecord(atFirstPosition);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs a search_count with the current domain to set the count. This is
|
||||
* useful as web_search_read limits the count for performance reasons, so it
|
||||
* might sometimes be less than the real number of records matching the domain.
|
||||
**/
|
||||
async fetchCount() {
|
||||
this.count = await this.model._updateCount(this.config);
|
||||
this.hasLimitedCount = false;
|
||||
return this.count;
|
||||
}
|
||||
|
||||
moveRecord(dataRecordId, _dataGroupId, refId, _targetGroupId) {
|
||||
return this.resequence(dataRecordId, refId);
|
||||
}
|
||||
|
||||
removeRecord(record) {
|
||||
if (!record.isNew) {
|
||||
throw new Error("removeRecord can't be called on an existing record");
|
||||
}
|
||||
const index = this.records.findIndex((r) => r === record);
|
||||
if (index < 0) {
|
||||
return;
|
||||
}
|
||||
this.records.splice(index, 1);
|
||||
this.count--;
|
||||
return record;
|
||||
}
|
||||
|
||||
async resequence(movedRecordId, targetRecordId) {
|
||||
return this.model.mutex.exec(
|
||||
async () =>
|
||||
await this._resequence(this.records, this.resModel, movedRecordId, targetRecordId)
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async _addNewRecord(atFirstPosition) {
|
||||
const values = await this.model._loadNewRecord({
|
||||
resModel: this.resModel,
|
||||
activeFields: this.activeFields,
|
||||
fields: this.fields,
|
||||
context: this.context,
|
||||
});
|
||||
const record = this._createRecordDatapoint(values, "edit");
|
||||
this._addRecord(record, atFirstPosition ? 0 : this.records.length);
|
||||
return record;
|
||||
}
|
||||
|
||||
_addRecord(record, index) {
|
||||
this.records.splice(Number.isInteger(index) ? index : this.records.length, 0, record);
|
||||
this.count++;
|
||||
}
|
||||
|
||||
_createRecordDatapoint(data, mode = "readonly") {
|
||||
return new this.model.constructor.Record(
|
||||
this.model,
|
||||
{
|
||||
context: this.context,
|
||||
activeFields: this.activeFields,
|
||||
resModel: this.resModel,
|
||||
fields: this.fields,
|
||||
resId: data.id || false,
|
||||
resIds: data.id ? [data.id] : [],
|
||||
isMonoRecord: true,
|
||||
mode,
|
||||
},
|
||||
data,
|
||||
{ manuallyAdded: !data.id }
|
||||
);
|
||||
}
|
||||
|
||||
_getDPresId(record) {
|
||||
return record.resId;
|
||||
}
|
||||
|
||||
_getDPFieldValue(record, handleField) {
|
||||
return record.data[handleField];
|
||||
}
|
||||
|
||||
async _load(offset, limit, orderBy, domain) {
|
||||
await this.model._updateConfig(
|
||||
this.config,
|
||||
{ offset, limit, orderBy, domain },
|
||||
{ commit: this._setData.bind(this) }
|
||||
);
|
||||
}
|
||||
|
||||
_removeRecords(recordIds) {
|
||||
const keptRecords = this.records.filter((r) => !recordIds.includes(r.id));
|
||||
this.count -= this.records.length - keptRecords.length;
|
||||
this.records = keptRecords;
|
||||
if (this.offset && !this.records.length) {
|
||||
// we weren't on the first page, and we removed all records of the current page
|
||||
const offset = Math.max(this.offset - this.limit, 0);
|
||||
this.model._updateConfig(this.config, { offset }, { reload: false });
|
||||
}
|
||||
}
|
||||
|
||||
_selectDomain(value) {
|
||||
if (value) {
|
||||
this.records.forEach((r) => (r.selected = true));
|
||||
}
|
||||
super._selectDomain(value);
|
||||
}
|
||||
|
||||
_updateCount(data) {
|
||||
const length = data.length;
|
||||
if (length >= this.config.countLimit + 1) {
|
||||
this.hasLimitedCount = true;
|
||||
this.count = this.config.countLimit;
|
||||
} else {
|
||||
this.hasLimitedCount = false;
|
||||
this.count = length;
|
||||
}
|
||||
}
|
||||
}
|
||||
22
frontend/web/static/src/model/relational_model/errors.js
Normal file
22
frontend/web/static/src/model/relational_model/errors.js
Normal file
@@ -0,0 +1,22 @@
|
||||
import { registry } from "@web/core/registry";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class FetchRecordError extends Error {
|
||||
constructor(resIds) {
|
||||
super(
|
||||
_t(
|
||||
"It seems the records with IDs %s cannot be found. They might have been deleted.",
|
||||
resIds
|
||||
)
|
||||
);
|
||||
this.resIds = resIds;
|
||||
}
|
||||
}
|
||||
function fetchRecordErrorHandler(env, error, originalError) {
|
||||
if (originalError instanceof FetchRecordError) {
|
||||
env.services.notification.add(originalError.message, { sticky: true, type: "danger" });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
const errorHandlerRegistry = registry.category("error_handlers");
|
||||
errorHandlerRegistry.add("fetchRecordErrorHandler", fetchRecordErrorHandler);
|
||||
136
frontend/web/static/src/model/relational_model/group.js
Normal file
136
frontend/web/static/src/model/relational_model/group.js
Normal file
@@ -0,0 +1,136 @@
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { DataPoint } from "./datapoint";
|
||||
|
||||
/**
|
||||
* @typedef Params
|
||||
* @property {string[]} groupBy
|
||||
*/
|
||||
|
||||
export class Group extends DataPoint {
|
||||
static type = "Group";
|
||||
|
||||
/**
|
||||
* @param {import("./relational_model").Config} config
|
||||
*/
|
||||
setup(config, data) {
|
||||
super.setup(...arguments);
|
||||
this.groupByField = this.fields[config.groupByFieldName];
|
||||
this.range = data.range;
|
||||
this._rawValue = data.rawValue;
|
||||
/** @type {number} */
|
||||
this.count = data.count;
|
||||
this.value = data.value;
|
||||
this.serverValue = data.serverValue;
|
||||
this.displayName = data.displayName;
|
||||
this.aggregates = data.aggregates;
|
||||
let List;
|
||||
if (config.list.groupBy.length) {
|
||||
List = this.model.constructor.DynamicGroupList;
|
||||
} else {
|
||||
List = this.model.constructor.DynamicRecordList;
|
||||
}
|
||||
/** @type {import("./dynamic_group_list").DynamicGroupList | import("./dynamic_record_list").DynamicRecordList} */
|
||||
this.list = new List(this.model, config.list, data);
|
||||
this._useGroupCountForList();
|
||||
if (config.record) {
|
||||
config.record.context = { ...config.record.context, ...config.context };
|
||||
this.record = new this.model.constructor.Record(this.model, config.record, data.values);
|
||||
}
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Getters
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
get groupDomain() {
|
||||
return this.config.initialDomain;
|
||||
}
|
||||
get hasData() {
|
||||
return this.count > 0;
|
||||
}
|
||||
get isFolded() {
|
||||
return this.config.isFolded;
|
||||
}
|
||||
get records() {
|
||||
return this.list.records;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
async addExistingRecord(resId, atFirstPosition = false) {
|
||||
const record = await this.list.addExistingRecord(resId, atFirstPosition);
|
||||
this.count++;
|
||||
return record;
|
||||
}
|
||||
|
||||
async addNewRecord(_unused, atFirstPosition = false) {
|
||||
const canProceed = await this.model.root.leaveEditMode();
|
||||
if (canProceed) {
|
||||
const record = await this.list.addNewRecord(atFirstPosition);
|
||||
if (record) {
|
||||
this.count++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async applyFilter(filter) {
|
||||
if (filter) {
|
||||
await this.list.load({
|
||||
domain: Domain.and([this.groupDomain, filter]).toList(),
|
||||
});
|
||||
} else {
|
||||
await this.list.load({ domain: this.groupDomain });
|
||||
this.count = this.list.isGrouped ? this.list.recordCount : this.list.count;
|
||||
}
|
||||
this.model._updateConfig(this.config, { extraDomain: filter }, { reload: false });
|
||||
}
|
||||
|
||||
deleteRecords(records) {
|
||||
return this.model.mutex.exec(() => this._deleteRecords(records));
|
||||
}
|
||||
|
||||
async toggle() {
|
||||
if (this.config.isFolded) {
|
||||
await this.list.load();
|
||||
}
|
||||
this._useGroupCountForList();
|
||||
this.model._updateConfig(
|
||||
this.config,
|
||||
{ isFolded: !this.config.isFolded },
|
||||
{ reload: false }
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
_addRecord(record, index) {
|
||||
this.list._addRecord(record, index);
|
||||
this.count++;
|
||||
}
|
||||
|
||||
async _deleteRecords(records) {
|
||||
await this.list._deleteRecords(records);
|
||||
this.count -= records.length;
|
||||
}
|
||||
|
||||
/**
|
||||
* The count returned by web_search_read is limited (see DEFAULT_COUNT_LIMIT). However, the one
|
||||
* returned by formatted_read_group, for each group, isn't. So in the grouped case, it might happen
|
||||
* that the group count is more accurate than the list one. It that case, we use it on the list.
|
||||
*/
|
||||
_useGroupCountForList() {
|
||||
if (!this.list.isGrouped && this.list.count === this.list.config.countLimit) {
|
||||
this.list.count = this.count;
|
||||
}
|
||||
}
|
||||
|
||||
async _removeRecords(recordIds) {
|
||||
const idsToRemove = recordIds.filter((id) => this.list.records.some((r) => r.id === id));
|
||||
this.list._removeRecords(idsToRemove);
|
||||
this.count -= idsToRemove.length;
|
||||
}
|
||||
}
|
||||
21
frontend/web/static/src/model/relational_model/operation.js
Normal file
21
frontend/web/static/src/model/relational_model/operation.js
Normal file
@@ -0,0 +1,21 @@
|
||||
export class Operation {
|
||||
constructor(operator, operand) {
|
||||
this.operator = operator;
|
||||
this.operand = operand;
|
||||
}
|
||||
|
||||
compute(value) {
|
||||
switch (this.operator) {
|
||||
case "+":
|
||||
return value + this.operand;
|
||||
case "-":
|
||||
return value - this.operand;
|
||||
case "*":
|
||||
return value * this.operand;
|
||||
case "/":
|
||||
return value / this.operand;
|
||||
default:
|
||||
throw new Error(`Unsupported operator: ${this.operator}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
1405
frontend/web/static/src/model/relational_model/record.js
Normal file
1405
frontend/web/static/src/model/relational_model/record.js
Normal file
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,870 @@
|
||||
// @ts-check
|
||||
|
||||
import { EventBus, markRaw, toRaw } from "@odoo/owl";
|
||||
import { makeContext } from "@web/core/context";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { WarningDialog } from "@web/core/errors/error_dialogs";
|
||||
import { rpcBus } from "@web/core/network/rpc";
|
||||
import { shallowEqual } from "@web/core/utils/arrays";
|
||||
import { deepCopy, pick } from "@web/core/utils/objects";
|
||||
import { Deferred, KeepLast, Mutex } from "@web/core/utils/concurrency";
|
||||
import { orderByToString } from "@web/search/utils/order_by";
|
||||
import { Model } from "../model";
|
||||
import { DynamicGroupList } from "./dynamic_group_list";
|
||||
import { DynamicRecordList } from "./dynamic_record_list";
|
||||
import { Group } from "./group";
|
||||
import { Record as RelationalRecord } from "./record";
|
||||
import { StaticList } from "./static_list";
|
||||
import {
|
||||
extractInfoFromGroupData,
|
||||
getAggregateSpecifications,
|
||||
getBasicEvalContext,
|
||||
getFieldsSpec,
|
||||
getGroupServerValue,
|
||||
getId,
|
||||
makeActiveField,
|
||||
} from "./utils";
|
||||
import { FetchRecordError } from "./errors";
|
||||
|
||||
/**
|
||||
* @typedef {import("@web/core/context").Context} Context
|
||||
* @typedef {import("./datapoint").DataPoint} DataPoint
|
||||
* @typedef {import("@web/core/domain").DomainListRepr} DomainListRepr
|
||||
* @typedef {import("@web/search/search_model").Field} Field
|
||||
* @typedef {import("@web/search/search_model").FieldInfo} FieldInfo
|
||||
* @typedef {import("@web/search/search_model").SearchParams} SearchParams
|
||||
* @typedef {import("services").ServiceFactories} Services
|
||||
*
|
||||
* @typedef {{
|
||||
* changes?: Record<string, unknown>;
|
||||
* fieldNames?: string[];
|
||||
* evalContext?: Context;
|
||||
* onError?: (error: unknown) => unknown;
|
||||
* cache?: Object;
|
||||
* }} OnChangeParams
|
||||
*
|
||||
* @typedef {SearchParams & {
|
||||
* fields: Record<string, Field>;
|
||||
* activeFields: Record<string, FieldInfo>;
|
||||
* fieldsToAggregate: string[];
|
||||
* isMonoRecord: boolean;
|
||||
* isRoot: boolean;
|
||||
* resIds?: number[];
|
||||
* mode?: "edit" | "readonly";
|
||||
* loadId?: string;
|
||||
* limit?: number;
|
||||
* offset?: number;
|
||||
* countLimit?: number;
|
||||
* groupsLimit?: number;
|
||||
* groups?: Record<string, unknown>;
|
||||
* currentGroups?: Record<string, unknown>; // FIXME: could be cleaned: Object
|
||||
* openGroupsByDefault?: boolean;
|
||||
* }} RelationalModelConfig
|
||||
*
|
||||
* @typedef {{
|
||||
* config: RelationalModelConfig;
|
||||
* state?: RelationalModelState;
|
||||
* hooks?: Partial<typeof DEFAULT_HOOKS>;
|
||||
* limit?: number;
|
||||
* countLimit?: number;
|
||||
* groupsLimit?: number;
|
||||
* defaultOrderBy?: string[];
|
||||
* maxGroupByDepth?: number;
|
||||
* multiEdit?: boolean;
|
||||
* groupByInfo?: Record<string, unknown>;
|
||||
* activeIdsLimit?: number;
|
||||
* useSendBeaconToSaveUrgently?: boolean;
|
||||
* }} RelationalModelParams
|
||||
*
|
||||
* @typedef {{
|
||||
* config: RelationalModelConfig;
|
||||
* specialDataCaches: Record<string, unknown>;
|
||||
* }} RelationalModelState
|
||||
*/
|
||||
|
||||
const DEFAULT_HOOKS = {
|
||||
/** @type {(config: RelationalModelConfig) => any} */
|
||||
onWillLoadRoot: () => {},
|
||||
/** @type {(root: DataPoint) => any} */
|
||||
onRootLoaded: () => {},
|
||||
/** @type {(record: RelationalRecord) => any} */
|
||||
onWillSaveRecord: () => {},
|
||||
/** @type {(record: RelationalRecord) => any} */
|
||||
onRecordSaved: () => {},
|
||||
/** @type {(record: RelationalRecord, changes: Object) => any} */
|
||||
onWillSaveMulti: () => {},
|
||||
/** @type {(records: RelationalRecord[]) => any} */
|
||||
onSavedMulti: () => {},
|
||||
/** @type {(record: RelationalRecord, fieldName: string) => any} */
|
||||
onWillSetInvalidField: () => {},
|
||||
/** @type {(record: RelationalRecord) => any} */
|
||||
onRecordChanged: () => {},
|
||||
/** @type {(warning: Object) => any} */
|
||||
onWillDisplayOnchangeWarning: () => {},
|
||||
/** @type {(changes: Object, validRecords: RelationalRecord[]) => any} */
|
||||
onAskMultiSaveConfirmation: () => true,
|
||||
};
|
||||
|
||||
rpcBus.addEventListener("RPC:RESPONSE", (ev) => {
|
||||
if (ev.detail.data.params?.method === "unlink") {
|
||||
rpcBus.trigger("CLEAR-CACHES", ["web_read", "web_search_read", "web_read_group"]);
|
||||
}
|
||||
});
|
||||
|
||||
export class RelationalModel extends Model {
|
||||
static services = ["action", "dialog", "notification", "orm"];
|
||||
static Record = RelationalRecord;
|
||||
static Group = Group;
|
||||
static DynamicRecordList = DynamicRecordList;
|
||||
static DynamicGroupList = DynamicGroupList;
|
||||
static StaticList = StaticList;
|
||||
static DEFAULT_LIMIT = 80;
|
||||
static DEFAULT_COUNT_LIMIT = 10000;
|
||||
static DEFAULT_GROUP_LIMIT = 80;
|
||||
static DEFAULT_OPEN_GROUP_LIMIT = 10; // TODO: remove ?
|
||||
static withCache = true;
|
||||
|
||||
/**
|
||||
* @param {RelationalModelParams} params
|
||||
* @param {Services} services
|
||||
*/
|
||||
setup(params, { action, dialog, notification }) {
|
||||
this.action = action;
|
||||
this.dialog = dialog;
|
||||
this.notification = notification;
|
||||
|
||||
this.bus = new EventBus();
|
||||
|
||||
this.keepLast = markRaw(new KeepLast());
|
||||
this.mutex = markRaw(new Mutex());
|
||||
|
||||
/** @type {RelationalModelConfig} */
|
||||
this.config = {
|
||||
isMonoRecord: false,
|
||||
context: {},
|
||||
fieldsToAggregate: Object.keys(params.config.activeFields), // active fields by default
|
||||
...params.config,
|
||||
isRoot: true,
|
||||
};
|
||||
|
||||
this.hooks = Object.assign({}, DEFAULT_HOOKS, params.hooks);
|
||||
|
||||
this.initialLimit = params.limit || this.constructor.DEFAULT_LIMIT;
|
||||
this.initialGroupsLimit = params.groupsLimit;
|
||||
this.initialCountLimit = params.countLimit || this.constructor.DEFAULT_COUNT_LIMIT;
|
||||
this.defaultOrderBy = params.defaultOrderBy;
|
||||
this.maxGroupByDepth = params.maxGroupByDepth;
|
||||
this.groupByInfo = params.groupByInfo || {};
|
||||
this.multiEdit = params.multiEdit;
|
||||
this.activeIdsLimit = params.activeIdsLimit || Number.MAX_SAFE_INTEGER;
|
||||
this.specialDataCaches = markRaw(params.state?.specialDataCaches || {});
|
||||
this.useSendBeaconToSaveUrgently = params.useSendBeaconToSaveUrgently || false;
|
||||
this.withCache = this.constructor.withCache && this.env.config?.cache;
|
||||
this.initialSampleGroups = undefined; // real groups to populate with sample records
|
||||
|
||||
this._urgentSave = false;
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Public
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
exportState() {
|
||||
const config = { ...toRaw(this.config) };
|
||||
delete config.currentGroups;
|
||||
return {
|
||||
config,
|
||||
specialDataCaches: this.specialDataCaches,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {Model["hasData"]}
|
||||
*/
|
||||
hasData() {
|
||||
return this.root.hasData;
|
||||
}
|
||||
|
||||
/**
|
||||
* @override
|
||||
* @type {Model["load"]}
|
||||
*/
|
||||
async load(params = {}) {
|
||||
if (this.orm.isSample && this.initialSampleGroups?.length) {
|
||||
this.orm.setGroups(this.initialSampleGroups);
|
||||
}
|
||||
const config = this._getNextConfig(this.config, params);
|
||||
if (!this.isReady) {
|
||||
// We want the control panel to be displayed directly, without waiting for data to be
|
||||
// loaded, for instance to be able to interact with the search view. For that reason, we
|
||||
// create an empty root, without data, s.t. controllers can make the assumption that the
|
||||
// root is set when they are rendered. The root is replaced later on by the real root,
|
||||
// when data are loaded.
|
||||
this.root = this._createEmptyRoot(config);
|
||||
this.config = config;
|
||||
}
|
||||
this.hooks.onWillLoadRoot(config);
|
||||
const rootLoadDef = new Deferred();
|
||||
const cache = this._getCacheParams(config, rootLoadDef);
|
||||
const data = await this.keepLast.add(this._loadData(config, cache));
|
||||
this.root = this._createRoot(config, data);
|
||||
rootLoadDef.resolve({ root: this.root, loadId: config.loadId });
|
||||
this.config = config;
|
||||
await this.hooks.onRootLoaded(this.root);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Protected
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* If we group by default based on a property, the property might not be loaded in `fields`.
|
||||
*
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {string} propertyFullName
|
||||
*/
|
||||
async _getPropertyDefinition(config, propertyFullName) {
|
||||
// dynamically load the property and add the definition in the fields attribute
|
||||
const result = await this.orm.call(
|
||||
config.resModel,
|
||||
"get_property_definition",
|
||||
[propertyFullName],
|
||||
{ context: config.context }
|
||||
);
|
||||
if (!result) {
|
||||
// the property might have been removed
|
||||
config.groupBy = null;
|
||||
} else {
|
||||
result.propertyName = result.name;
|
||||
result.name = propertyFullName; // "xxxxx" -> "property.xxxxx"
|
||||
// needed for _applyChanges
|
||||
result.relatedPropertyField = { fieldName: propertyFullName.split(".")[0] };
|
||||
result.relation = result.comodel; // match name on field
|
||||
config.fields[propertyFullName] = result;
|
||||
}
|
||||
}
|
||||
|
||||
async _askChanges() {
|
||||
const proms = [];
|
||||
this.bus.trigger("NEED_LOCAL_CHANGES", { proms });
|
||||
await Promise.all([...proms, this.mutex.getUnlockedDef()]);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a root datapoint without data. Supported root types are DynamicRecordList and
|
||||
* DynamicGroupList.
|
||||
*
|
||||
* @param {RelationalModelConfig} config
|
||||
* @returns {DataPoint | undefined}
|
||||
*/
|
||||
_createEmptyRoot(config) {
|
||||
if (!config.isMonoRecord) {
|
||||
if (config.groupBy.length) {
|
||||
return this._createRoot(config, { groups: [], length: 0 });
|
||||
}
|
||||
return this._createRoot(config, { records: [], length: 0 });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Record<string, unknown>} data
|
||||
* @returns {DataPoint}
|
||||
*/
|
||||
_createRoot(config, data) {
|
||||
if (config.isMonoRecord) {
|
||||
return new this.constructor.Record(this, config, data);
|
||||
}
|
||||
if (config.groupBy.length) {
|
||||
return new this.constructor.DynamicGroupList(this, config, data);
|
||||
}
|
||||
return new this.constructor.DynamicRecordList(this, config, data);
|
||||
}
|
||||
|
||||
_getCacheParams(config, rootLoadDef) {
|
||||
if (!this.withCache) {
|
||||
return;
|
||||
}
|
||||
if (
|
||||
!this.isReady || // first load of the model
|
||||
// monorecord, loading a different id, or creating a new record (onchange)
|
||||
(config.isMonoRecord && (this.root.config.resId !== config.resId || !config.resId))
|
||||
) {
|
||||
return {
|
||||
type: "disk",
|
||||
update: "always",
|
||||
callback: async (result, hasChanged) => {
|
||||
if (!hasChanged) {
|
||||
return;
|
||||
}
|
||||
const { root, loadId } = await rootLoadDef;
|
||||
if (root.id !== this.root.id) {
|
||||
// The root id might have changed, either because:
|
||||
// 1) the user already changed the domain and a second load has been done
|
||||
// 2) there was no data, so we reloaded directly with the sample orm
|
||||
// In the first case, there's nothing to do, we can ignore this update. We
|
||||
// have to deal with the second case:
|
||||
if (this.useSampleModel) {
|
||||
// We displayed sample data from the cache, but the rpc returned records
|
||||
// or groups => leave sample mode, forget previous groups and update
|
||||
this.useSampleModel = false;
|
||||
if (this.root.config.groupBy.length) {
|
||||
delete this.root.config.currentGroups;
|
||||
result = await this._postprocessReadGroup(this.root.config, result);
|
||||
}
|
||||
this.root._setData(result);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (loadId !== this.root.config.loadId) {
|
||||
// Avoid updating if another load was already done (e.g. a sort in a list)
|
||||
return;
|
||||
}
|
||||
if (root.config.isMonoRecord) {
|
||||
if (!root.config.resId) {
|
||||
// result is the response of the onchange rpc
|
||||
return root._setData(result.value, { keepChanges: true });
|
||||
}
|
||||
// result is the response of a web_read rpc
|
||||
if (!result.length) {
|
||||
// we read a record that no longer exists
|
||||
throw new FetchRecordError([root.config.resId]);
|
||||
}
|
||||
return root._setData(result[0], { keepChanges: true });
|
||||
}
|
||||
|
||||
// multi record case: either grouped or ungrouped
|
||||
if (root.config.groupBy.length) {
|
||||
// result is the response of a web_read_group rpc
|
||||
// in case there're less groups, we don't want to keep displaying groups
|
||||
// that are no longer there => forget previous groups
|
||||
delete this.root.config.currentGroups;
|
||||
result = await this._postprocessReadGroup(root.config, result);
|
||||
}
|
||||
root._setData(result);
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} currentConfig
|
||||
* @param {Partial<SearchParams>} params
|
||||
* @returns {RelationalModelConfig}
|
||||
*/
|
||||
_getNextConfig(currentConfig, params) {
|
||||
const currentGroupBy = currentConfig.groupBy;
|
||||
const config = Object.assign({}, currentConfig);
|
||||
|
||||
config.context = "context" in params ? params.context : config.context;
|
||||
config.context = { ...config.context };
|
||||
if (currentConfig.isMonoRecord) {
|
||||
config.resId = "resId" in params ? params.resId : config.resId;
|
||||
config.resIds = "resIds" in params ? params.resIds : config.resIds;
|
||||
if (!config.resIds) {
|
||||
config.resIds = config.resId ? [config.resId] : [];
|
||||
}
|
||||
if (!config.resId && config.mode !== "edit") {
|
||||
config.mode = "edit";
|
||||
}
|
||||
} else {
|
||||
config.domain = "domain" in params ? params.domain : config.domain;
|
||||
|
||||
// groupBy
|
||||
config.groupBy = "groupBy" in params ? params.groupBy : config.groupBy;
|
||||
// restrict the number of groupbys if requested
|
||||
if (this.maxGroupByDepth) {
|
||||
config.groupBy = config.groupBy.slice(0, this.maxGroupByDepth);
|
||||
}
|
||||
// apply month granularity if none explicitly given
|
||||
// TODO: accept only explicit granularity
|
||||
config.groupBy = config.groupBy.map((g) => {
|
||||
if (g in config.fields && ["date", "datetime"].includes(config.fields[g].type)) {
|
||||
return `${g}:month`;
|
||||
}
|
||||
return g;
|
||||
});
|
||||
|
||||
// orderBy
|
||||
config.orderBy = "orderBy" in params ? params.orderBy : config.orderBy;
|
||||
// re-apply previous orderBy if not given (or no order)
|
||||
if (!config.orderBy.length) {
|
||||
config.orderBy = currentConfig.orderBy || [];
|
||||
}
|
||||
// apply default order if no order
|
||||
if (this.defaultOrderBy && !config.orderBy.length) {
|
||||
config.orderBy = this.defaultOrderBy;
|
||||
}
|
||||
|
||||
// keep current root config if any, if the groupBy parameter is the same
|
||||
if (!shallowEqual(config.groupBy || [], currentGroupBy || [])) {
|
||||
delete config.groups;
|
||||
}
|
||||
if (!config.groupBy.length) {
|
||||
config.orderBy = config.orderBy.filter((order) => order.name !== "__count");
|
||||
}
|
||||
}
|
||||
if (!config.isMonoRecord && params.domain) {
|
||||
// always reset the offset to 0 when reloading from above with a domain
|
||||
const resetOffset = (config) => {
|
||||
config.offset = 0;
|
||||
for (const group of Object.values(config.groups || {})) {
|
||||
resetOffset(group.list);
|
||||
}
|
||||
};
|
||||
if (this.root) {
|
||||
resetOffset(config);
|
||||
}
|
||||
if (!!config.groupBy.length !== !!currentGroupBy?.length) {
|
||||
// from grouped to ungrouped or the other way around -> force the limit to be reset
|
||||
delete config.limit;
|
||||
}
|
||||
}
|
||||
|
||||
return config;
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Object} [cache]
|
||||
*/
|
||||
async _loadData(config, cache) {
|
||||
config.loadId = getId("load");
|
||||
if (config.isMonoRecord) {
|
||||
const evalContext = getBasicEvalContext(config);
|
||||
if (!config.resId) {
|
||||
return this._loadNewRecord(config, { evalContext, cache });
|
||||
}
|
||||
const records = await this._loadRecords(config, evalContext, cache);
|
||||
return records[0];
|
||||
}
|
||||
if (config.resIds) {
|
||||
// static list
|
||||
const resIds = config.resIds.slice(config.offset, config.offset + config.limit);
|
||||
return this._loadRecords({ ...config, resIds });
|
||||
}
|
||||
if (config.groupBy.length) {
|
||||
return this._loadGroupedList(config, cache);
|
||||
}
|
||||
Object.assign(config, {
|
||||
limit: config.limit || this.initialLimit,
|
||||
countLimit: "countLimit" in config ? config.countLimit : this.initialCountLimit,
|
||||
offset: config.offset || 0,
|
||||
});
|
||||
if (config.countLimit !== Number.MAX_SAFE_INTEGER) {
|
||||
config.countLimit = Math.max(config.countLimit, config.offset + config.limit);
|
||||
}
|
||||
const { records, length } = await this._loadUngroupedList(config, cache);
|
||||
if (config.offset && !records.length) {
|
||||
config.offset = 0;
|
||||
return this._loadData(config, cache);
|
||||
}
|
||||
return { records, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Object} [cache]
|
||||
*/
|
||||
async _loadGroupedList(config, cache) {
|
||||
config.offset = config.offset || 0;
|
||||
config.limit = config.limit || this.initialGroupsLimit;
|
||||
if (!config.limit) {
|
||||
config.limit = config.openGroupsByDefault
|
||||
? this.constructor.DEFAULT_OPEN_GROUP_LIMIT
|
||||
: this.constructor.DEFAULT_GROUP_LIMIT;
|
||||
}
|
||||
config.groups = config.groups || {};
|
||||
|
||||
const response = await this._webReadGroup(config, cache);
|
||||
return this._postprocessReadGroup(config, response);
|
||||
}
|
||||
|
||||
async _postprocessReadGroup(config, { groups, length }) {
|
||||
const commonConfig = {
|
||||
resModel: config.resModel,
|
||||
fields: config.fields,
|
||||
activeFields: config.activeFields,
|
||||
fieldsToAggregate: config.fieldsToAggregate,
|
||||
offset: 0,
|
||||
};
|
||||
const extractGroups = async (currentConfig, groupsData) => {
|
||||
const groupByFieldName = currentConfig.groupBy[0].split(":")[0];
|
||||
if (groupByFieldName.includes(".")) {
|
||||
if (!config.fields[groupByFieldName]) {
|
||||
await this._getPropertyDefinition(config, groupByFieldName);
|
||||
}
|
||||
const propertiesFieldName = groupByFieldName.split(".")[0];
|
||||
if (!config.activeFields[propertiesFieldName]) {
|
||||
// add the properties field so we load its data when reading the records
|
||||
// so when we drag and drop we don't need to fetch the value of the record
|
||||
config.activeFields[propertiesFieldName] = makeActiveField();
|
||||
}
|
||||
}
|
||||
const nextLevelGroupBy = currentConfig.groupBy.slice(1);
|
||||
const groups = [];
|
||||
|
||||
let groupRecordConfig;
|
||||
if (this.groupByInfo[groupByFieldName]) {
|
||||
groupRecordConfig = {
|
||||
...this.groupByInfo[groupByFieldName],
|
||||
resModel: currentConfig.fields[groupByFieldName].relation,
|
||||
context: {},
|
||||
};
|
||||
}
|
||||
|
||||
for (const groupData of groupsData) {
|
||||
const group = extractInfoFromGroupData(
|
||||
groupData,
|
||||
currentConfig.groupBy,
|
||||
currentConfig.fields,
|
||||
currentConfig.domain
|
||||
);
|
||||
if (!currentConfig.groups[group.value]) {
|
||||
currentConfig.groups[group.value] = {
|
||||
...commonConfig,
|
||||
groupByFieldName,
|
||||
extraDomain: false,
|
||||
value: group.value,
|
||||
list: {
|
||||
...commonConfig,
|
||||
groupBy: nextLevelGroupBy,
|
||||
groups: {},
|
||||
limit:
|
||||
nextLevelGroupBy.length === 0
|
||||
? this.initialLimit
|
||||
: this.initialGroupsLimit ||
|
||||
this.constructor.DEFAULT_GROUP_LIMIT,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const groupConfig = currentConfig.groups[group.value];
|
||||
groupConfig.list.orderBy = currentConfig.orderBy;
|
||||
groupConfig.initialDomain = group.domain;
|
||||
if (groupConfig.extraDomain) {
|
||||
groupConfig.list.domain = Domain.and([
|
||||
group.domain,
|
||||
groupConfig.extraDomain,
|
||||
]).toList();
|
||||
} else {
|
||||
groupConfig.list.domain = group.domain;
|
||||
}
|
||||
const context = {
|
||||
...currentConfig.context,
|
||||
[`default_${groupByFieldName}`]: group.serverValue,
|
||||
};
|
||||
groupConfig.list.context = context;
|
||||
groupConfig.context = context;
|
||||
if (nextLevelGroupBy.length) {
|
||||
groupConfig.isFolded = !("__groups" in groupData);
|
||||
if (!groupConfig.isFolded) {
|
||||
const { groups, length } = groupData.__groups;
|
||||
group.groups = await extractGroups(groupConfig.list, groups);
|
||||
group.length = length;
|
||||
} else {
|
||||
group.groups = [];
|
||||
}
|
||||
} else {
|
||||
groupConfig.isFolded = !("__records" in groupData);
|
||||
if (!groupConfig.isFolded) {
|
||||
group.records = groupData.__records;
|
||||
group.length = groupData.__count;
|
||||
} else {
|
||||
group.records = [];
|
||||
}
|
||||
}
|
||||
if (Object.hasOwn(groupData, "__offset")) {
|
||||
groupConfig.list.offset = groupData.__offset;
|
||||
}
|
||||
if (groupRecordConfig) {
|
||||
groupConfig.record = {
|
||||
...groupRecordConfig,
|
||||
resId: group.value ?? false,
|
||||
};
|
||||
}
|
||||
groups.push(group);
|
||||
}
|
||||
|
||||
return groups;
|
||||
};
|
||||
|
||||
groups = await extractGroups(config, groups);
|
||||
|
||||
const params = JSON.stringify([
|
||||
config.domain,
|
||||
config.groupBy,
|
||||
config.offset,
|
||||
config.limit,
|
||||
config.orderBy,
|
||||
]);
|
||||
if (config.currentGroups && config.currentGroups.params === params) {
|
||||
const currentGroups = config.currentGroups.groups;
|
||||
currentGroups.forEach((group, index) => {
|
||||
if (
|
||||
config.groups[group.value] &&
|
||||
!groups.some((g) => JSON.stringify(g.value) === JSON.stringify(group.value))
|
||||
) {
|
||||
const aggregates = Object.assign({}, group.aggregates);
|
||||
for (const key in aggregates) {
|
||||
// the `array_agg_distinct` aggregator's value is an array
|
||||
aggregates[key] = Array.isArray(aggregates[key]) ? [] : 0;
|
||||
}
|
||||
groups.splice(
|
||||
index,
|
||||
0,
|
||||
Object.assign({}, group, { count: 0, length: 0, records: [], aggregates })
|
||||
);
|
||||
}
|
||||
});
|
||||
}
|
||||
config.currentGroups = { params, groups };
|
||||
|
||||
return { groups, length };
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Partial<RelationalModelParams>} [params={}]
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
async _loadNewRecord(config, params = {}) {
|
||||
return this._onchange(config, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Context} evalContext
|
||||
* @param {Object} [cache]
|
||||
*/
|
||||
async _loadRecords(config, evalContext = config.context, cache) {
|
||||
const { resModel, activeFields, fields, context } = config;
|
||||
const resIds = config.resId ? [config.resId] : config.resIds;
|
||||
if (!resIds.length) {
|
||||
return [];
|
||||
}
|
||||
const fieldSpec = getFieldsSpec(activeFields, fields, evalContext);
|
||||
if (Object.keys(fieldSpec).length > 0) {
|
||||
const kwargs = {
|
||||
context: { bin_size: true, ...context },
|
||||
specification: fieldSpec,
|
||||
};
|
||||
const orm = cache ? this.orm.cache(cache) : this.orm;
|
||||
const records = await orm.webRead(resModel, resIds, kwargs);
|
||||
if (!records.length) {
|
||||
throw new FetchRecordError(resIds);
|
||||
}
|
||||
|
||||
return records;
|
||||
} else {
|
||||
return resIds.map((resId) => ({ id: resId }));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load records from the server for an ungrouped list. Return the result
|
||||
* of unity read RPC.
|
||||
*
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Object} [cache]
|
||||
*/
|
||||
async _loadUngroupedList(config, cache) {
|
||||
const orderBy = config.orderBy.filter((o) => o.name !== "__count");
|
||||
const kwargs = {
|
||||
specification: getFieldsSpec(config.activeFields, config.fields, config.context),
|
||||
offset: config.offset,
|
||||
order: orderByToString(orderBy),
|
||||
limit: config.limit,
|
||||
context: { bin_size: true, ...config.context },
|
||||
count_limit:
|
||||
config.countLimit !== Number.MAX_SAFE_INTEGER ? config.countLimit + 1 : undefined,
|
||||
};
|
||||
const orm = cache ? this.orm.cache(cache) : this.orm;
|
||||
return orm.webSearchRead(config.resModel, config.domain, kwargs);
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {OnChangeParams} params
|
||||
* @returns {Promise<Record<string, unknown>>}
|
||||
*/
|
||||
async _onchange(
|
||||
config,
|
||||
{ changes = {}, fieldNames = [], evalContext = config.context, onError, cache }
|
||||
) {
|
||||
const { fields, activeFields, resModel, resId } = config;
|
||||
let context = config.context;
|
||||
if (fieldNames.length === 1) {
|
||||
const fieldContext = config.activeFields[fieldNames[0]].context;
|
||||
context = makeContext([context, fieldContext], evalContext);
|
||||
}
|
||||
const spec = getFieldsSpec(activeFields, fields, evalContext, { withInvisible: true });
|
||||
const args = [resId ? [resId] : [], changes, fieldNames, spec];
|
||||
let response;
|
||||
try {
|
||||
const orm = cache ? this.orm.cache(cache) : this.orm;
|
||||
response = await orm.call(resModel, "onchange", args, { context });
|
||||
} catch (e) {
|
||||
if (onError) {
|
||||
return void onError(e);
|
||||
}
|
||||
throw e;
|
||||
}
|
||||
if (response.warning) {
|
||||
Promise.resolve(this.hooks.onWillDisplayOnchangeWarning(response.warning)).then(() => {
|
||||
const { type, title, message, className, sticky } = response.warning;
|
||||
if (type === "dialog") {
|
||||
this.dialog.add(WarningDialog, { title, message });
|
||||
} else {
|
||||
this.notification.add(message, {
|
||||
className,
|
||||
sticky,
|
||||
title,
|
||||
type: "warning",
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
return response.value;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Partial<RelationalModelConfig>} patch
|
||||
* @param {{
|
||||
* commit?: (data: Record<string, unknown>) => unknown;
|
||||
* reload?: boolean;
|
||||
* }} [options]
|
||||
*/
|
||||
async _updateConfig(config, patch, { reload = true, commit } = {}) {
|
||||
const tmpConfig = { ...config, ...patch };
|
||||
markRaw(tmpConfig.activeFields);
|
||||
markRaw(tmpConfig.fields);
|
||||
|
||||
let data;
|
||||
if (reload) {
|
||||
if (tmpConfig.isRoot) {
|
||||
this.hooks.onWillLoadRoot(tmpConfig);
|
||||
}
|
||||
data = await this._loadData(tmpConfig);
|
||||
}
|
||||
Object.assign(config, tmpConfig);
|
||||
if (data && commit) {
|
||||
commit(data);
|
||||
}
|
||||
if (reload && config.isRoot) {
|
||||
await this.hooks.onRootLoaded(this.root);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @param {RelationalModelConfig} config
|
||||
* @returns {Promise<number>}
|
||||
*/
|
||||
async _updateCount(config) {
|
||||
const count = await this.keepLast.add(
|
||||
this.orm.searchCount(config.resModel, config.domain, { context: config.context })
|
||||
);
|
||||
config.countLimit = Number.MAX_SAFE_INTEGER;
|
||||
return count;
|
||||
}
|
||||
|
||||
/**
|
||||
* When grouped by a many2many field, the same record may be displayed in
|
||||
* several groups. When one of these records is edited, we want all other
|
||||
* occurrences to be updated. The purpose of this function is to find and
|
||||
* update all occurrences of a record that has been reloaded, in a grouped
|
||||
* list view.
|
||||
*
|
||||
* @param {RelationalRecord} reloadedRecord
|
||||
* @param {Record<string, unknown>} serverValues
|
||||
*/
|
||||
_updateSimilarRecords(reloadedRecord, serverValues) {
|
||||
if (this.config.isMonoRecord || !this.config.groupBy.length) {
|
||||
return;
|
||||
}
|
||||
for (const record of this.root.records) {
|
||||
if (record === reloadedRecord) {
|
||||
continue;
|
||||
}
|
||||
if (record.resId === reloadedRecord.resId) {
|
||||
record._applyValues(serverValues);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {RelationalModelConfig} config
|
||||
* @param {Object} cache
|
||||
*/
|
||||
async _webReadGroup(config, cache) {
|
||||
function getGroupInfo(groups) {
|
||||
return Object.values(groups).map((group) => {
|
||||
const field = group.fields[group.groupByFieldName];
|
||||
const value =
|
||||
field.type !== "many2many"
|
||||
? getGroupServerValue(field, group.value)
|
||||
: group.value;
|
||||
if (group.isFolded) {
|
||||
return { value, folded: group.isFolded };
|
||||
} else {
|
||||
return {
|
||||
value,
|
||||
folded: group.isFolded,
|
||||
limit: group.list.limit,
|
||||
offset: group.list.offset,
|
||||
progressbar_domain: group.extraDomain,
|
||||
groups: group.list.groups && getGroupInfo(group.list.groups),
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
const aggregates = getAggregateSpecifications(
|
||||
pick(config.fields, ...config.fieldsToAggregate)
|
||||
);
|
||||
const currentGroupInfos = getGroupInfo(config.groups);
|
||||
const { activeFields, fields } = config;
|
||||
const evalContext = getBasicEvalContext(config);
|
||||
const unfoldReadSpecification = getFieldsSpec(activeFields, fields, evalContext);
|
||||
|
||||
const groupByReadSpecification = {};
|
||||
for (const groupBy of config.groupBy) {
|
||||
const groupInfo = this.groupByInfo[groupBy];
|
||||
if (groupInfo) {
|
||||
const { activeFields, fields } = this.groupByInfo[groupBy];
|
||||
groupByReadSpecification[groupBy] = getFieldsSpec(
|
||||
activeFields,
|
||||
fields,
|
||||
evalContext
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const params = {
|
||||
limit: config.limit !== Number.MAX_SAFE_INTEGER ? config.limit : undefined,
|
||||
offset: config.offset,
|
||||
order: orderByToString(config.orderBy),
|
||||
auto_unfold: config.openGroupsByDefault,
|
||||
opening_info: currentGroupInfos,
|
||||
unfold_read_specification: unfoldReadSpecification,
|
||||
unfold_read_default_limit: this.initialLimit,
|
||||
groupby_read_specification: groupByReadSpecification,
|
||||
context: { read_group_expand: true, ...config.context },
|
||||
};
|
||||
const orm = cache ? this.orm.cache(cache) : this.orm;
|
||||
const result = await orm.webReadGroup(
|
||||
config.resModel,
|
||||
config.domain,
|
||||
config.groupBy,
|
||||
aggregates,
|
||||
params
|
||||
);
|
||||
if (!this.initialSampleGroups) {
|
||||
this.initialSampleGroups = deepCopy(result.groups);
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
1186
frontend/web/static/src/model/relational_model/static_list.js
Normal file
1186
frontend/web/static/src/model/relational_model/static_list.js
Normal file
File diff suppressed because it is too large
Load Diff
905
frontend/web/static/src/model/relational_model/utils.js
Normal file
905
frontend/web/static/src/model/relational_model/utils.js
Normal file
@@ -0,0 +1,905 @@
|
||||
import { markup, onWillDestroy, onWillStart, onWillUpdateProps, useComponent } from "@odoo/owl";
|
||||
import { evalPartialContext, makeContext } from "@web/core/context";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import {
|
||||
deserializeDate,
|
||||
deserializeDateTime,
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
} from "@web/core/l10n/dates";
|
||||
import { x2ManyCommands } from "@web/core/orm_service";
|
||||
import { evaluateExpr } from "@web/core/py_js/py";
|
||||
import { Deferred } from "@web/core/utils/concurrency";
|
||||
import { omit } from "@web/core/utils/objects";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { batched } from "@web/core/utils/timing";
|
||||
import { orderByToString } from "@web/search/utils/order_by";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { user } from "@web/core/user";
|
||||
import { uniqueId } from "@web/core/utils/functions";
|
||||
import { unique } from "@web/core/utils/arrays";
|
||||
|
||||
const granularityToInterval = {
|
||||
hour: { hours: 1 },
|
||||
day: { days: 1 },
|
||||
week: { days: 7 },
|
||||
month: { month: 1 },
|
||||
quarter: { month: 4 },
|
||||
year: { year: 1 },
|
||||
};
|
||||
|
||||
/**
|
||||
* @param {boolean || string} value boolean or string encoding a python expression
|
||||
* @returns {string} string encoding a python expression
|
||||
*/
|
||||
function convertBoolToPyExpr(value) {
|
||||
if (value === true || value === false) {
|
||||
return value ? "True" : "False";
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function makeActiveField({
|
||||
context,
|
||||
invisible,
|
||||
readonly,
|
||||
required,
|
||||
onChange,
|
||||
forceSave,
|
||||
isHandle,
|
||||
} = {}) {
|
||||
return {
|
||||
context: context || "{}",
|
||||
invisible: convertBoolToPyExpr(invisible || false),
|
||||
readonly: convertBoolToPyExpr(readonly || false),
|
||||
required: convertBoolToPyExpr(required || false),
|
||||
onChange: onChange || false,
|
||||
forceSave: forceSave || false,
|
||||
isHandle: isHandle || false,
|
||||
};
|
||||
}
|
||||
|
||||
export const AGGREGATABLE_FIELD_TYPES = ["float", "integer", "monetary"]; // types that can be aggregated in grouped views
|
||||
|
||||
export function addFieldDependencies(activeFields, fields, fieldDependencies = []) {
|
||||
for (const field of fieldDependencies) {
|
||||
if (!("readonly" in field)) {
|
||||
field.readonly = true;
|
||||
}
|
||||
if (field.name in activeFields) {
|
||||
patchActiveFields(activeFields[field.name], makeActiveField(field));
|
||||
} else {
|
||||
activeFields[field.name] = makeActiveField(field);
|
||||
if (["one2many", "many2many"].includes(field.type)) {
|
||||
activeFields[field.name].related = { activeFields: {}, fields: {} };
|
||||
}
|
||||
}
|
||||
if (!fields[field.name]) {
|
||||
const newField = omit(field, [
|
||||
"context",
|
||||
"invisible",
|
||||
"required",
|
||||
"readonly",
|
||||
"onChange",
|
||||
]);
|
||||
fields[field.name] = newField;
|
||||
if (newField.type === "selection" && !Array.isArray(newField.selection)) {
|
||||
newField.selection = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function completeActiveField(activeField, extra) {
|
||||
if (extra.related) {
|
||||
for (const fieldName in extra.related.activeFields) {
|
||||
if (fieldName in activeField.related.activeFields) {
|
||||
completeActiveField(
|
||||
activeField.related.activeFields[fieldName],
|
||||
extra.related.activeFields[fieldName]
|
||||
);
|
||||
} else {
|
||||
activeField.related.activeFields[fieldName] = {
|
||||
...extra.related.activeFields[fieldName],
|
||||
};
|
||||
}
|
||||
}
|
||||
Object.assign(activeField.related.fields, extra.related.fields);
|
||||
}
|
||||
}
|
||||
|
||||
export function completeActiveFields(activeFields, extraActiveFields) {
|
||||
for (const fieldName in extraActiveFields) {
|
||||
const extraActiveField = {
|
||||
...extraActiveFields[fieldName],
|
||||
invisible: "True",
|
||||
};
|
||||
if (fieldName in activeFields) {
|
||||
completeActiveField(activeFields[fieldName], extraActiveField);
|
||||
} else {
|
||||
activeFields[fieldName] = extraActiveField;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function createPropertyActiveField(property) {
|
||||
const { type } = property;
|
||||
|
||||
const activeField = makeActiveField();
|
||||
if (type === "one2many" || type === "many2many") {
|
||||
activeField.related = {
|
||||
fields: {
|
||||
id: { name: "id", type: "integer" },
|
||||
display_name: { name: "display_name", type: "char" },
|
||||
},
|
||||
activeFields: {
|
||||
id: makeActiveField({ readonly: true }),
|
||||
display_name: makeActiveField(),
|
||||
},
|
||||
};
|
||||
}
|
||||
return activeField;
|
||||
}
|
||||
|
||||
export function combineModifiers(mod1, mod2, operator) {
|
||||
if (operator === "AND") {
|
||||
if (!mod1 || mod1 === "False" || !mod2 || mod2 === "False") {
|
||||
return "False";
|
||||
}
|
||||
if (mod1 === "True") {
|
||||
return mod2;
|
||||
}
|
||||
if (mod2 === "True") {
|
||||
return mod1;
|
||||
}
|
||||
return "(" + mod1 + ") and (" + mod2 + ")";
|
||||
} else if (operator === "OR") {
|
||||
if (mod1 === "True" || mod2 === "True") {
|
||||
return "True";
|
||||
}
|
||||
if (!mod1 || mod1 === "False") {
|
||||
return mod2;
|
||||
}
|
||||
if (!mod2 || mod2 === "False") {
|
||||
return mod1;
|
||||
}
|
||||
return "(" + mod1 + ") or (" + mod2 + ")";
|
||||
}
|
||||
throw new Error(
|
||||
`Operator provided to "combineModifiers" must be "AND" or "OR", received ${operator}`
|
||||
);
|
||||
}
|
||||
|
||||
export function patchActiveFields(activeField, patch) {
|
||||
activeField.invisible = combineModifiers(activeField.invisible, patch.invisible, "AND");
|
||||
activeField.readonly = combineModifiers(activeField.readonly, patch.readonly, "AND");
|
||||
activeField.required = combineModifiers(activeField.required, patch.required, "OR");
|
||||
activeField.onChange = activeField.onChange || patch.onChange;
|
||||
activeField.forceSave = activeField.forceSave || patch.forceSave;
|
||||
activeField.isHandle = activeField.isHandle || patch.isHandle;
|
||||
// x2manys
|
||||
if (patch.related) {
|
||||
const related = activeField.related;
|
||||
for (const fieldName in patch.related.activeFields) {
|
||||
if (fieldName in related.activeFields) {
|
||||
patchActiveFields(
|
||||
related.activeFields[fieldName],
|
||||
patch.related.activeFields[fieldName]
|
||||
);
|
||||
} else {
|
||||
related.activeFields[fieldName] = { ...patch.related.activeFields[fieldName] };
|
||||
}
|
||||
}
|
||||
Object.assign(related.fields, patch.related.fields);
|
||||
}
|
||||
if ("limit" in patch) {
|
||||
activeField.limit = patch.limit;
|
||||
}
|
||||
if (patch.defaultOrderBy) {
|
||||
activeField.defaultOrderBy = patch.defaultOrderBy;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractFieldsFromArchInfo({ fieldNodes, widgetNodes }, fields) {
|
||||
const activeFields = {};
|
||||
for (const fieldNode of Object.values(fieldNodes)) {
|
||||
const fieldName = fieldNode.name;
|
||||
const activeField = makeActiveField({
|
||||
context: fieldNode.context,
|
||||
invisible: combineModifiers(fieldNode.invisible, fieldNode.column_invisible, "OR"),
|
||||
readonly: fieldNode.readonly,
|
||||
required: fieldNode.required,
|
||||
onChange: fieldNode.onChange,
|
||||
forceSave: fieldNode.forceSave,
|
||||
isHandle: fieldNode.isHandle,
|
||||
});
|
||||
if (["one2many", "many2many"].includes(fields[fieldName].type)) {
|
||||
activeField.related = {
|
||||
activeFields: {},
|
||||
fields: {},
|
||||
};
|
||||
if (fieldNode.views) {
|
||||
const viewDescr = fieldNode.views[fieldNode.viewMode];
|
||||
if (viewDescr) {
|
||||
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
|
||||
activeField.limit = viewDescr.limit;
|
||||
activeField.defaultOrderBy = viewDescr.defaultOrder;
|
||||
if (fieldNode.views.form) {
|
||||
// we already know the form view (it is inline), so add its fields (in invisible)
|
||||
// s.t. they will be sent in the spec for onchange, and create commands returned
|
||||
// by the onchange could return values for those fields (that would be displayed
|
||||
// later if the user opens the form view)
|
||||
const formArchInfo = extractFieldsFromArchInfo(
|
||||
fieldNode.views.form,
|
||||
fieldNode.views.form.fields
|
||||
);
|
||||
completeActiveFields(
|
||||
activeField.related.activeFields,
|
||||
formArchInfo.activeFields
|
||||
);
|
||||
Object.assign(activeField.related.fields, formArchInfo.fields);
|
||||
}
|
||||
|
||||
if (fieldNode.viewMode !== "default" && fieldNode.views.default) {
|
||||
const defaultArchInfo = extractFieldsFromArchInfo(
|
||||
fieldNode.views.default,
|
||||
fieldNode.views.default.fields
|
||||
);
|
||||
for (const fieldName in defaultArchInfo.activeFields) {
|
||||
if (fieldName in activeField.related.activeFields) {
|
||||
patchActiveFields(
|
||||
activeField.related.activeFields[fieldName],
|
||||
defaultArchInfo.activeFields[fieldName]
|
||||
);
|
||||
} else {
|
||||
activeField.related.activeFields[fieldName] = {
|
||||
...defaultArchInfo.activeFields[fieldName],
|
||||
};
|
||||
}
|
||||
}
|
||||
activeField.related.fields = Object.assign(
|
||||
{},
|
||||
defaultArchInfo.fields,
|
||||
activeField.related.fields
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
if (fieldNode.field?.useSubView) {
|
||||
activeField.required = "False";
|
||||
}
|
||||
}
|
||||
if (
|
||||
["many2one", "many2one_reference"].includes(fields[fieldName].type) &&
|
||||
fieldNode.views
|
||||
) {
|
||||
const viewDescr = fieldNode.views.default;
|
||||
activeField.related = extractFieldsFromArchInfo(viewDescr, viewDescr.fields);
|
||||
}
|
||||
|
||||
if (fieldName in activeFields) {
|
||||
patchActiveFields(activeFields[fieldName], activeField);
|
||||
} else {
|
||||
activeFields[fieldName] = activeField;
|
||||
}
|
||||
|
||||
if (fieldNode.field) {
|
||||
let fieldDependencies = fieldNode.field.fieldDependencies;
|
||||
if (typeof fieldDependencies === "function") {
|
||||
fieldDependencies = fieldDependencies(fieldNode);
|
||||
}
|
||||
addFieldDependencies(activeFields, fields, fieldDependencies);
|
||||
}
|
||||
}
|
||||
|
||||
for (const widgetInfo of Object.values(widgetNodes || {})) {
|
||||
let fieldDependencies = widgetInfo.widget.fieldDependencies;
|
||||
if (typeof fieldDependencies === "function") {
|
||||
fieldDependencies = fieldDependencies(widgetInfo);
|
||||
}
|
||||
addFieldDependencies(activeFields, fields, fieldDependencies);
|
||||
}
|
||||
return { activeFields, fields };
|
||||
}
|
||||
|
||||
export function getFieldContext(
|
||||
record,
|
||||
fieldName,
|
||||
rawContext = record.activeFields[fieldName].context
|
||||
) {
|
||||
const context = {};
|
||||
for (const key in record.context) {
|
||||
if (
|
||||
!key.startsWith("default_") &&
|
||||
!key.startsWith("search_default_") &&
|
||||
!key.endsWith("_view_ref")
|
||||
) {
|
||||
context[key] = record.context[key];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...context,
|
||||
...record.fields[fieldName].context,
|
||||
...makeContext([rawContext], record.evalContext),
|
||||
};
|
||||
}
|
||||
|
||||
export function getFieldDomain(record, fieldName, domain) {
|
||||
if (typeof domain === "function") {
|
||||
domain = domain();
|
||||
domain = typeof domain === "function" ? domain() : domain;
|
||||
}
|
||||
if (domain) {
|
||||
return domain;
|
||||
}
|
||||
// Fallback to the domain defined in the field definition in python
|
||||
domain = record.fields[fieldName].domain;
|
||||
return typeof domain === "string"
|
||||
? new Domain(evaluateExpr(domain, record.evalContext)).toList()
|
||||
: domain || [];
|
||||
}
|
||||
|
||||
export function getBasicEvalContext(config) {
|
||||
const { uid, allowed_company_ids } = config.context;
|
||||
return {
|
||||
context: config.context,
|
||||
uid,
|
||||
allowed_company_ids,
|
||||
current_company_id: user.activeCompany?.id,
|
||||
};
|
||||
}
|
||||
|
||||
function getFieldContextForSpec(activeFields, fields, fieldName, evalContext) {
|
||||
let context = activeFields[fieldName].context;
|
||||
if (!context || context === "{}") {
|
||||
context = fields[fieldName].context || {};
|
||||
} else {
|
||||
context = evalPartialContext(context, evalContext);
|
||||
}
|
||||
if (Object.keys(context).length > 0) {
|
||||
return context;
|
||||
}
|
||||
}
|
||||
|
||||
export function getFieldsSpec(activeFields, fields, evalContext, { orderBys, withInvisible } = {}) {
|
||||
const fieldsSpec = {};
|
||||
const properties = [];
|
||||
for (const fieldName in activeFields) {
|
||||
if (fields[fieldName].relatedPropertyField) {
|
||||
continue;
|
||||
}
|
||||
const { related, limit, defaultOrderBy, invisible } = activeFields[fieldName];
|
||||
const isAlwaysInvisible = invisible === "True" || invisible === "1";
|
||||
fieldsSpec[fieldName] = {};
|
||||
switch (fields[fieldName].type) {
|
||||
case "one2many":
|
||||
case "many2many": {
|
||||
if (related && (withInvisible || !isAlwaysInvisible)) {
|
||||
fieldsSpec[fieldName].fields = getFieldsSpec(
|
||||
related.activeFields,
|
||||
related.fields,
|
||||
evalContext,
|
||||
{ withInvisible }
|
||||
);
|
||||
fieldsSpec[fieldName].context = getFieldContextForSpec(
|
||||
activeFields,
|
||||
fields,
|
||||
fieldName,
|
||||
evalContext
|
||||
);
|
||||
fieldsSpec[fieldName].limit = limit;
|
||||
const orderBy = orderBys?.[fieldName] || defaultOrderBy || [];
|
||||
if (orderBy.length) {
|
||||
fieldsSpec[fieldName].order = orderByToString(orderBy);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "many2one":
|
||||
case "reference": {
|
||||
fieldsSpec[fieldName].fields = {};
|
||||
if (!isAlwaysInvisible) {
|
||||
if (related) {
|
||||
fieldsSpec[fieldName].fields = getFieldsSpec(
|
||||
related.activeFields,
|
||||
related.fields,
|
||||
evalContext
|
||||
);
|
||||
}
|
||||
fieldsSpec[fieldName].fields.display_name = {};
|
||||
fieldsSpec[fieldName].context = getFieldContextForSpec(
|
||||
activeFields,
|
||||
fields,
|
||||
fieldName,
|
||||
evalContext
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "many2one_reference": {
|
||||
if (related && !isAlwaysInvisible) {
|
||||
fieldsSpec[fieldName].fields = getFieldsSpec(
|
||||
related.activeFields,
|
||||
related.fields,
|
||||
evalContext
|
||||
);
|
||||
fieldsSpec[fieldName].context = getFieldContextForSpec(
|
||||
activeFields,
|
||||
fields,
|
||||
fieldName,
|
||||
evalContext
|
||||
);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case "properties": {
|
||||
properties.push(fieldName);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const fieldName of properties) {
|
||||
const fieldSpec = fieldsSpec[fields[fieldName].definition_record];
|
||||
if (fieldSpec) {
|
||||
if (!fieldSpec.fields) {
|
||||
fieldSpec.fields = {};
|
||||
}
|
||||
fieldSpec.fields.display_name = {};
|
||||
}
|
||||
}
|
||||
return fieldsSpec;
|
||||
}
|
||||
|
||||
let nextId = 0;
|
||||
/**
|
||||
* @param {string} [prefix]
|
||||
* @returns {string}
|
||||
*/
|
||||
export function getId(prefix = "") {
|
||||
return `${prefix}_${++nextId}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* @protected
|
||||
* @param {Field | false} field
|
||||
* @param {any} value
|
||||
* @returns {any}
|
||||
*/
|
||||
export function parseServerValue(field, value) {
|
||||
switch (field.type) {
|
||||
case "char":
|
||||
case "text": {
|
||||
return value || "";
|
||||
}
|
||||
case "html": {
|
||||
return markup(value || "");
|
||||
}
|
||||
case "date": {
|
||||
return value ? deserializeDate(value) : false;
|
||||
}
|
||||
case "datetime": {
|
||||
return value ? deserializeDateTime(value) : false;
|
||||
}
|
||||
case "selection": {
|
||||
if (value === false) {
|
||||
// process selection: convert false to 0, if 0 is a valid key
|
||||
const hasKey0 = field.selection.find((option) => option[0] === 0);
|
||||
return hasKey0 ? 0 : value;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "reference": {
|
||||
if (value === false) {
|
||||
return false;
|
||||
}
|
||||
return {
|
||||
resId: value.id.id,
|
||||
resModel: value.id.model,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
case "many2one_reference": {
|
||||
if (value === 0) {
|
||||
// unset many2one_reference fields' value is 0
|
||||
return false;
|
||||
}
|
||||
if (typeof value === "number") {
|
||||
// many2one_reference fetched without "fields" key in spec -> only returns the id
|
||||
return { resId: value };
|
||||
}
|
||||
return {
|
||||
resId: value.id,
|
||||
displayName: value.display_name,
|
||||
};
|
||||
}
|
||||
case "many2one": {
|
||||
if (Array.isArray(value)) {
|
||||
// Used for web_read_group, where the value is an array of [id, display_name]
|
||||
value = { id: value[0], display_name: value[1] };
|
||||
}
|
||||
return value;
|
||||
}
|
||||
case "properties": {
|
||||
return value
|
||||
? value.map((property) => {
|
||||
if (property.value !== undefined) {
|
||||
property.value = parseServerValue(property, property.value ?? false);
|
||||
}
|
||||
if (property.default !== undefined) {
|
||||
property.default = parseServerValue(property, property.default ?? false);
|
||||
}
|
||||
return property;
|
||||
})
|
||||
: [];
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function getAggregateSpecifications(fields) {
|
||||
const aggregatableFields = Object.values(fields)
|
||||
.filter((field) => field.aggregator && AGGREGATABLE_FIELD_TYPES.includes(field.type))
|
||||
.map((field) => `${field.name}:${field.aggregator}`);
|
||||
const currencyFields = unique(
|
||||
Object.values(fields)
|
||||
.filter((field) => field.aggregator && field.currency_field)
|
||||
.map((field) => [
|
||||
`${field.currency_field}:array_agg_distinct`,
|
||||
`${field.name}:sum_currency`,
|
||||
])
|
||||
.flat()
|
||||
);
|
||||
return aggregatableFields.concat(currencyFields);
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract useful information from a group data returned by a call to webReadGroup.
|
||||
*
|
||||
* @param {Object} groupData
|
||||
* @param {string[]} groupBy
|
||||
* @param {Object} fields
|
||||
* @returns {Object}
|
||||
*/
|
||||
export function extractInfoFromGroupData(groupData, groupBy, fields, domain) {
|
||||
const info = {};
|
||||
const groupByField = fields[groupBy[0].split(":")[0]];
|
||||
info.count = groupData.__count;
|
||||
info.length = info.count; // TODO: remove but still used in DynamicRecordList._updateCount
|
||||
info.domain = Domain.and([domain, groupData.__extra_domain]).toList();
|
||||
info.rawValue = groupData[groupBy[0]];
|
||||
info.value = getValueFromGroupData(groupByField, info.rawValue);
|
||||
if (["date", "datetime"].includes(groupByField.type) && info.value) {
|
||||
const granularity = groupBy[0].split(":")[1];
|
||||
info.range = {
|
||||
from: info.value,
|
||||
to: info.value.plus(granularityToInterval[granularity]),
|
||||
};
|
||||
}
|
||||
info.displayName = getDisplayNameFromGroupData(groupByField, info.rawValue);
|
||||
info.serverValue = getGroupServerValue(groupByField, info.value);
|
||||
info.aggregates = getAggregatesFromGroupData(groupData, fields);
|
||||
info.values = groupData.__values; // Extra data of the relational groupby field record
|
||||
return info;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {Object} groupData
|
||||
* @returns {Object}
|
||||
*/
|
||||
function getAggregatesFromGroupData(groupData, fields) {
|
||||
const aggregates = {};
|
||||
for (const keyAggregate of getAggregateSpecifications(fields)) {
|
||||
if (keyAggregate in groupData) {
|
||||
const [fieldName, aggregate] = keyAggregate.split(":");
|
||||
if (aggregate === "sum_currency") {
|
||||
const currencies =
|
||||
groupData[fields[fieldName].currency_field + ":array_agg_distinct"];
|
||||
if (currencies.length === 1) {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
aggregates[fieldName] = groupData[keyAggregate];
|
||||
}
|
||||
}
|
||||
return aggregates;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./datapoint").Field} field
|
||||
* @param {any} rawValue
|
||||
* @returns {string}
|
||||
*/
|
||||
function getDisplayNameFromGroupData(field, rawValue) {
|
||||
switch (field.type) {
|
||||
case "selection": {
|
||||
return Object.fromEntries(field.selection)[rawValue];
|
||||
}
|
||||
case "boolean": {
|
||||
return rawValue ? _t("Yes") : _t("No");
|
||||
}
|
||||
case "integer": {
|
||||
return rawValue ? String(rawValue) : "0";
|
||||
}
|
||||
case "many2one":
|
||||
case "many2many":
|
||||
case "date":
|
||||
case "datetime":
|
||||
case "tags": {
|
||||
return (rawValue && rawValue[1]) || field.falsy_value_label || _t("None");
|
||||
}
|
||||
}
|
||||
return rawValue ? String(rawValue) : field.falsy_value_label || _t("None");
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./datapoint").Field} field
|
||||
* @param {any} value
|
||||
* @returns {any}
|
||||
*/
|
||||
export function getGroupServerValue(field, value) {
|
||||
switch (field.type) {
|
||||
case "many2many": {
|
||||
return value ? [value] : false;
|
||||
}
|
||||
case "datetime": {
|
||||
return value ? serializeDateTime(value) : false;
|
||||
}
|
||||
case "date": {
|
||||
return value ? serializeDate(value) : false;
|
||||
}
|
||||
default: {
|
||||
return value || false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {import("./datapoint").Field} field
|
||||
* @param {any} rawValue
|
||||
* @param {object} [range]
|
||||
* @returns {any}
|
||||
*/
|
||||
function getValueFromGroupData(field, rawValue) {
|
||||
if (["date", "datetime"].includes(field.type)) {
|
||||
if (!rawValue) {
|
||||
return false;
|
||||
}
|
||||
return parseServerValue(field, rawValue[0]);
|
||||
}
|
||||
const value = parseServerValue(field, rawValue);
|
||||
if (field.type === "many2one") {
|
||||
return value && value.id;
|
||||
}
|
||||
if (field.type === "many2many") {
|
||||
return value ? value[0] : false;
|
||||
}
|
||||
if (field.type === "tags") {
|
||||
return value ? value[0] : false;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Onchanges sometimes return update commands for records we don't know (e.g. if
|
||||
* they are on a page we haven't loaded yet). We may actually never load them.
|
||||
* When this happens, we must still be able to send back those commands to the
|
||||
* server when saving. However, we can't send the commands exactly as we received
|
||||
* them, since the values they contain have been "unity read". The purpose of this
|
||||
* function is to transform field values from the unity format to the format
|
||||
* expected by the server for a write.
|
||||
* For instance, for a many2one: { id: 3, display_name: "Marc" } => 3.
|
||||
*/
|
||||
export function fromUnityToServerValues(
|
||||
values,
|
||||
fields,
|
||||
activeFields,
|
||||
{ withReadonly, context } = {}
|
||||
) {
|
||||
const { CREATE, UPDATE } = x2ManyCommands;
|
||||
const serverValues = {};
|
||||
for (const fieldName in values) {
|
||||
let value = values[fieldName];
|
||||
const field = fields[fieldName];
|
||||
const activeField = activeFields[fieldName];
|
||||
if (!withReadonly) {
|
||||
if (field.readonly) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (evaluateExpr(activeField.readonly, context)) {
|
||||
continue;
|
||||
}
|
||||
} catch {
|
||||
// if the readonly expression depends on other fields, we can't evaluate it as we
|
||||
// didn't read the record, so we simply ignore it
|
||||
}
|
||||
}
|
||||
switch (fields[fieldName].type) {
|
||||
case "one2many":
|
||||
case "many2many":
|
||||
value = value.map((c) => {
|
||||
if (c[0] === CREATE || c[0] === UPDATE) {
|
||||
const _fields = activeField.related.fields;
|
||||
const _activeFields = activeField.related.activeFields;
|
||||
return [
|
||||
c[0],
|
||||
c[1],
|
||||
fromUnityToServerValues(c[2], _fields, _activeFields, { withReadonly }),
|
||||
];
|
||||
}
|
||||
return [c[0], c[1]];
|
||||
});
|
||||
break;
|
||||
case "many2one":
|
||||
value = value ? value.id : false;
|
||||
break;
|
||||
// case "reference":
|
||||
// // TODO
|
||||
// break;
|
||||
}
|
||||
serverValues[fieldName] = value;
|
||||
}
|
||||
return serverValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} field
|
||||
* @returns {boolean}
|
||||
*/
|
||||
export function isRelational(field) {
|
||||
return field && ["one2many", "many2many", "many2one"].includes(field.type);
|
||||
}
|
||||
|
||||
/**
|
||||
* This hook should only be used in a component field because it
|
||||
* depends on the record props.
|
||||
* The callback will be executed once during setup and each time
|
||||
* a record value read in the callback changes.
|
||||
* @param {(record) => void} callback
|
||||
*/
|
||||
export function useRecordObserver(callback) {
|
||||
const component = useComponent();
|
||||
let currentId;
|
||||
const observeRecord = (props) => {
|
||||
currentId = uniqueId();
|
||||
if (!props.record) {
|
||||
return;
|
||||
}
|
||||
const def = new Deferred();
|
||||
const effectId = currentId;
|
||||
let firstCall = true;
|
||||
effect(
|
||||
(record) => {
|
||||
if (firstCall) {
|
||||
firstCall = false;
|
||||
return Promise.resolve(callback(record, props))
|
||||
.then(def.resolve)
|
||||
.catch(def.reject);
|
||||
} else {
|
||||
return batched(
|
||||
(record) => {
|
||||
if (effectId !== currentId) {
|
||||
// effect doesn't clean up when the component is unmounted.
|
||||
// We must do it manually.
|
||||
return;
|
||||
}
|
||||
return Promise.resolve(callback(record, props))
|
||||
.then(def.resolve)
|
||||
.catch(def.reject);
|
||||
},
|
||||
() => new Promise((resolve) => window.requestAnimationFrame(resolve))
|
||||
)(record);
|
||||
}
|
||||
},
|
||||
[props.record]
|
||||
);
|
||||
return def;
|
||||
};
|
||||
onWillDestroy(() => {
|
||||
currentId = uniqueId();
|
||||
});
|
||||
onWillStart(() => observeRecord(component.props));
|
||||
onWillUpdateProps((nextProps) => {
|
||||
if (nextProps.record !== component.props.record) {
|
||||
return observeRecord(nextProps);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Resequence records based on provided parameters.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {Array} params.records - The list of records to resequence.
|
||||
* @param {string} params.resModel - The model to be used for resequencing.
|
||||
* @param {Object} params.orm
|
||||
* @param {string} params.fieldName - The field used to handle the sequence.
|
||||
* @param {number} params.movedId - The id of the record being moved.
|
||||
* @param {number} [params.targetId] - The id of the target position, the record will be resequenced
|
||||
* after the target. If undefined, the record will be resequenced
|
||||
* as the first record.
|
||||
* @param {Boolean} [params.asc] - Resequence in ascending or descending order
|
||||
* @param {Function} [params.getSequence] - Function to get the sequence of a record.
|
||||
* @param {Function} [params.getResId] - Function to get the resID of the record.
|
||||
* @param {Object} [params.context]
|
||||
* @returns {Promise<any>} - The list of the resequenced fieldName
|
||||
*/
|
||||
export async function resequence({
|
||||
records,
|
||||
resModel,
|
||||
orm,
|
||||
fieldName,
|
||||
movedId,
|
||||
targetId,
|
||||
asc = true,
|
||||
getSequence = (record) => record[fieldName],
|
||||
getResId = (record) => record.id,
|
||||
context,
|
||||
}) {
|
||||
// Find indices
|
||||
const fromIndex = records.findIndex((d) => d.id === movedId);
|
||||
let toIndex = 0;
|
||||
if (targetId !== null) {
|
||||
const targetIndex = records.findIndex((d) => d.id === targetId);
|
||||
toIndex = fromIndex > targetIndex ? targetIndex + 1 : targetIndex;
|
||||
}
|
||||
|
||||
// Determine which records/groups need to be modified
|
||||
const firstIndex = Math.min(fromIndex, toIndex);
|
||||
const lastIndex = Math.max(fromIndex, toIndex) + 1;
|
||||
let reorderAll = records.some((record) => getSequence(record) === undefined);
|
||||
if (!reorderAll) {
|
||||
let lastSequence = (asc ? -1 : 1) * Infinity;
|
||||
for (let index = 0; index < records.length; index++) {
|
||||
const sequence = getSequence(records[index]);
|
||||
if ((asc && lastSequence >= sequence) || (!asc && lastSequence <= sequence)) {
|
||||
reorderAll = true;
|
||||
break;
|
||||
}
|
||||
lastSequence = sequence;
|
||||
}
|
||||
}
|
||||
|
||||
// Save the original list in case of error
|
||||
const originalOrder = [...records];
|
||||
// Perform the resequence in the list of records/groups
|
||||
const record = records[fromIndex];
|
||||
if (fromIndex !== toIndex) {
|
||||
records.splice(fromIndex, 1);
|
||||
records.splice(toIndex, 0, record);
|
||||
}
|
||||
|
||||
// Creates the list of records/groups to modify
|
||||
let toReorder = records;
|
||||
if (!reorderAll) {
|
||||
toReorder = toReorder.slice(firstIndex, lastIndex).filter((r) => r.id !== movedId);
|
||||
if (fromIndex < toIndex) {
|
||||
toReorder.push(record);
|
||||
} else {
|
||||
toReorder.unshift(record);
|
||||
}
|
||||
}
|
||||
if (!asc) {
|
||||
toReorder.reverse();
|
||||
}
|
||||
|
||||
const resIds = toReorder.map((d) => getResId(d)).filter((id) => id && !isNaN(id));
|
||||
const sequences = toReorder.map(getSequence);
|
||||
const offset = Math.min(...sequences) || 0;
|
||||
|
||||
// Try to write new sequences on the affected records/groups
|
||||
try {
|
||||
return await orm.webResequence(resModel, resIds, {
|
||||
field_name: fieldName,
|
||||
offset,
|
||||
context,
|
||||
specification: { [fieldName]: {} },
|
||||
});
|
||||
} catch (error) {
|
||||
// If the server fails to resequence, rollback the original list
|
||||
records.splice(0, records.length, ...originalOrder);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
861
frontend/web/static/src/model/sample_server.js
Normal file
861
frontend/web/static/src/model/sample_server.js
Normal file
@@ -0,0 +1,861 @@
|
||||
import {
|
||||
deserializeDate,
|
||||
deserializeDateTime,
|
||||
serializeDate,
|
||||
serializeDateTime,
|
||||
} from "@web/core/l10n/dates";
|
||||
import { ORM } from "@web/core/orm_service";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { cartesian, sortBy as arraySortBy, unique } from "@web/core/utils/arrays";
|
||||
import { parseServerValue } from "./relational_model/utils";
|
||||
|
||||
class UnimplementedRouteError extends Error {}
|
||||
|
||||
/**
|
||||
* Helper function returning the value from a list of sample strings
|
||||
* corresponding to the given ID.
|
||||
* @param {number} id
|
||||
* @param {string[]} sampleTexts
|
||||
* @returns {string}
|
||||
*/
|
||||
function getSampleFromId(id, sampleTexts) {
|
||||
return sampleTexts[(id - 1) % sampleTexts.length];
|
||||
}
|
||||
|
||||
function serializeGroupDateValue(range, field) {
|
||||
if (!range) {
|
||||
return false;
|
||||
}
|
||||
const dateValue = parseServerValue(field, range[0]);
|
||||
return field.type === "date" ? serializeDate(dateValue) : serializeDateTime(dateValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function returning a regular expression specifically matching
|
||||
* a given 'term' in a fieldName. For example `fieldNameRegex('abc')`:
|
||||
* will match:
|
||||
* - "abc"
|
||||
* - "field_abc__def"
|
||||
* will not match:
|
||||
* - "aabc"
|
||||
* - "abcd_ef"
|
||||
* @param {...string} term
|
||||
* @returns {RegExp}
|
||||
*/
|
||||
function fieldNameRegex(...terms) {
|
||||
return new RegExp(`\\b((\\w+)?_)?(${terms.join("|")})(_(\\w+)?)?\\b`);
|
||||
}
|
||||
|
||||
const MEASURE_SPEC_REGEX = /(?<fieldName>\w+):(?<func>\w+)/;
|
||||
const DESCRIPTION_REGEX = fieldNameRegex("description", "label", "title", "subject", "message");
|
||||
const EMAIL_REGEX = fieldNameRegex("email");
|
||||
const PHONE_REGEX = fieldNameRegex("phone");
|
||||
const URL_REGEX = fieldNameRegex("url");
|
||||
const MAX_NUMBER_OPENED_GROUPS = 10;
|
||||
|
||||
/**
|
||||
* Sample server class
|
||||
*
|
||||
* Represents a static instance of the server used when a RPC call sends
|
||||
* empty values/groups while the attribute 'sample' is set to true on the
|
||||
* view.
|
||||
*
|
||||
* This server will generate fake data and send them in the adequate format
|
||||
* according to the route/method used in the RPC.
|
||||
*/
|
||||
export class SampleServer {
|
||||
/**
|
||||
* @param {string} modelName
|
||||
* @param {Object} fields
|
||||
*/
|
||||
constructor(modelName, fields) {
|
||||
this.mainModel = modelName;
|
||||
this.data = {};
|
||||
this.data[modelName] = {
|
||||
fields,
|
||||
records: [],
|
||||
};
|
||||
// Generate relational fields' co models
|
||||
for (const fieldName in fields) {
|
||||
const field = fields[fieldName];
|
||||
if (["many2one", "one2many", "many2many"].includes(field.type)) {
|
||||
this.data[field.relation] = this.data[field.relation] || {
|
||||
fields: {
|
||||
display_name: { type: "char" },
|
||||
id: { type: "integer" },
|
||||
color: { type: "integer" },
|
||||
},
|
||||
records: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
// On some models, empty grouped Kanban or List view still contain
|
||||
// real (empty) groups. In this case, we re-use the result of the
|
||||
// web_read_group rpc to tweak sample data s.t. those real groups
|
||||
// contain sample records.
|
||||
this.existingGroups = null;
|
||||
// Sample records generation is only done if necessary, so we delay
|
||||
// it to the first "mockRPC" call. These flags allow us to know if
|
||||
// the records have been generated or not.
|
||||
this.populated = false;
|
||||
this.existingGroupsPopulated = false;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Public
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* This is the main entry point of the SampleServer. Mocks a request to
|
||||
* the server with sample data.
|
||||
* @param {Object} params
|
||||
* @returns {any} the result obtained with the sample data
|
||||
* @throws {Error} If called on a route/method we do not handle
|
||||
*/
|
||||
mockRpc(params) {
|
||||
if (!(params.model in this.data)) {
|
||||
throw new Error(`SampleServer: unknown model ${params.model}`);
|
||||
}
|
||||
this._populateModels();
|
||||
switch (params.method || params.route) {
|
||||
case "web_search_read":
|
||||
return this._mockWebSearchReadUnity(params);
|
||||
case "web_read_group":
|
||||
return this._mockWebReadGroup(params);
|
||||
case "formatted_read_group":
|
||||
return this._mockFormattedReadGroup(params);
|
||||
case "formatted_read_grouping_sets":
|
||||
return this._mockFormattedReadGroupingSets(params);
|
||||
case "read_progress_bar":
|
||||
return this._mockReadProgressBar(params);
|
||||
case "read":
|
||||
return this._mockRead(params);
|
||||
}
|
||||
// this rpc can't be mocked by the SampleServer itself, so check if there is an handler
|
||||
// in the registry: either specific for this model (with key 'model/method'), or
|
||||
// global (with key 'method')
|
||||
const method = params.method || params.route;
|
||||
// This allows to register mock version of methods or routes,
|
||||
// for all models:
|
||||
// registry.category("sample_server").add('some_route', () => "abcd");
|
||||
// for a specific model (e.g. 'res.partner'):
|
||||
// registry.category("sample_server").add('res.partner/some_method', () => 23);
|
||||
const mockFunction =
|
||||
registry.category("sample_server").get(`${params.model}/${method}`, null) ||
|
||||
registry.category("sample_server").get(method, null);
|
||||
if (mockFunction) {
|
||||
return mockFunction.call(this, params);
|
||||
}
|
||||
console.log(`SampleServer: unimplemented route "${params.method || params.route}"`);
|
||||
throw new SampleServer.UnimplementedRouteError();
|
||||
}
|
||||
|
||||
setExistingGroups(groups) {
|
||||
this.existingGroups = groups;
|
||||
}
|
||||
|
||||
//---------------------------------------------------------------------
|
||||
// Private
|
||||
//---------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* @param {Object[]} measures, each measure has the form { fieldName, type }
|
||||
* @param {Object[]} records
|
||||
* @returns {Object}
|
||||
*/
|
||||
_aggregateFields(measures, records) {
|
||||
const group = {};
|
||||
for (const { fieldName, func, name } of measures) {
|
||||
if (["sum", "sum_currency", "avg", "max", "min"].includes(func)) {
|
||||
if (!records.length) {
|
||||
group[name] = false;
|
||||
} else {
|
||||
group[name] = 0;
|
||||
for (const record of records) {
|
||||
group[name] += record[fieldName];
|
||||
}
|
||||
}
|
||||
group[name] = this._sanitizeNumber(group[name]);
|
||||
} else if (func === "array_agg") {
|
||||
group[name] = records.map((r) => r[fieldName]);
|
||||
} else if (func === "__count") {
|
||||
group[name] = records.length;
|
||||
} else if (func === "count_distinct") {
|
||||
group[name] = unique(records.map((r) => r[fieldName])).filter(Boolean).length;
|
||||
} else if (func === "bool_or") {
|
||||
group[name] = records.some((r) => Boolean(r[fieldName]));
|
||||
} else if (func === "bool_and") {
|
||||
group[name] = records.every((r) => Boolean(r[fieldName]));
|
||||
} else if (func === "array_agg_distinct") {
|
||||
group[name] = unique(records.map((r) => r[fieldName]));
|
||||
} else {
|
||||
throw new Error(`Aggregate "${func}" not implemented in SampleServer`);
|
||||
}
|
||||
}
|
||||
return group;
|
||||
}
|
||||
|
||||
/**
|
||||
* @param {any} value
|
||||
* @param {Object} options
|
||||
* @param {string} [options.interval]
|
||||
* @param {string} [options.relation]
|
||||
* @param {string} [options.type]
|
||||
* @returns {any}
|
||||
*/
|
||||
_formatValue(value, options) {
|
||||
if (!value) {
|
||||
return false;
|
||||
}
|
||||
const { type, interval, relation } = options;
|
||||
if (["date", "datetime"].includes(type) && value) {
|
||||
const deserialize = type === "date" ? deserializeDate : deserializeDateTime;
|
||||
const serialize = type === "date" ? serializeDate : serializeDateTime;
|
||||
const from = deserialize(value).startOf(interval);
|
||||
const fmt = SampleServer.FORMATS[interval];
|
||||
return [serialize(from), from.toFormat(fmt)];
|
||||
} else if (["many2one", "many2many"].includes(type)) {
|
||||
const rec = this.data[relation].records.find(({ id }) => id === value);
|
||||
return [value, rec.display_name];
|
||||
} else {
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates field values based on heuristics according to field types
|
||||
* and names.
|
||||
*
|
||||
* @private
|
||||
* @param {string} modelName
|
||||
* @param {string} fieldName
|
||||
* @param {number} id the record id
|
||||
* @returns {any} the field value
|
||||
*/
|
||||
_generateFieldValue(modelName, fieldName, id) {
|
||||
const field = this.data[modelName].fields[fieldName];
|
||||
switch (field.type) {
|
||||
case "boolean":
|
||||
return fieldName === "active" ? true : this._getRandomBool();
|
||||
case "char":
|
||||
case "text":
|
||||
if (["display_name", "name"].includes(fieldName)) {
|
||||
if (SampleServer.PEOPLE_MODELS.includes(modelName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_PEOPLE);
|
||||
} else if (modelName === "res.country") {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_COUNTRIES);
|
||||
}
|
||||
}
|
||||
if (fieldName === "display_name") {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (["name", "reference"].includes(fieldName)) {
|
||||
return `REF${String(id).padStart(4, "0")}`;
|
||||
} else if (DESCRIPTION_REGEX.test(fieldName)) {
|
||||
return getSampleFromId(id, SampleServer.SAMPLE_TEXTS);
|
||||
} else if (EMAIL_REGEX.test(fieldName)) {
|
||||
const emailName = getSampleFromId(id, SampleServer.SAMPLE_PEOPLE)
|
||||
.replace(/ /, ".")
|
||||
.toLowerCase();
|
||||
return `${emailName}@sample.demo`;
|
||||
} else if (PHONE_REGEX.test(fieldName)) {
|
||||
return `+1 555 754 ${String(id).padStart(4, "0")}`;
|
||||
} else if (URL_REGEX.test(fieldName)) {
|
||||
return `http://sample${id}.com`;
|
||||
}
|
||||
return false;
|
||||
case "date":
|
||||
case "datetime": {
|
||||
const datetime = this._getRandomDate();
|
||||
return field.type === "date"
|
||||
? serializeDate(datetime)
|
||||
: serializeDateTime(datetime);
|
||||
}
|
||||
case "float":
|
||||
return this._getRandomFloat(SampleServer.MAX_FLOAT);
|
||||
case "integer": {
|
||||
let max = SampleServer.MAX_INTEGER;
|
||||
if (fieldName.includes("color")) {
|
||||
max = this._getRandomBool() ? SampleServer.MAX_COLOR_INT : 0;
|
||||
}
|
||||
return this._getRandomInt(max);
|
||||
}
|
||||
case "monetary":
|
||||
return this._getRandomInt(SampleServer.MAX_MONETARY);
|
||||
case "many2one":
|
||||
if (field.relation === "res.currency") {
|
||||
/** @todo return session.company_currency_id */
|
||||
return 1;
|
||||
}
|
||||
if (field.relation === "ir.attachment") {
|
||||
return false;
|
||||
}
|
||||
return this._getRandomSubRecordId();
|
||||
case "one2many":
|
||||
case "many2many": {
|
||||
const ids = [this._getRandomSubRecordId(), this._getRandomSubRecordId()];
|
||||
return [...new Set(ids)];
|
||||
}
|
||||
case "selection": {
|
||||
return this._getRandomSelectionValue(modelName, field);
|
||||
}
|
||||
default:
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {any[]} array
|
||||
* @returns {any}
|
||||
*/
|
||||
_getRandomArrayEl(array) {
|
||||
return array[Math.floor(Math.random() * array.length)];
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {boolean}
|
||||
*/
|
||||
_getRandomBool() {
|
||||
return Math.random() < 0.5;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {DateTime}
|
||||
*/
|
||||
_getRandomDate() {
|
||||
const delta = Math.floor((Math.random() - Math.random()) * SampleServer.DATE_DELTA);
|
||||
return luxon.DateTime.local().plus({ hours: delta });
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} max
|
||||
* @returns {number} float in [O, max[
|
||||
*/
|
||||
_getRandomFloat(max) {
|
||||
return this._sanitizeNumber(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @param {number} max
|
||||
* @returns {number} int in [0, max[
|
||||
*/
|
||||
_getRandomInt(max) {
|
||||
return Math.floor(Math.random() * max);
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {string}
|
||||
*/
|
||||
_getRandomSelectionValue(modelName, field) {
|
||||
if (field.selection.length > 0) {
|
||||
return this._getRandomArrayEl(field.selection)[0];
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* @private
|
||||
* @returns {number} id in [1, SUB_RECORDSET_SIZE]
|
||||
*/
|
||||
_getRandomSubRecordId() {
|
||||
return Math.floor(Math.random() * SampleServer.SUB_RECORDSET_SIZE) + 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {Array[]} params.args (args[0] is the list of ids, args[1] is
|
||||
* the list of fields)
|
||||
* @returns {Object[]}
|
||||
*/
|
||||
_mockRead(params) {
|
||||
const model = this.data[params.model];
|
||||
const ids = params.args[0];
|
||||
const fieldNames = params.args[1];
|
||||
const records = [];
|
||||
for (const r of model.records) {
|
||||
if (!ids.includes(r.id)) {
|
||||
continue;
|
||||
}
|
||||
const record = { id: r.id };
|
||||
for (const fieldName of fieldNames) {
|
||||
const field = model.fields[fieldName];
|
||||
if (!field) {
|
||||
record[fieldName] = false; // unknown field
|
||||
} else if (field.type === "many2one") {
|
||||
const relModel = this.data[field.relation];
|
||||
const relRecord = relModel.records.find((relR) => r[fieldName] === relR.id);
|
||||
record[fieldName] = relRecord ? [relRecord.id, relRecord.display_name] : false;
|
||||
} else {
|
||||
record[fieldName] = r[fieldName];
|
||||
}
|
||||
}
|
||||
records.push(record);
|
||||
}
|
||||
return records;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the base method of formatted_read_group method.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.groupBy
|
||||
* @param {string[]} params.aggregates
|
||||
* @returns {Object[]} Object with keys groups and length
|
||||
*/
|
||||
_mockFormattedReadGroup(params) {
|
||||
const model = params.model;
|
||||
const groupBy = params.groupBy;
|
||||
const fields = this.data[model].fields;
|
||||
const records = this.data[model].records;
|
||||
const normalizedGroupBys = [];
|
||||
|
||||
for (const groupBySpec of groupBy) {
|
||||
const [fieldName, interval] = groupBySpec.split(":");
|
||||
const { type, relation } = fields[fieldName];
|
||||
if (type) {
|
||||
const gb = { fieldName, type, interval, relation, alias: groupBySpec };
|
||||
normalizedGroupBys.push(gb);
|
||||
}
|
||||
}
|
||||
|
||||
const groupsFromRecord = (record) => {
|
||||
const values = [];
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { fieldName, type, alias } = gb;
|
||||
let fieldVals;
|
||||
if (["date", "datetime"].includes(type)) {
|
||||
fieldVals = [this._formatValue(record[fieldName], gb)];
|
||||
} else if (type === "many2many") {
|
||||
fieldVals = record[fieldName].length ? record[fieldName] : [false];
|
||||
} else {
|
||||
fieldVals = [record[fieldName]];
|
||||
}
|
||||
values.push(fieldVals.map((val) => ({ [alias]: val })));
|
||||
}
|
||||
const cart = cartesian(...values);
|
||||
return cart.map((tuple) => {
|
||||
if (!Array.isArray(tuple)) {
|
||||
tuple = [tuple];
|
||||
}
|
||||
return Object.assign({}, ...tuple);
|
||||
});
|
||||
};
|
||||
|
||||
const groups = {};
|
||||
for (const record of records) {
|
||||
const recordGroups = groupsFromRecord(record);
|
||||
for (const group of recordGroups) {
|
||||
const groupId = JSON.stringify(group);
|
||||
if (!(groupId in groups)) {
|
||||
groups[groupId] = [];
|
||||
}
|
||||
groups[groupId].push(record);
|
||||
}
|
||||
}
|
||||
|
||||
const aggregates = params.aggregates || [];
|
||||
const measures = [];
|
||||
for (const measureSpec of aggregates) {
|
||||
if (measureSpec === "__count") {
|
||||
measures.push({ fieldName: "__count", func: "__count", name: measureSpec });
|
||||
continue;
|
||||
}
|
||||
const matches = measureSpec.match(MEASURE_SPEC_REGEX);
|
||||
if (!matches) {
|
||||
throw new Error(`Invalidate Aggregate "${measureSpec}" in SampleServer`);
|
||||
}
|
||||
const { fieldName, func } = matches.groups;
|
||||
measures.push({ fieldName, func, name: measureSpec });
|
||||
}
|
||||
|
||||
let result = [];
|
||||
for (const id in groups) {
|
||||
const records = groups[id];
|
||||
const group = { __extra_domain: [] };
|
||||
const firstElem = records[0];
|
||||
const parsedId = JSON.parse(id);
|
||||
for (const gb of normalizedGroupBys) {
|
||||
const { alias, fieldName, type } = gb;
|
||||
if (type === "many2many") {
|
||||
group[alias] = this._formatValue(parsedId[fieldName], gb);
|
||||
} else {
|
||||
group[alias] = this._formatValue(firstElem[fieldName], gb);
|
||||
}
|
||||
}
|
||||
Object.assign(group, this._aggregateFields(measures, records));
|
||||
result.push(group);
|
||||
}
|
||||
if (normalizedGroupBys.length > 0) {
|
||||
const { alias, type } = normalizedGroupBys[0];
|
||||
result = arraySortBy(result, (group) => {
|
||||
const val = group[alias];
|
||||
if (type === "datetime") {
|
||||
return deserializeDateTime(val);
|
||||
} else if (type === "date") {
|
||||
return deserializeDate(val);
|
||||
}
|
||||
return val;
|
||||
});
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the base method of formatted_read_grouping_sets method.
|
||||
*
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[][]} params.grouping_sets
|
||||
* @param {string[]} params.aggregates
|
||||
* @returns {Object[]} Object with keys groups and length
|
||||
*/
|
||||
_mockFormattedReadGroupingSets(params) {
|
||||
const res = [];
|
||||
for (const groupBy of params.grouping_sets) {
|
||||
res.push(this._mockFormattedReadGroup({ ...params, groupBy }));
|
||||
}
|
||||
return res;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the read_progress_bar method.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string} params.group_by
|
||||
* @param {Object} params.progress_bar
|
||||
* @return {Object}
|
||||
*/
|
||||
_mockReadProgressBar(params) {
|
||||
const groupBy = params.group_by;
|
||||
const progressBar = params.progress_bar;
|
||||
const groups = this._mockFormattedReadGroup({
|
||||
model: params.model,
|
||||
domain: params.domain,
|
||||
groupBy: [groupBy, progressBar.field],
|
||||
aggregates: ["__count"],
|
||||
});
|
||||
const data = {};
|
||||
for (const group of groups) {
|
||||
let groupByValue = group[groupBy];
|
||||
if (Array.isArray(groupByValue)) {
|
||||
groupByValue = groupByValue[0];
|
||||
}
|
||||
|
||||
// special case for bool values: rpc call response with capitalized strings
|
||||
if (!(groupByValue in data)) {
|
||||
if (groupByValue === true) {
|
||||
groupByValue = "True";
|
||||
} else if (groupByValue === false) {
|
||||
groupByValue = "False";
|
||||
}
|
||||
}
|
||||
|
||||
if (!(groupByValue in data)) {
|
||||
data[groupByValue] = {};
|
||||
for (const key in progressBar.colors) {
|
||||
data[groupByValue][key] = 0;
|
||||
}
|
||||
}
|
||||
data[groupByValue][group[progressBar.field]] += group.__count;
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
_mockWebSearchReadUnity(params) {
|
||||
const fields = Object.keys(params.specification);
|
||||
const model = this.data[params.model];
|
||||
let rawRecords = model.records;
|
||||
if ("recordIds" in params) {
|
||||
rawRecords = model.records.filter((record) => params.recordIds.includes(record.id));
|
||||
} else {
|
||||
rawRecords = rawRecords.slice(0, SampleServer.SEARCH_READ_LIMIT);
|
||||
}
|
||||
const records = this._mockRead({
|
||||
model: params.model,
|
||||
args: [rawRecords.map((r) => r.id), fields],
|
||||
});
|
||||
const result = { records, length: records.length };
|
||||
// populate many2one and x2many values
|
||||
for (const fieldName in params.specification) {
|
||||
const field = this.data[params.model].fields[fieldName];
|
||||
if (field.type === "many2one") {
|
||||
for (const record of result.records) {
|
||||
record[fieldName] = record[fieldName]
|
||||
? {
|
||||
id: record[fieldName][0],
|
||||
display_name: record[fieldName][1],
|
||||
}
|
||||
: false;
|
||||
}
|
||||
}
|
||||
if (field.type === "one2many" || field.type === "many2many") {
|
||||
const relFields = Object.keys(params.specification[fieldName].fields || {});
|
||||
if (relFields.length) {
|
||||
const relIds = result.records.map((r) => r[fieldName]).flat();
|
||||
const relRecords = {};
|
||||
const _relRecords = this._mockRead({
|
||||
model: field.relation,
|
||||
args: [relIds, relFields],
|
||||
});
|
||||
for (const relRecord of _relRecords) {
|
||||
relRecords[relRecord.id] = relRecord;
|
||||
}
|
||||
for (const record of result.records) {
|
||||
record[fieldName] = record[fieldName].map((resId) => relRecords[resId]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Mocks calls to the web_read_group method to return groups populated
|
||||
* with sample records. Only handles the case where the real call to
|
||||
* web_read_group returned groups, but none of these groups contain
|
||||
* records. In this case, we keep the real groups, and populate them
|
||||
* with sample records.
|
||||
* @private
|
||||
* @param {Object} params
|
||||
* @param {Object} [result] the result of a real call to web_read_group
|
||||
* @returns {{ groups: Object[], length: number }}
|
||||
*/
|
||||
_mockWebReadGroup(params) {
|
||||
const aggregates = [...params.aggregates, "__count"];
|
||||
if (params.unfold_read_specification) {
|
||||
aggregates.push("id:array_agg");
|
||||
}
|
||||
let groups;
|
||||
if (this.existingGroups) {
|
||||
this._tweakExistingGroups({ ...params, aggregates });
|
||||
groups = this.existingGroups;
|
||||
} else {
|
||||
groups = this._mockFormattedReadGroup({ ...params, aggregates });
|
||||
}
|
||||
// Don't care another params - and no subgroup:
|
||||
// order / opening_info / unfold_read_default_limit / groupby_read_specification
|
||||
const openAllGroups = params.auto_unfold && !this.existingGroups;
|
||||
let nbOpenedGroup = 0;
|
||||
if (params.unfold_read_specification) {
|
||||
for (const group of groups) {
|
||||
if (openAllGroups || "__records" in group) {
|
||||
// if group has a "__records" key, it means that it is an existing group, and
|
||||
// that the real webReadGroup returned a "__records" key for that group (which
|
||||
// is empty, otherwise we wouldn't be here), i.e. that group is opened.
|
||||
if (nbOpenedGroup < MAX_NUMBER_OPENED_GROUPS) {
|
||||
nbOpenedGroup++;
|
||||
group.__records = this._mockWebSearchReadUnity({
|
||||
model: params.model,
|
||||
specification: params.unfold_read_specification,
|
||||
recordIds: group["id:array_agg"],
|
||||
}).records;
|
||||
}
|
||||
}
|
||||
delete group["id:array_agg"];
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
groups,
|
||||
length: groups.length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the sample data such that the existing groups (in database)
|
||||
* also exists in the sample, and such that there are sample records in
|
||||
* those groups.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.groupBy
|
||||
*/
|
||||
_populateExistingGroups(params) {
|
||||
const groups = this.existingGroups;
|
||||
const groupBy = params.groupBy[0].split(":")[0];
|
||||
const groupByField = this.data[params.model].fields[groupBy];
|
||||
const groupedByM2O = groupByField.type === "many2one";
|
||||
if (groupedByM2O) {
|
||||
// re-populate co model with relevant records
|
||||
this.data[groupByField.relation].records = groups.map((g) => ({
|
||||
id: g[groupBy][0],
|
||||
display_name: g[groupBy][1],
|
||||
}));
|
||||
}
|
||||
for (const r of this.data[params.model].records) {
|
||||
const group = getSampleFromId(r.id, groups);
|
||||
if (["date", "datetime"].includes(groupByField.type)) {
|
||||
r[groupBy] = serializeGroupDateValue(group[params.groupBy[0]], groupByField);
|
||||
} else if (groupByField.type === "many2one") {
|
||||
r[groupBy] = group[params.groupBy[0]] ? group[params.groupBy[0]][0] : false;
|
||||
} else {
|
||||
r[groupBy] = group[params.groupBy[0]];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Generates sample records for the models in this.data. Records will be
|
||||
* generated once, and subsequent calls to this function will be skipped.
|
||||
* @private
|
||||
*/
|
||||
_populateModels() {
|
||||
if (!this.populated) {
|
||||
for (const modelName in this.data) {
|
||||
const model = this.data[modelName];
|
||||
const fieldNames = Object.keys(model.fields).filter((f) => f !== "id");
|
||||
const size =
|
||||
modelName === this.mainModel
|
||||
? SampleServer.MAIN_RECORDSET_SIZE
|
||||
: SampleServer.SUB_RECORDSET_SIZE;
|
||||
for (let id = 1; id <= size; id++) {
|
||||
const record = { id };
|
||||
for (const fieldName of fieldNames) {
|
||||
record[fieldName] = this._generateFieldValue(modelName, fieldName, id);
|
||||
}
|
||||
model.records.push(record);
|
||||
}
|
||||
}
|
||||
this.populated = true;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rounds the given number value according to the configured precision.
|
||||
* @private
|
||||
* @param {number} value
|
||||
* @returns {number}
|
||||
*/
|
||||
_sanitizeNumber(value) {
|
||||
return parseFloat(value.toFixed(SampleServer.FLOAT_PRECISION));
|
||||
}
|
||||
|
||||
/**
|
||||
* A real (web_)read_group call has been done, and it has returned groups,
|
||||
* but they are all empty. This function updates the sample data such
|
||||
* that those group values exist and those groups contain sample records.
|
||||
* @private
|
||||
* @param {Object[]} groups empty groups returned by the server
|
||||
* @param {Object} params
|
||||
* @param {string} params.model
|
||||
* @param {string[]} params.aggregates
|
||||
* @param {string[]} params.groupBy
|
||||
* @returns {Object[]} groups with count and aggregate values updated
|
||||
*
|
||||
* TODO: rename
|
||||
*/
|
||||
_tweakExistingGroups(params) {
|
||||
const groups = this.existingGroups;
|
||||
this._populateExistingGroups(params);
|
||||
|
||||
// update count and aggregates for each group
|
||||
const fullGroupBy = params.groupBy[0];
|
||||
const groupBy = fullGroupBy.split(":")[0];
|
||||
const groupByField = this.data[params.model].fields[groupBy];
|
||||
const records = this.data[params.model].records;
|
||||
for (const g of groups) {
|
||||
const recordsInGroup = records.filter((r) => {
|
||||
if (["date", "datetime"].includes(groupByField.type)) {
|
||||
return r[groupBy] === serializeGroupDateValue(g[fullGroupBy], groupByField);
|
||||
} else if (groupByField.type === "many2one") {
|
||||
return (!r[groupBy] && !g[fullGroupBy]) || r[groupBy] === g[fullGroupBy][0];
|
||||
}
|
||||
return r[groupBy] === g[fullGroupBy];
|
||||
});
|
||||
for (const aggregateSpec of params.aggregates || []) {
|
||||
if (aggregateSpec === "__count") {
|
||||
g.__count = recordsInGroup.length;
|
||||
continue;
|
||||
}
|
||||
const [fieldName, func] = aggregateSpec.split(":");
|
||||
if (func === "array_agg") {
|
||||
g[aggregateSpec] = recordsInGroup.map((r) => r[fieldName]);
|
||||
} else if (
|
||||
["integer, float", "monetary"].includes(
|
||||
this.data[params.model].fields[fieldName].type
|
||||
)
|
||||
) {
|
||||
g[aggregateSpec] = recordsInGroup.reduce((acc, r) => acc + r[fieldName], 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
SampleServer.FORMATS = {
|
||||
day: "yyyy-MM-dd",
|
||||
week: "'W'WW kkkk",
|
||||
month: "MMMM yyyy",
|
||||
quarter: "'Q'q yyyy",
|
||||
year: "y",
|
||||
};
|
||||
SampleServer.INTERVALS = {
|
||||
day: (dt) => dt.plus({ days: 1 }),
|
||||
week: (dt) => dt.plus({ weeks: 1 }),
|
||||
month: (dt) => dt.plus({ months: 1 }),
|
||||
quarter: (dt) => dt.plus({ months: 3 }),
|
||||
year: (dt) => dt.plus({ years: 1 }),
|
||||
};
|
||||
SampleServer.DISPLAY_FORMATS = Object.assign({}, SampleServer.FORMATS, { day: "dd MMM yyyy" });
|
||||
|
||||
SampleServer.MAIN_RECORDSET_SIZE = 16;
|
||||
SampleServer.SUB_RECORDSET_SIZE = 5;
|
||||
SampleServer.SEARCH_READ_LIMIT = 10;
|
||||
|
||||
SampleServer.MAX_FLOAT = 100;
|
||||
SampleServer.MAX_INTEGER = 50;
|
||||
SampleServer.MAX_COLOR_INT = 7;
|
||||
SampleServer.MAX_MONETARY = 100000;
|
||||
SampleServer.DATE_DELTA = 24 * 60; // in hours -> 60 days
|
||||
SampleServer.FLOAT_PRECISION = 2;
|
||||
|
||||
SampleServer.SAMPLE_COUNTRIES = ["Belgium", "France", "Portugal", "Singapore", "Australia"];
|
||||
SampleServer.SAMPLE_PEOPLE = [
|
||||
"John Miller",
|
||||
"Henry Campbell",
|
||||
"Carrie Helle",
|
||||
"Wendi Baltz",
|
||||
"Thomas Passot",
|
||||
];
|
||||
SampleServer.SAMPLE_TEXTS = [
|
||||
"Laoreet id",
|
||||
"Volutpat blandit",
|
||||
"Integer vitae",
|
||||
"Viverra nam",
|
||||
"In massa",
|
||||
];
|
||||
SampleServer.PEOPLE_MODELS = [
|
||||
"res.users",
|
||||
"res.partner",
|
||||
"hr.employee",
|
||||
"mail.followers",
|
||||
"mailing.contact",
|
||||
];
|
||||
|
||||
SampleServer.UnimplementedRouteError = UnimplementedRouteError;
|
||||
|
||||
export function buildSampleORM(resModel, fields, user) {
|
||||
const sampleServer = new SampleServer(resModel, fields);
|
||||
const fakeRPC = async (_, params) => {
|
||||
const { args, kwargs, method, model } = params;
|
||||
const { groupby: groupBy } = kwargs;
|
||||
return sampleServer.mockRpc({ method, model, args, ...kwargs, groupBy });
|
||||
};
|
||||
const sampleORM = new ORM(user);
|
||||
sampleORM.rpc = fakeRPC;
|
||||
sampleORM.isSample = true;
|
||||
sampleORM.cache = () => sampleORM;
|
||||
sampleORM.setGroups = (groups) => sampleServer.setExistingGroups(groups);
|
||||
return sampleORM;
|
||||
}
|
||||
Reference in New Issue
Block a user