19.0 vanilla

This commit is contained in:
Ernad Husremovic 2026-03-09 09:32:02 +01:00
parent 62d197ac8b
commit 184bb0e321
667 changed files with 691406 additions and 239886 deletions

View file

@ -0,0 +1,77 @@
import { animationFrame } from "@odoo/hoot-mock";
import * as spreadsheet from "@odoo/o-spreadsheet";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
const uuidGenerator = new spreadsheet.helpers.UuidGenerator();
/**
* @typedef {import("@odoo/o-spreadsheet").Model} Model
* @typedef {import("@spreadsheet").OdooSpreadsheetModel} OdooSpreadsheetModel
*/
/**
*
* @param {Model} model
* @param {string} type
* @param {import("@spreadsheet/chart/odoo_chart/odoo_chart").OdooChartDefinition} definition
*/
export function insertChartInSpreadsheet(model, type = "odoo_bar", definition = {}) {
definition = { ...getChartDefinition(type), ...definition };
model.dispatch("CREATE_CHART", {
sheetId: model.getters.getActiveSheetId(),
chartId: definition.id,
figureId: uuidGenerator.smallUuid(),
col: 0,
row: 0,
offset: {
x: 10,
y: 10,
},
definition,
});
return definition.id;
}
/**
*
* @param {Object} params
* @param {function} [params.definition]
* @param {function} [params.mockRPC]
* @param {string} [params.type]
* @param {import("./data").ServerData} [params.serverData]
*
* @returns { Promise<{ model: OdooSpreadsheetModel, env: Object }>}
*/
export async function createSpreadsheetWithChart(params = {}) {
const { model, env } = await createModelWithDataSource(params);
insertChartInSpreadsheet(model, params.type, params.definition);
await animationFrame();
return { model, env };
}
function getChartDefinition(type) {
return {
metaData: {
groupBy: ["foo", "bar"],
measure: "__count",
order: null,
resModel: "partner",
},
searchParams: {
comparison: null,
context: {},
domain: [],
groupBy: [],
orderBy: [],
},
stacked: true,
title: { text: "Partners" },
background: "#FFFFFF",
legendPosition: "top",
verticalAxisPosition: "left",
dataSourceId: uuidGenerator.smallUuid(),
id: uuidGenerator.smallUuid(),
type,
};
}

View file

@ -0,0 +1,373 @@
/** @ts-check */
import * as spreadsheet from "@odoo/o-spreadsheet";
import { waitForDataLoaded } from "@spreadsheet/helpers/model";
import { animationFrame } from "@odoo/hoot-mock";
const { toCartesian, toZone, lettersToNumber, deepCopy } = spreadsheet.helpers;
/**
* @typedef {import("@spreadsheet").GlobalFilter} GlobalFilter
* @typedef {import("@spreadsheet").CmdGlobalFilter} CmdGlobalFilter
* @typedef {import("@spreadsheet").OdooSpreadsheetModel} OdooSpreadsheetModel
* @typedef {import("@odoo/o-spreadsheet").UID} UID
*/
/**
* Select a cell
*/
export function selectCell(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
if (sheetId !== model.getters.getActiveSheetId()) {
model.dispatch("ACTIVATE_SHEET", { sheetIdTo: sheetId });
}
return model.selection.selectCell(col, row);
}
/**
* Add a global filter. Does not wait for the data sources to be reloaded
* @param {import("@spreadsheet").OdooSpreadsheetModel} model
* @param {CmdGlobalFilter} filter
*/
export function addGlobalFilterWithoutReload(model, filter, fieldMatchings = {}) {
return model.dispatch("ADD_GLOBAL_FILTER", { filter, ...fieldMatchings });
}
export function setGlobalFilterValueWithoutReload(model, payload) {
return model.dispatch("SET_GLOBAL_FILTER_VALUE", payload);
}
/**
* Add a global filter and ensure the data sources are completely reloaded
* @param {import("@spreadsheet").OdooSpreadsheetModel} model
* @param {CmdGlobalFilter} filter
*/
export async function addGlobalFilter(model, filter, fieldMatchings = {}) {
const result = model.dispatch("ADD_GLOBAL_FILTER", { filter, ...fieldMatchings });
// Wait for the fetch of DisplayNames
await animationFrame();
await waitForDataLoaded(model);
return result;
}
/**
* Remove a global filter and ensure the data sources are completely reloaded
*/
export async function removeGlobalFilter(model, id) {
const result = model.dispatch("REMOVE_GLOBAL_FILTER", { id });
// Wait for the fetch of DisplayNames
await animationFrame();
await waitForDataLoaded(model);
return result;
}
/**
* Edit a global filter and ensure the data sources are completely reloaded
* @param {Model} model
* @param {CmdGlobalFilter} filter
*/
export async function editGlobalFilter(model, filter) {
const result = model.dispatch("EDIT_GLOBAL_FILTER", { filter });
// Wait for the fetch of DisplayNames
await animationFrame();
await waitForDataLoaded(model);
return result;
}
/**
* Set the value of a global filter and ensure the data sources are completely
* reloaded
*/
export async function setGlobalFilterValue(model, payload) {
const result = model.dispatch("SET_GLOBAL_FILTER_VALUE", payload);
// Wait for the fetch of DisplayNames
await animationFrame();
await waitForDataLoaded(model);
return result;
}
export function moveGlobalFilter(model, id, delta) {
return model.dispatch("MOVE_GLOBAL_FILTER", { id, delta });
}
/**
* Set the selection
*/
export function setSelection(model, xc) {
const zone = toZone(xc);
model.selection.selectZone({ cell: { col: zone.left, row: zone.top }, zone });
}
/**
* Autofill from a zone to a cell
*/
export function autofill(model, from, to) {
setSelection(model, from);
model.dispatch("AUTOFILL_SELECT", toCartesian(to));
model.dispatch("AUTOFILL");
}
/**
* Set the content of a cell
*/
export function setCellContent(model, xc, content, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, content });
}
/**
* Set the format of a cell
*/
export function setCellFormat(model, xc, format, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, format });
}
/**
* Set the style of a cell
*/
export function setCellStyle(model, xc, style, sheetId = model.getters.getActiveSheetId()) {
model.dispatch("UPDATE_CELL", { ...toCartesian(xc), sheetId, style });
}
/**
* Add columns
* @param {OdooSpreadsheetModel} model
* @param {"before"|"after"} position
* @param {string} column
* @param {number} quantity
* @param {UID} sheetId
*/
export function addColumns(
model,
position,
column,
quantity,
sheetId = model.getters.getActiveSheetId()
) {
return model.dispatch("ADD_COLUMNS_ROWS", {
sheetId,
dimension: "COL",
position,
base: lettersToNumber(column),
quantity,
});
}
export function addRows(
model,
position,
row,
quantity,
sheetId = model.getters.getActiveSheetId()
) {
return model.dispatch("ADD_COLUMNS_ROWS", {
sheetId,
dimension: "ROW",
position,
base: row,
quantity,
});
}
/**
* Delete columns
* @param {OdooSpreadsheetModel} model
* @param {string[]} columns
* @param {UID} sheetId
*/
export function deleteColumns(model, columns, sheetId = model.getters.getActiveSheetId()) {
return model.dispatch("REMOVE_COLUMNS_ROWS", {
sheetId,
dimension: "COL",
elements: columns.map(lettersToNumber),
});
}
/** Create a test chart in the active sheet*/
export function createBasicChart(
model,
chartId,
definition,
sheetId = model.getters.getActiveSheetId(),
figureId = model.uuidGenerator.smallUuid()
) {
model.dispatch("CREATE_CHART", {
chartId,
figureId,
col: 0,
row: 0,
offset: {
x: 0,
y: 0,
},
sheetId: sheetId,
definition: {
title: { text: "test" },
dataSets: [{ dataRange: "A1" }],
type: "bar",
background: "#fff",
verticalAxisPosition: "left",
legendPosition: "top",
stackedBar: false,
...definition,
},
});
}
/** Create a test scorecard chart in the active sheet*/
export function createScorecardChart(
model,
chartId,
sheetId = model.getters.getActiveSheetId(),
figureId = model.uuidGenerator.smallUuid()
) {
model.dispatch("CREATE_CHART", {
figureId,
chartId,
col: 0,
row: 0,
offset: { x: 0, y: 0 },
sheetId: sheetId,
definition: {
title: { text: "test" },
keyValue: "A1",
type: "scorecard",
background: "#fff",
baselineColorDown: "#DC6965",
baselineColorUp: "#00A04A",
baselineMode: "absolute",
},
});
}
/** Create a test scorecard chart in the active sheet*/
export function createGaugeChart(
model,
chartId,
sheetId = model.getters.getActiveSheetId(),
figureId = model.uuidGenerator.smallUuid()
) {
model.dispatch("CREATE_CHART", {
figureId,
chartId,
col: 0,
row: 0,
offset: { x: 0, y: 0 },
sheetId: sheetId,
definition: {
title: { text: "test" },
type: "gauge",
background: "#fff",
dataRange: "A1",
sectionRule: {
rangeMin: "0",
rangeMax: "100",
colors: {
lowerColor: "#112233",
middleColor: "#445566",
upperColor: "#778899",
},
lowerInflectionPoint: {
type: "number",
value: "25",
},
upperInflectionPoint: {
type: "number",
value: "85",
},
},
},
});
}
export function updateChart(model, chartId, partialDefinition) {
const definition = model.getters.getChartDefinition(chartId);
return model.dispatch("UPDATE_CHART", {
definition: { ...definition, ...partialDefinition },
chartId,
figureId: model.getters.getFigureIdFromChartId(chartId),
sheetId: model.getters.getActiveSheetId(),
});
}
export function undo(model) {
model.dispatch("REQUEST_UNDO");
}
export function redo(model) {
model.dispatch("REQUEST_REDO");
}
export function updatePivot(model, pivotId, pivotData) {
const pivot = {
...model.getters.getPivotCoreDefinition(pivotId),
...pivotData,
};
return model.dispatch("UPDATE_PIVOT", { pivotId, pivot });
}
/**
* Copy a zone
*/
export function copy(model, xc) {
setSelection(model, xc);
return model.dispatch("COPY");
}
/**
* Cut a zone
*/
export function cut(model, xc) {
setSelection(model, xc);
return model.dispatch("CUT");
}
/**
* Paste on a zone
*/
export function paste(model, range, pasteOption) {
return model.dispatch("PASTE", { target: [toZone(range)], pasteOption });
}
export function updatePivotMeasureDisplay(model, pivotId, measureId, display) {
const measures = deepCopy(model.getters.getPivotCoreDefinition(pivotId)).measures;
const measure = measures.find((m) => m.id === measureId);
measure.display = display;
updatePivot(model, pivotId, { measures });
}
export function createSheet(model, data = {}) {
const sheetId = data.sheetId || model.uuidGenerator.smallUuid();
return model.dispatch("CREATE_SHEET", {
position: data.position !== undefined ? data.position : 1,
sheetId,
cols: data.cols,
rows: data.rows,
name: data.name,
});
}
export function createCarousel(model, data = { items: [] }, carouselId, sheetId, figureData = {}) {
return model.dispatch("CREATE_CAROUSEL", {
figureId: carouselId || model.uuidGenerator.smallUuid(),
sheetId: sheetId || model.getters.getActiveSheetId(),
col: 0,
row: 0,
definition: data,
size: { width: 500, height: 300 },
offset: { x: 0, y: 0 },
...figureData,
});
}
export function addChartFigureToCarousel(
model,
carouselId,
chartFigureId,
sheetId = model.getters.getActiveSheetId()
) {
return model.dispatch("ADD_FIGURE_CHART_TO_CAROUSEL", {
carouselFigureId: carouselId,
chartFigureId,
sheetId,
});
}

View file

@ -0,0 +1,693 @@
import {
MockServer,
defineActions,
defineModels,
fields,
models,
onRpc,
serverState,
webModels,
} from "@web/../tests/web_test_helpers";
import { mailModels } from "@mail/../tests/mail_test_helpers";
import { RPCError } from "@web/core/network/rpc";
/**
* @typedef {object} ServerData
* @property {object} [models]
* @property {object} [views]
* @property {object} [menus]
* @property {object} [actions]
*/
/**
* Get a basic arch for a pivot, which is compatible with the data given by
* getBasicData().
*
* Here is the pivot created:
* A B C D E F
* 1 1 2 12 17 Total
* 2 Proba Proba Proba Proba Proba
* 3 false 15 15
* 4 true 11 10 95 116
* 5 Total 11 15 10 95 131
*/
export function getBasicPivotArch() {
return /* xml */ `
<pivot string="Partners">
<field name="foo" type="col"/>
<field name="bar" type="row"/>
<field name="probability" type="measure"/>
</pivot>`;
}
/**
* Get a basic arch for a list, which is compatible with the data given by
* getBasicData().
*
* Here is the list created:
* A B C D
* 1 Foo bar Date Product
* 2 12 True 2016-04-14 xphone
* 3 1 True 2016-10-26 xpad
* 4 17 True 2016-12-15 xpad
* 5 2 False 2016-12-11 xpad
*/
export function getBasicListArch() {
return /* xml */ `
<list string="Partners">
<field name="foo"/>
<field name="bar"/>
<field name="date"/>
<field name="product_id"/>
</list>
`;
}
export function getBasicGraphArch() {
return /* xml */ `
<graph string="PartnerGraph">
<field name="bar" />
</graph>
`;
}
/**
* @returns {ServerData}
*/
export function getBasicServerData() {
return {
models: getBasicData(),
views: {},
};
}
/**
*
* @param {string} model
* @param {Array<string>} columns
* @param {{name: string, asc: boolean}[]} orderBy
*
* @returns { {definition: Object, columns: Array<Object>}}
*/
export function generateListDefinition(model, columns, actionXmlId, orderBy = []) {
const cols = [];
for (const name of columns) {
const PyModel = Object.values(SpreadsheetModels).find((m) => m._name === model);
cols.push({
name,
type: PyModel._fields[name].type,
});
}
return {
definition: {
metaData: {
resModel: model,
columns,
},
searchParams: {
domain: [],
context: {},
orderBy,
},
name: "List",
actionXmlId,
},
columns: cols,
};
}
export function getBasicListArchs() {
return {
"partner,false,list": getBasicListArch(),
};
}
function mockSpreadsheetDataController(_request, { res_model, res_id }) {
const [record] = this.env[res_model].search_read([["id", "=", parseInt(res_id)]]);
if (!record) {
const error = new RPCError(`Spreadsheet ${res_id} does not exist`);
error.data = {};
throw error;
}
return {
data: JSON.parse(record.spreadsheet_data),
name: record.name,
revisions: [],
isReadonly: false,
writable_rec_name_field: "name",
};
}
onRpc("/spreadsheet/data/<string:res_model>/<int:res_id>", mockSpreadsheetDataController);
export function defineSpreadsheetModels() {
defineModels(SpreadsheetModels);
}
export function defineSpreadsheetActions() {
defineActions([
{
id: 1,
name: "partner Action",
res_model: "partner",
xml_id: "spreadsheet.partner_action",
views: [
[false, "list"],
[false, "pivot"],
[false, "graph"],
[false, "search"],
[false, "form"],
],
},
]);
}
export class IrModel extends webModels.IrModel {
display_name_for(models) {
const records = this.env["ir.model"].search_read(
[["model", "in", models]],
["name", "model"]
);
const result = [];
for (const model of models) {
const record = records.find((record) => record.model === model);
if (record) {
result.push({ model: model, display_name: record.name });
} else {
result.push({ model: model, display_name: model });
}
}
return result;
}
/**
* @param {string[]} modelNames
*/
has_searchable_parent_relation(modelNames) {
return Object.fromEntries(modelNames.map((modelName) => [modelName, false]));
}
get_available_models() {
return this.env["ir.model"].search_read([], ["display_name", "model"]);
}
_records = [
{
id: 37,
name: "Product",
model: "product",
},
{
id: 40,
name: "Partner",
model: "partner",
},
{
id: 55,
name: "Users",
model: "res.users",
},
{
id: 56,
name: "Currency",
model: "res.currency",
},
{
id: 57,
name: "Tag",
model: "tag",
},
];
}
export class IrUIMenu extends models.Model {
_name = "ir.ui.menu";
name = fields.Char({ string: "Name" });
action = fields.Char({ string: "Action" });
group_ids = fields.Many2many({ string: "Groups", relation: "res.group" });
}
export class IrActions extends models.Model {
_name = "ir.actions";
}
export class ResGroup extends models.Model {
_name = "res.group";
name = fields.Char({ string: "Name" });
}
export class ResUsers extends mailModels.ResUsers {
_name = "res.users";
name = fields.Char({ string: "Name" });
group_ids = fields.Many2many({ string: "Groups", relation: "res.group" });
}
export class SpreadsheetMixin extends models.Model {
_name = "spreadsheet.mixin";
spreadsheet_binary_data = fields.Binary({ string: "Spreadsheet file" });
spreadsheet_data = fields.Text();
display_thumbnail = fields.Binary();
get_display_names_for_spreadsheet(args) {
const result = [];
for (const { model, id } of args) {
const record = this.env[model].search_read([["id", "=", id]])[0];
result.push(record?.display_name ?? null);
}
return result;
}
get_selector_spreadsheet_models() {
return [
{
model: "documents.document",
display_name: "Spreadsheets",
allow_create: true,
},
];
}
}
export class ResCurrency extends models.Model {
_name = "res.currency";
name = fields.Char({ string: "Code" });
symbol = fields.Char({ string: "Symbol" });
position = fields.Selection({
string: "Position",
selection: [
["after", "A"],
["before", "B"],
],
});
decimal_places = fields.Integer({ string: "decimal" });
get_company_currency_for_spreadsheet() {
return {
code: "EUR",
symbol: "€",
position: "after",
decimalPlaces: 2,
};
}
_records = [
{
id: 1,
name: "EUR",
symbol: "€",
position: "after",
decimal_places: 2,
},
{
id: 2,
name: "USD",
symbol: "$",
position: "before",
decimal_places: 2,
},
];
}
export class ResCountry extends webModels.ResCountry {
_name = "res.country";
name = fields.Char({ string: "Country" });
code = fields.Char({ string: "Code" });
_records = [
{ id: 1, name: "Belgium", code: "BE" },
{ id: 2, name: "France", code: "FR" },
{ id: 3, name: "United States", code: "US" },
];
}
export class ResCountryState extends models.Model {
_name = "res.country.state";
name = fields.Char({ string: "Name" });
code = fields.Char({ string: "Code" });
country_id = fields.Many2one({ relation: "res.country" });
display_name = fields.Char({ string: "Display Name" });
_records = [
{ id: 1, name: "California", code: "CA", country_id: 3, display_name: "California (US)" },
{ id: 2, name: "New York", code: "NY", country_id: 3, display_name: "New York (US)" },
{ id: 3, name: "Texas", code: "TX", country_id: 3, display_name: "Texas (US)" },
];
}
export class Partner extends models.Model {
_name = "partner";
foo = fields.Integer({
string: "Foo",
store: true,
searchable: true,
aggregator: "sum",
groupable: false,
});
bar = fields.Boolean({
string: "Bar",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
name = fields.Char({
string: "name",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
date = fields.Date({
string: "Date",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
create_date = fields.Datetime({
string: "Creation Date",
store: true,
sortable: true,
groupable: true,
});
active = fields.Boolean({
string: "Active",
default: true,
searchable: true,
groupable: false,
});
product_id = fields.Many2one({
string: "Product",
relation: "product",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
tag_ids = fields.Many2many({
string: "Tags",
relation: "tag",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
probability = fields.Float({
string: "Probability",
searchable: true,
store: true,
aggregator: "avg",
groupable: false,
});
field_with_array_agg = fields.Integer({
string: "field_with_array_agg",
searchable: true,
groupable: false,
aggregator: "array_agg",
});
currency_id = fields.Many2one({
string: "Currency",
relation: "res.currency",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
pognon = fields.Monetary({
string: "Money!",
currency_field: "currency_id",
store: true,
sortable: true,
aggregator: "avg",
groupable: true,
searchable: true,
});
partner_properties = fields.Properties({
string: "Properties",
store: true,
sortable: true,
groupable: true,
searchable: true,
definition_record: "product_id",
definition_record_field: "properties_definitions",
});
jsonField = fields.Json({ string: "Json Field", store: true, groupable: false });
user_ids = fields.Many2many({
relation: "res.users",
string: "Users",
searchable: true,
groupable: false,
});
_records = [
{
id: 1,
foo: 12,
bar: true,
name: "Raoul",
date: "2016-04-14",
create_date: "2016-04-03 00:00:00",
product_id: 37,
probability: 10,
field_with_array_agg: 1,
tag_ids: [42, 67],
currency_id: 1,
pognon: 74.4,
},
{
id: 2,
foo: 1,
bar: true,
name: "Steven",
date: "2016-10-26",
create_date: "2014-04-03 00:05:32",
product_id: 41,
probability: 11,
field_with_array_agg: 2,
tag_ids: [42, 67],
currency_id: 2,
pognon: 74.8,
},
{
id: 3,
foo: 17,
bar: true,
name: "Taylor",
date: "2016-12-15",
create_date: "2006-01-03 11:30:50",
product_id: 41,
probability: 95,
field_with_array_agg: 3,
tag_ids: [],
currency_id: 1,
pognon: 4,
},
{
id: 4,
foo: 2,
bar: false,
name: "Zara",
date: "2016-12-11",
create_date: "2016-12-10 21:59:59",
product_id: 41,
probability: 15,
field_with_array_agg: 4,
tag_ids: [42],
currency_id: 2,
pognon: 1000,
},
];
// TODO: check which views are actually needed in the tests
_views = {
list: getBasicListArch(),
pivot: getBasicPivotArch(),
graph: getBasicGraphArch(),
};
}
export class Product extends models.Model {
_name = "product";
name = fields.Char({ string: "Product Name" });
display_name = fields.Char({ string: "Product Name" });
active = fields.Boolean({ string: "Active", default: true });
template_id = fields.Many2one({
string: "Template",
relation: "product",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
properties_definitions = fields.PropertiesDefinition();
pognon = fields.Monetary({
string: "Money!",
currency_field: "currency_id",
store: true,
sortable: true,
aggregator: "avg",
groupable: true,
searchable: true,
});
currency_id = fields.Many2one({
string: "Currency",
relation: "res.currency",
store: true,
sortable: true,
groupable: true,
searchable: true,
});
_records = [
{
id: 37,
display_name: "xphone",
name: "xphone",
currency_id: 2,
pognon: 699.99,
},
{
id: 41,
display_name: "xpad",
template_id: 37,
name: "xpad",
currency_id: 2,
pognon: 599.99,
},
];
}
export class Tag extends models.Model {
_name = "tag";
name = fields.Char({ string: "Tag Name" });
_records = [
{
id: 42,
name: "isCool",
},
{
id: 67,
name: "Growing",
},
];
}
export function getBasicData() {
return {
"documents.document": {},
"ir.model": {},
"ir.embedded.actions": {},
"documents.tag": {},
"spreadsheet.template": {},
"res.currency": {},
"res.users": {},
partner: {},
product: {},
tag: {},
};
}
export const SpreadsheetModels = {
...webModels,
...mailModels,
IrModel,
IrUIMenu,
IrActions,
ResGroup,
ResUsers,
ResCountry,
ResCountryState,
SpreadsheetMixin,
ResCurrency,
Partner,
Product,
Tag,
};
/**
* Add the records inside serverData in the MockServer
*
* @param {ServerData} serverData
*/
export function addRecordsFromServerData(serverData) {
for (const modelName of Object.keys(serverData.models)) {
const records = serverData.models[modelName].records;
if (!records) {
continue;
}
const PyModel = getSpreadsheetModel(modelName);
if (!PyModel) {
throw new Error(`Model ${modelName} not found inside SpreadsheetModels`);
}
checkRecordsValidity(modelName, records);
PyModel._records = records;
}
}
/**
* Add the views inside serverData in the MockServer
*
* @param {ServerData} serverData
*
* @example
* addViewsFromServerData({ "partner,false,search": "<search/>" });
* Will set the default search view for the partner model
*/
export function addViewsFromServerData(serverData) {
for (const fullViewKey of Object.keys(serverData.views)) {
const viewArch = serverData.views[fullViewKey];
const splitted = fullViewKey.split(",");
const modelName = splitted[0];
const viewType = splitted[2];
const recordId = splitted[1];
const PyModel = getSpreadsheetModel(modelName);
if (!PyModel) {
throw new Error(`Model ${modelName} not found inside SpreadsheetModels`);
}
const viewKey = viewType + "," + recordId;
PyModel._views[viewKey] = viewArch;
}
}
/**
* Check if the records are valid.
* This is mainly to avoid the mail's service crashing if the res.users are not correctly set.
*/
function checkRecordsValidity(modelName, records) {
if (modelName === "res.users") {
const serverUserId = serverState.userId;
const currentUser = records.find((record) => record.id === serverUserId);
if (!currentUser) {
throw new Error(
`The current user (${serverUserId}) is not in the records. did you forget to set serverState.userId ?`
);
}
if (!currentUser.active) {
throw new Error(`The current user (${serverUserId}) is not active`);
}
if (!currentUser.partner_id) {
throw new Error(
`The current user (${serverUserId}) has no partner_id. It should be set to serverState.partnerId`
);
}
}
}
export function getPyEnv() {
const mockServer = MockServer.current;
if (!mockServer) {
throw new Error("No mock server found");
}
return mockServer.env;
}
export function getSpreadsheetModel(modelName) {
return Object.values(SpreadsheetModels).find((model) => model._name === modelName);
}

View file

@ -0,0 +1,50 @@
const { DateTime } = luxon;
import { Domain } from "@web/core/domain";
import { expect } from "@odoo/hoot";
function getDateDomainBounds(domain) {
const startDateStr = domain[1][2];
const endDateStr = domain[2][2];
const isDateTime = startDateStr.includes(":");
if (isDateTime) {
const dateTimeFormat = "yyyy-MM-dd HH:mm:ss";
const start = DateTime.fromFormat(startDateStr, dateTimeFormat);
const end = DateTime.fromFormat(endDateStr, dateTimeFormat);
return { start, end };
}
const start = DateTime.fromISO(startDateStr);
const end = DateTime.fromISO(endDateStr);
const startIsIncluded = domain[1][1] === ">=";
const endIsIncluded = domain[2][1] === "<=";
return {
start: startIsIncluded ? start.startOf("day") : start.endOf("day"),
end: endIsIncluded ? end.endOf("day") : end.startOf("day"),
};
}
/**
* @param {string} field
* @param {string} start
* @param {string} end
* @param {import("@web/core/domain").DomainRepr} domain
*/
export function assertDateDomainEqual(field, start, end, domain) {
domain = new Domain(domain).toList();
expect(domain[0]).toBe("&");
expect(domain[1]).toEqual([field, ">=", start]);
expect(domain[2]).toEqual([field, "<=", end]);
}
/**
* @param {import("@web/core/domain").DomainRepr} domain
* @returns {number}
*/
export function getDateDomainDurationInDays(domain) {
domain = new Domain(domain).toList();
const bounds = getDateDomainBounds(domain);
const diff = bounds.end.diff(bounds.start, ["days"]);
return Math.round(diff.days);
}

View file

@ -0,0 +1,126 @@
import * as spreadsheet from "@odoo/o-spreadsheet";
import { range } from "@web/core/utils/numbers";
const { toCartesian, toZone, toXC } = spreadsheet.helpers;
/**
* Get the value of the given cell
*/
export function getCellValue(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
return cell.value;
}
/**
* Get the cell of the given xc
*/
export function getCell(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
return model.getters.getCell({ sheetId, col, row });
}
export function getEvaluatedCell(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
return model.getters.getEvaluatedCell({ sheetId, col, row });
}
export function getEvaluatedGrid(model, zoneXc, sheetId = model.getters.getActiveSheetId()) {
const { top, bottom, left, right } = toZone(zoneXc);
const grid = [];
for (const row of range(top, bottom + 1)) {
const colValues = [];
grid.push(colValues);
for (const col of range(left, right + 1)) {
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
colValues.push(cell.value);
}
}
return grid;
}
export function getEvaluatedFormatGrid(model, zoneXc, sheetId = model.getters.getActiveSheetId()) {
const { top, bottom, left, right } = toZone(zoneXc);
const grid = [];
for (const row of range(top, bottom + 1)) {
grid.push([]);
for (const col of range(left, right + 1)) {
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
grid[row][col] = cell.format;
}
}
return grid;
}
export function getFormattedValueGrid(model, zoneXc, sheetId = model.getters.getActiveSheetId()) {
const { top, bottom, left, right } = toZone(zoneXc);
const grid = {};
for (const row of range(top, bottom + 1)) {
for (const col of range(left, right + 1)) {
const cell = model.getters.getEvaluatedCell({ sheetId, col, row });
grid[toXC(col, row)] = cell.formattedValue;
}
}
return grid;
}
/**
* Get the cells of the given sheet (or active sheet if not provided)
*/
export function getCells(model, sheetId = model.getters.getActiveSheetId()) {
return model.getters.getCells(sheetId);
}
/**
* Get the formula of the given xc
*/
export function getCellFormula(model, xc, sheetId = model.getters.getActiveSheetId()) {
const cell = getCell(model, xc, sheetId);
return cell && cell.isFormula ? cell.content : "";
}
/**
* Get the content of the given xc
*/
export function getCellContent(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
return model.getters.getCellText({ sheetId, col, row }, { showFormula: true });
}
export function getCorrespondingCellFormula(model, xc, sheetId = model.getters.getActiveSheetId()) {
const cell = model.getters.getCorrespondingFormulaCell({ sheetId, ...toCartesian(xc) });
return cell && cell.isFormula ? cell.content : "";
}
/**
* Get the list of the merges (["A1:A2"]) of the sheet
*/
export function getMerges(model, sheetId = model.getters.getActiveSheetId()) {
return model.exportData().sheets.find((sheet) => sheet.id === sheetId).merges;
}
/**
* Get the borders at the given XC
*/
export function getBorders(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
const borders = model.getters.getCellBorder({ sheetId, col, row });
if (!borders) {
return null;
}
Object.keys(borders).forEach((key) => borders[key] === undefined && delete borders[key]);
return borders;
}
/**
* Get the formatted value of the given xc
*/
export function getCellFormattedValue(model, xc, sheetId = model.getters.getActiveSheetId()) {
const { col, row } = toCartesian(xc);
return model.getters.getCellText({ sheetId, col, row }, false);
}
export function getCellIcons(model, xc) {
const sheetId = model.getters.getActiveSheetId();
return model.getters.getCellIcons({ ...toCartesian(xc), sheetId });
}

View file

@ -0,0 +1,12 @@
/**
* @typedef {import("@spreadsheet/global_filters/plugins/global_filters_core_plugin").GlobalFilter} GlobalFilter
*
*/
/** @type FixedPeriodDateGlobalFilter */
export const THIS_YEAR_GLOBAL_FILTER = {
id: "43",
type: "date",
label: "This Year",
defaultValue: "this_year",
};

View file

@ -0,0 +1,74 @@
import { waitForDataLoaded } from "@spreadsheet/helpers/model";
import { generateListDefinition } from "@spreadsheet/../tests/helpers/data";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
/**
* @typedef {import("@spreadsheet/o_spreadsheet/o_spreadsheet").Model} Model
*/
/**
* Insert a list in a spreadsheet model.
*
* @param {Model} model
* @param {Object} params
* @param {string} params.model
* @param {Array<string>} params.columns
* @param {number} [params.linesNumber]
* @param {[number, number]} [params.position]
* @param {string} [params.sheetId]
* @param {{name: string, asc: boolean}[]} [params.orderBy]
*/
export function insertListInSpreadsheet(model, params) {
const { definition, columns } = generateListDefinition(
params.model,
params.columns,
params.actionXmlId,
params.orderBy
);
const [col, row] = params.position || [0, 0];
model.dispatch("INSERT_ODOO_LIST", {
sheetId: params.sheetId || model.getters.getActiveSheetId(),
definition,
linesNumber: params.linesNumber || 10,
columns,
id: model.getters.getNextListId(),
col,
row,
});
}
/**
*
* @param {Object} params
* @param {string} [params.model]
* @param {Array<string>} [params.columns]
* @param {Object} [params.serverData]
* @param {function} [params.mockRPC]
* @param {number} [params.linesNumber]
* @param {[number, number]} [params.position]
* @param {string} [params.sheetId]
* @param {object} [params.modelConfig]
* @param {{name: string, asc: boolean}[]} [params.orderBy]
*
* @returns { Promise<{ model: OdooSpreadsheetModel, env: Object }>}
*/
export async function createSpreadsheetWithList(params = {}) {
const { model, env } = await createModelWithDataSource({
mockRPC: params.mockRPC,
serverData: params.serverData,
modelConfig: params.modelConfig,
});
insertListInSpreadsheet(model, {
columns: params.columns || ["foo", "bar", "date", "product_id"],
model: params.model || "partner",
linesNumber: params.linesNumber,
position: params.position,
sheetId: params.sheetId,
orderBy: params.orderBy,
});
await waitForDataLoaded(model);
return { model, env };
}

View file

@ -0,0 +1,96 @@
import { animationFrame } from "@odoo/hoot-mock";
import { Model } from "@odoo/o-spreadsheet";
import { OdooDataProvider } from "@spreadsheet/data_sources/odoo_data_provider";
import {
defineActions,
defineMenus,
getMockEnv,
makeMockEnv,
onRpc,
} from "@web/../tests/web_test_helpers";
import { setCellContent } from "./commands";
import { addRecordsFromServerData, addViewsFromServerData } from "./data";
/**
* @typedef {import("@spreadsheet/../tests/helpers/data").ServerData} ServerData
* @typedef {import("@spreadsheet/helpers/model").OdooSpreadsheetModel} OdooSpreadsheetModel
*/
export function setupDataSourceEvaluation(model) {
model.config.custom.odooDataProvider.addEventListener("data-source-updated", () => {
const sheetId = model.getters.getActiveSheetId();
model.dispatch("EVALUATE_CELLS", { sheetId });
});
}
/**
* Create a spreadsheet model with a mocked server environnement
*
* @param {object} params
* @param {object} [params.spreadsheetData] Spreadsheet data to import
* @param {object} [params.modelConfig]
* @param {ServerData} [params.serverData] Data to be injected in the mock server
* @param {function} [params.mockRPC] Mock rpc function
* @returns {Promise<{ model: OdooSpreadsheetModel, env: Object }>}
*/
export async function createModelWithDataSource(params = {}) {
const env = await makeSpreadsheetMockEnv(params);
const config = params.modelConfig;
/** @type any*/
const model = new Model(params.spreadsheetData, {
...config,
custom: {
env,
odooDataProvider: new OdooDataProvider(env),
...config?.custom,
},
});
env.model = model;
// if (params.serverData) {
// await addRecordsFromServerData(params.serverData);
// }
setupDataSourceEvaluation(model);
await animationFrame(); // initial async formulas loading
return { model, env };
}
/**
* Create a mocked server environnement
*
* @param {object} params
* @param {object} [params.spreadsheetData] Spreadsheet data to import
* @param {ServerData} [params.serverData] Data to be injected in the mock server
* @param {function} [params.mockRPC] Mock rpc function
* @returns {Promise<Object>}
*/
export async function makeSpreadsheetMockEnv(params = {}) {
if (params.mockRPC) {
// Note: calling onRpc with only a callback only works for routes such as orm routes that have a default listener
// For arbitrary rpc request (eg. /web/domain/validate) we need to call onRpc("/my/route", callback)
onRpc((args) => params.mockRPC(args.route, args)); // separate route from args for legacy (& forward ports) compatibility
}
if (params.serverData?.menus) {
defineMenus(Object.values(params.serverData.menus));
}
if (params.serverData?.actions) {
defineActions(Object.values(params.serverData.actions));
}
if (params.serverData?.models) {
addRecordsFromServerData(params.serverData);
}
if (params.serverData?.views) {
addViewsFromServerData(params.serverData);
}
const env = getMockEnv() || (await makeMockEnv());
return env;
}
export function createModelFromGrid(grid) {
const model = new Model();
for (const xc in grid) {
if (grid[xc] !== undefined) {
setCellContent(model, xc, grid[xc]);
}
}
return model;
}

View file

@ -0,0 +1,162 @@
import { PivotArchParser } from "@web/views/pivot/pivot_arch_parser";
import { OdooPivot } from "@spreadsheet/pivot/odoo_pivot";
import { getBasicPivotArch, getPyEnv } from "@spreadsheet/../tests/helpers/data";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
import { waitForDataLoaded } from "@spreadsheet/helpers/model";
import { helpers } from "@odoo/o-spreadsheet";
const { parseDimension, isDateOrDatetimeField } = helpers;
/**
* @typedef {import("@spreadsheet").OdooSpreadsheetModel} OdooSpreadsheetModel
* @typedef {import("@spreadsheet").Zone} Zone
*/
function addEmptyGranularity(dimensions, fields) {
return dimensions.map((dimension) => {
if (dimension.fieldName !== "id" && isDateOrDatetimeField(fields[dimension.fieldName])) {
return {
granularity: "month",
...dimension,
};
}
return dimension;
});
}
async function insertStaticPivot(model, pivotId, params) {
const ds = model.getters.getPivot(pivotId);
if (!(ds instanceof OdooPivot)) {
throw new Error("The pivot data source is not an OdooPivot");
}
const [col, row] = params.anchor || [0, 0];
await ds.load();
const { cols, rows, measures, fieldsType } = ds.getExpandedTableStructure().export();
const table = {
cols,
rows,
measures,
fieldsType,
};
model.dispatch("INSERT_PIVOT", {
pivotId,
sheetId: params.sheetId || model.getters.getActiveSheetId(),
col,
row,
table,
});
}
function insertDynamicPivot(model, pivotId, params) {
const pivotFormulaId = model.getters.getPivotFormulaId(pivotId);
const [col, row] = params.anchor || [0, 0];
model.dispatch("UPDATE_CELL", {
sheetId: params.sheetId || model.getters.getActiveSheetId(),
col,
row,
content: `=PIVOT(${pivotFormulaId})`,
});
}
/**
* @param {OdooSpreadsheetModel} model
* @param {string} pivotId
* @param {object} params
* @param {string} [params.arch]
* @param {string} [params.resModel]
* @param {object} [params.serverData]
* @param {string} [params.sheetId]
* @param {["static"|"dynamic"]} [params.pivotType]
* @param {[number, number]} [params.anchor]
*/
export async function insertPivotInSpreadsheet(model, pivotId, params) {
const archInfo = new PivotArchParser().parse(params.arch || getBasicPivotArch());
const resModel = params.resModel || "partner";
const pyEnv = getPyEnv();
const pivot = {
type: "ODOO",
domain: [],
context: {},
measures: archInfo.activeMeasures.map((measure) => ({
id: pyEnv[resModel]._fields[measure]?.aggregator
? `${measure}:${pyEnv[resModel]._fields[measure].aggregator}`
: measure,
fieldName: measure,
aggregator: pyEnv[resModel]._fields[measure]?.aggregator,
})),
model: resModel,
columns: addEmptyGranularity(
archInfo.colGroupBys.map(parseDimension),
pyEnv[resModel]._fields
),
rows: addEmptyGranularity(
archInfo.rowGroupBys.map(parseDimension),
pyEnv[resModel]._fields
),
name: "Partner Pivot",
};
model.dispatch("ADD_PIVOT", {
pivotId,
pivot,
});
if (params.pivotType === "static") {
await insertStaticPivot(model, pivotId, params);
} else {
insertDynamicPivot(model, pivotId, params);
}
}
/**
* @param {object} params
* @param {string} [params.arch]
* @param {object} [params.serverData]
* @param {function} [params.mockRPC]
* @param {"dynamic"|"static"} [params.pivotType]
* @returns {Promise<{ model: OdooSpreadsheetModel, env: object, pivotId: string}>}
*/
export async function createSpreadsheetWithPivot(params = {}) {
const { model, env } = await createModelWithDataSource({
mockRPC: params.mockRPC,
serverData: params.serverData,
});
const arch = params.arch || getBasicPivotArch();
const pivotId = "PIVOT#1";
await insertPivotInSpreadsheet(model, pivotId, { arch, pivotType: params.pivotType });
await waitForDataLoaded(model);
return { model, env, pivotId };
}
/**
* Return the zone that contains all the cells of the given data source
*
* TODO: Un-duplicate this once this or #50623 is merged
*
* @param model
* @param {"pivot" | "list"} dataSourceType
* @param {string} id
* @returns {Zone}
*/
export function getZoneOfInsertedDataSource(model, dataSourceType, id) {
const sheetId = model.getters.getActiveSheetId();
const cells = model.getters.getCells(sheetId);
const positions = Object.keys(cells).map(model.getters.getCellPosition);
let top = 0;
let left = 0;
let bottom = 0;
let right = 0;
for (const position of positions) {
const cellDataSourceId =
dataSourceType === "pivot"
? model.getters.getPivotIdFromPosition({ sheetId, ...position })
: model.getters.getListIdFromPosition({ sheetId, ...position });
if (id !== cellDataSourceId) {
continue;
}
top = Math.min(top, position.row);
left = Math.min(left, position.col);
bottom = Math.max(bottom, position.row);
right = Math.max(right, position.col);
}
return { top, bottom, left, right };
}

View file

@ -0,0 +1,13 @@
import { animationFrame } from "@odoo/hoot-mock";
import { createSpreadsheetWithPivot } from "@spreadsheet/../tests/helpers/pivot";
import { insertListInSpreadsheet } from "@spreadsheet/../tests/helpers/list";
export async function createSpreadsheetWithPivotAndList() {
const { model, env } = await createSpreadsheetWithPivot();
insertListInSpreadsheet(model, {
model: "partner",
columns: ["foo", "bar", "date", "product_id"],
});
await animationFrame();
return { env, model };
}

View file

@ -0,0 +1,53 @@
// @ts-check
import { stores } from "@odoo/o-spreadsheet";
import { createModelWithDataSource } from "@spreadsheet/../tests/helpers/model";
const { ModelStore, NotificationStore, DependencyContainer } = stores;
/**
* @template T
* @typedef {import("@odoo/o-spreadsheet").StoreConstructor<T>} StoreConstructor<T>
*/
/**
* @typedef {import("@spreadsheet").OdooSpreadsheetModel} OdooSpreadsheetModel
*/
/**
* @template T
* @param {StoreConstructor<T>} Store
* @param {any[]} args
* @return {Promise<{ store: T, container: InstanceType<DependencyContainer>, model: OdooSpreadsheetModel }>}
*/
export async function makeStore(Store, ...args) {
const { model } = await createModelWithDataSource();
return makeStoreWithModel(model, Store, ...args);
}
/**
* @template T
* @param {import("@odoo/o-spreadsheet").Model} model
* @param {StoreConstructor<T>} Store
* @param {any[]} args
* @return {{ store: T, container: InstanceType<DependencyContainer>, model: OdooSpreadsheetModel }}
*/
export function makeStoreWithModel(model, Store, ...args) {
const container = new DependencyContainer();
container.inject(ModelStore, model);
container.inject(NotificationStore, makeTestNotificationStore());
return {
store: container.instantiate(Store, ...args),
container,
// @ts-ignore
model: container.get(ModelStore),
};
}
function makeTestNotificationStore() {
return {
notifyUser: () => {},
raiseError: () => {},
askConfirmation: () => {},
};
}

View file

@ -0,0 +1,77 @@
import { Model, Spreadsheet } from "@odoo/o-spreadsheet";
import { loadBundle } from "@web/core/assets";
import { getFixture } from "@odoo/hoot";
import { animationFrame } from "@odoo/hoot-mock";
import { Component, xml } from "@odoo/owl";
import { useSpreadsheetNotificationStore } from "@spreadsheet/hooks";
import { PublicReadonlySpreadsheet } from "@spreadsheet/public_readonly_app/public_readonly";
import { mountWithCleanup } from "@web/../tests/web_test_helpers";
class Parent extends Component {
static template = xml`<Spreadsheet model="props.model"/>`;
static components = { Spreadsheet };
static props = { model: Model };
setup() {
useSpreadsheetNotificationStore();
}
}
/**
* Mount o-spreadsheet component with the given spreadsheet model
* @param {Model} model
* @returns {Promise<HTMLElement>}
*/
export async function mountSpreadsheet(model) {
// const serviceRegistry = registry.category("services");
// serviceRegistry.add("dialog", makeFakeDialogService(), { force: true });
// serviceRegistry.add("notification", makeFakeNotificationService(), { force: true });
await loadBundle("web.chartjs_lib");
mountWithCleanup(Parent, {
props: {
model,
},
env: model.config.custom.env,
noMainContainer: true,
});
await animationFrame();
return getFixture();
}
/**
* Mount public spreadsheet component with the given data
* @returns {Promise<HTMLElement>}
*/
export async function mountPublicSpreadsheet(dataUrl, mode, downloadExcelUrl = "") {
mountWithCleanup(PublicReadonlySpreadsheet, {
props: {
dataUrl,
downloadExcelUrl,
mode,
},
noMainContainer: true,
});
await animationFrame();
return getFixture();
}
export async function doMenuAction(registry, path, env) {
await getActionMenu(registry, path, env).execute(env);
}
export function getActionMenu(registry, _path, env) {
const path = [..._path];
let items = registry.getMenuItems();
while (items.length && path.length) {
const id = path.shift();
const item = items.find((item) => item.id === id);
if (!item) {
throw new Error(`Menu item ${id} not found`);
}
if (path.length === 0) {
return item;
}
items = item.children(env);
}
throw new Error(`Menu item not found`);
}

View file

@ -0,0 +1,7 @@
import { helpers } from "@odoo/o-spreadsheet";
const { toUnboundedZone } = helpers;
export function toRangeData(sheetId, xc) {
return { _zone: toUnboundedZone(xc), _sheetId: sheetId };
}