In Odoo, a view is a structured representation that defines how records are displayed and interacted with in the user interface, specified using XML within the Odoo framework.
Odoo offers various types of views, each providing a unique visualization, such as forms, lists, kanbans, and more. This blog will cover the steps for adding a new view type in Odoo 18.
To start, let’s look at the process for creating a new view type, specifically a “Grid” view. Begin by setting up a basic add-on framework. Next, add a new view selection in the ir.ui.view model to establish the server-side declaration for the "Grid" view type.
class View(models.Model):
"""
Extends the base 'ir.ui.view' model to include a new type of view
called 'grid'.
"""
_inherit = 'ir.ui.view'
type = fields.Selection(selection_add=[('grid', "Grid")])
def _get_view_info(self):
return {'grid': {'icon': 'fa fa-th'}} | super()._get_view_info()
In the same way, adding this view type to the ir.actions.act_window.view model allows us to access and open this specific view.
class IrActionsActWindowView(models.Model):
"""
Extends the base 'ir.actions.act_window.view' model to include
a new view mode called 'grid'.
"""
_inherit = 'ir.actions.act_window.view'
view_mode = fields.Selection(selection_add=[('grid', "Grid")],
ondelete={'grid': 'cascade'})
In addition to creating a new view type, it’s important to include additional JavaScript files.
1. Create a controller file.
A controller's primary role is to manage interactions between various elements within a view, such as the Renderer, Model, Layout components, and more.
/** @odoo-module **/
import { Component, useState } from "@odoo/owl";
import { Layout } from "@web/search/layout";
import { useModelWithSampleData } from "@web/model/model";
import { CogMenu } from "@web/search/cog_menu/cog_menu";
import { SearchBar } from "@web/search/search_bar/search_bar";
import { ViewButton } from "@web/views/view_button/view_button";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { extractFieldsFromArchInfo } from "@web/model/relational_model/utils";
import { session } from "@web/session";
import { useBus, useService } from "@web/core/utils/hooks";
import { useSearchBarToggler } from "@web/search/search_bar/search_bar_toggler";
import { useSetupView } from "@web/views/view_hook";
export class GridController extends Component {
static components = {
Layout,
Dropdown,
DropdownItem,
ViewButton,
CogMenu,
SearchBar,
};
async setup() {
this.viewService = useService("view");
this.dataSearch = []
this.ui = useService("ui");
useBus(this.ui.bus, "resize", this.render);
this.archInfo = this.props.archInfo;
const fields = this.props.fields;
this.model = useState(useModelWithSampleData(this.props.Model, this.modelParams));
this.searchBarToggler = useSearchBarToggler();
useSetupView({
rootRef: this.rootRef,
beforeLeave: async () => {
return this.model.root.leaveEditMode();
},
beforeUnload: async (ev) => {
const editedRecord = this.model.root.editedRecord;
if (editedRecord) {
const isValid = await editedRecord.urgentSave();
if (!isValid) {
ev.preventDefault();
ev.returnValue = "Unsaved changes";
}
}
},
getOrderBy: () => {
return this.model.root.orderBy;
},
});
}
get modelParams() {
const { defaultGroupBy, rawExpand } = this.archInfo;
const { activeFields, fields } = extractFieldsFromArchInfo(
this.archInfo,
this.props.fields
);
const groupByInfo = {};
for (const fieldName in this.archInfo.groupBy.fields) {
const fieldNodes = this.archInfo.groupBy.fields[fieldName].fieldNodes;
const fields = this.archInfo.groupBy.fields[fieldName].fields;
groupByInfo[fieldName] = extractFieldsFromArchInfo({ fieldNodes }, fields);
}
const modelConfig = this.props.state?.modelState?.config || {
resModel: this.props.resModel,
fields,
activeFields,
openGroupsByDefault: true,
};
return {
config: modelConfig,
state: this.props.state?.modelState,
groupByInfo,
limit: null,
countLimit: this.archInfo.countLimit,
defaultOrderBy: this.archInfo.defaultOrder,
defaultGroupBy: this.props.searchMenuTypes.includes("groupBy") ? defaultGroupBy : false,
groupsLimit: this.archInfo.groupsLimit,
multiEdit: this.archInfo.multiEdit,
activeIdsLimit: session.active_ids_limit,
};
}
}
GridController.template = "GridView";
Include the template for the controller.
<?xml version="1.0" encoding="UTF-8" ?>
<template id="template" xml:space="preserve">
<t t-name="GridView">
<!-- The root element of the view component -->
<div t-ref="root" t-att-class="props.className">
<!-- Layout component for organizing the view -->
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''"
display="props.display">
<!-- Slot for additional actions in the control panel -->
<t t-set-slot="control-panel-additional-actions">
<CogMenu/>
</t>
<!-- Slot for layout buttons -->
<t t-set-slot="layout-buttons"/>
<!-- Slot for layout actions -->
<t t-set-slot="layout-actions">
<!-- SearchBar component rendered if showSearchBar is true -->
<SearchBar t-if="searchBarToggler.state.showSearchBar"/>
</t>
<!-- Slot for additional navigation actions in the control panel -->
<t t-set-slot="control-panel-navigation-additional">
<!-- Render the component specified by searchBarToggler.component -->
<t t-component="searchBarToggler.component"
t-props="searchBarToggler.props"/>
</t>
<!-- Render the main content using the Renderer component -->
<t t-component="props.Renderer" t-props="renderProps" list="model.root"/>
</Layout>
</div>
</t>
</template>
2. Create a Renderer file
A renderer’s main role is to create a visual representation of data by rendering the view, including the records it contains.
/** @odoo-module **/
import { Component } from "@odoo/owl";
import { View } from "@web/views/view";
import { Field } from "@web/views/fields/field";
import { Record } from "@web/model/record";
import { ViewScaleSelector } from "@web/views/view_components/view_scale_selector";
export class GridRenderer extends Component {
async setup() {
}
}
GridRenderer.template = "grid_view.GridRenderer";
GridRenderer.components = {
View,
Field,
Record,
ViewScaleSelector,
};
Include the template for the renderer.
<templates xml:space="preserve">
<t t-name="grid_view.GridRenderer" owl="1">
<div class="o_content grid-contents">
<div class="o_grid_renderer flex-grow-1">
<div class="cy_grid_renderer flex-grow-1">
<div class="o_grid_view o_view_controller o_action o_action_delegate_scroll">
<div class="table-head-div row d-flex align-items-center py-2">
<div class="col-md-3 cy-main-sectionhead timesheet-head-name">
Timesheet
</div>
</div>
</div>
</div>
<div class="timesheet-grid-table">
<table class="table table-sm table-dark">
<thead>
<tr>
<th scope="col">#</th>
<th scope="col">First Name</th>
<th scope="col">Last Name</th>
<th scope="col">Time</th>
</tr>
</thead>
<tbody>
<tr>
<th scope="row">1</th>
<td>Mark</td>
<td>Otto</td>
<td>11:00</td>
</tr>
<tr>
<th scope="row">2</th>
<td>Jacob</td>
<td>Thornton</td>
<td>04:20</td>
</tr>
<tr>
<th scope="row">3</th>
<td colspan="2">Larry the Bird</td>
<td>23:23</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</t>
</templates>
3. Create an Arch Parser.
The arch parser's role is to interpret the arch view, allowing the view to access the provided information.
/** @odoo-module **/
import { visitXML } from "@web/core/utils/xml";
import { Field } from "@web/views/fields/field";
import { addFieldDependencies,archParseBoolean, processButton } from "@web/views/utils"
import { Widget } from "@web/views/widgets/widget";
export class GroupListArchParser {
parse(arch, models, modelName, jsClass) {
const fieldNodes = {};
const fieldNextIds = {};
const buttons = [];
let buttonId = 0;
visitXML(arch, (node) => {
if (node.tagName === "button") {
buttons.push({
...processButton(node),
id: buttonId++,
});
return false;
} else if (node.tagName === "field") {
const fieldInfo = Field.parseFieldNode(node, models, modelName, "list", jsClass);
if (!(fieldInfo.name in fieldNextIds)) {
fieldNextIds[fieldInfo.name] = 0;
}
const fieldId = `${fieldInfo.name}_${fieldNextIds[fieldInfo.name]++}`;
fieldNodes[fieldId] = fieldInfo;
node.setAttribute("field_id", fieldId);
return false;
}
});
return { fieldNodes, buttons };
}
}
export class GridArchParser{
/**
* Check if a column is visible based on the column invisible modifier.
* @param {boolean} columnInvisibleModifier - The column invisible modifier.
* @returns {boolean} - True if the column is visible, false otherwise.
*/
isColumnVisible(columnInvisibleModifier) {
return columnInvisibleModifier !== true;
}
/**
* Parse a field node and return the parsed field information.
* @param {Node} node - The field node to parse.
* @param {Object} models - The models information.
* @param {string} modelName - The name of the model.
* @returns {Object} - The parsed field information.
*/
parseFieldNode(node, models, modelName) {
return Field.parseFieldNode(node, models, modelName, "grid");
}
parseWidgetNode(node, models, modelName) {
return Widget.parseWidgetNode(node);
}
processButton(node) {
return processButton(node);
}
/**
* Parse the grid view architecture.
* @param {string} arch - The XML architecture to parse.
* @param {Object} models - The models information.
* @param {string} modelName - The name of the model.
* @returns {Object} - The parsed grid view architecture.
*/
parse(xmlDoc, models, modelName) {
const fieldNodes = {};
const widgetNodes = {};
let widgetNextId = 0;
const columns = [];
const fields = models[modelName];
let buttonId = 0;
const groupBy = {
buttons: {},
fields: {},
};
let headerButtons = [];
const creates = [];
const groupListArchParser = new GroupListArchParser();
let buttonGroup;
let handleField = null;
const treeAttr = {
limit: 200,
};
let nextId = 0;
const activeFields = {};
const fieldNextIds = {};
visitXML(xmlDoc, (node) => {
if (node.tagName !== "button") {
buttonGroup = undefined;
}
if (node.tagName === "button") {
const button = {
...this.processButton(node),
defaultRank: "btn-link",
type: "button",
id: buttonId++,
};
if (buttonGroup) {
buttonGroup.buttons.push(button);
buttonGroup.column_invisible = combineModifiers(buttonGroup.column_invisible, node.getAttribute('column_invisible'), "AND");
} else {
buttonGroup = {
id: `column_${nextId++}`,
type: "button_group",
buttons: [button],
hasLabel: false,
column_invisible: node.getAttribute('column_invisible'),
};
columns.push(buttonGroup);
}
} else if (node.tagName === "field") {
const fieldInfo = this.parseFieldNode(node, models, modelName);
if (!(fieldInfo.name in fieldNextIds)) {
fieldNextIds[fieldInfo.name] = 0;
}
const fieldId = `${fieldInfo.name}_${fieldNextIds[fieldInfo.name]++}`;
fieldNodes[fieldId] = fieldInfo;
node.setAttribute("field_id", fieldId);
if (fieldInfo.isHandle) {
handleField = fieldInfo.name;
}
const label = fieldInfo.field.label;
columns.push({
...fieldInfo,
id: `column_${nextId++}`,
className: node.getAttribute("class"), // for oe_edit_only and oe_read_only
optional: node.getAttribute("optional") || false,
type: "field",
hasLabel: !(
archParseBoolean(fieldInfo.attrs.nolabel) || fieldInfo.field.noLabel
),
label: (fieldInfo.widget && label && label.toString()) || fieldInfo.string,
});
return false;
} else if (node.tagName === "widget") {
const widgetInfo = this.parseWidgetNode(node);
const widgetId = `widget_${++widgetNextId}`;
widgetNodes[widgetId] = widgetInfo;
node.setAttribute("widget_id", widgetId);
addFieldDependencies(widgetInfo, activeFields, models[modelName]);
const widgetProps = {
name: widgetInfo.name,
// FIXME: this is dumb, we encode it into a weird object so that the widget
// can decode it later...
node: encodeObjectForTemplate({ attrs: widgetInfo.attrs }).slice(1, -1),
className: node.getAttribute("class") || "",
};
columns.push({
...widgetInfo,
props: widgetProps,
id: `column_${nextId++}`,
type: "widget",
});
} else if (node.tagName === "groupby" && node.getAttribute("name")) {
const fieldName = node.getAttribute("name");
const xmlSerializer = new XMLSerializer();
const groupByArch = xmlSerializer.serializeToString(node);
const coModelName = fields[fieldName].relation;
const groupByArchInfo = groupListArchParser.parse(groupByArch, models, coModelName);
groupBy.buttons[fieldName] = groupByArchInfo.buttons;
groupBy.fields[fieldName] = {
activeFields: groupByArchInfo.activeFields,
fieldNodes: groupByArchInfo.fieldNodes,
fields: models[coModelName],
};
return false;
} else if (node.tagName === "header") {
// AAB: not sure we need to handle invisible="1" button as the usecase seems way
// less relevant than for fields (so for buttons, relying on the modifiers logic
// that applies later on could be enough, even if the value is always true)
headerButtons = [...node.children]
.map((node) => ({
...processButton(node),
type: "button",
id: buttonId++,
}))
.filter((button) => button.modifiers.invisible !== true);
return false;
} else if (node.tagName === "control") {
for (const childNode of node.children) {
if (childNode.tagName === "button") {
creates.push({
type: "button",
...processButton(childNode),
});
} else if (childNode.tagName === "create") {
creates.push({
type: "create",
context: childNode.getAttribute("context"),
string: childNode.getAttribute("string"),
});
}
}
return false;
}
});
return {
creates,
handleField,
headerButtons,
fieldNodes,
widgetNodes,
activeFields,
columns,
groupBy,
xmlDoc,
...treeAttr,
};
}
}
4. Create the view
By integrating all components, and then proceed to officially register it in the views registry.
/** @odoo-module **/
import { RelationalModel } from "@web/model/relational_model/relational_model";
import { registry } from "@web/core/registry";
import {GridRenderer} from "./grid_renderer";
import {GridController} from "./grid_controller";
import {GridArchParser} from "./grid_arch_parser";
export const gridView = {
type: "grid",
display_name: "Grid",
multiRecord: true,
Controller: GridController,
Renderer: GridRenderer,
ArchParser: GridArchParser,
Model: RelationalModel,
/**
* Function that returns the props for the grid view.
* @param {object} genericProps - Generic properties of the view.
* @param {object} view - The view object.
* @returns {object} Props for the grid view.
*/
props: (genericProps, view) => {
const {
ArchParser,
Model,
Renderer
} = view;
const {
arch,
relatedModels,
resModel
} = genericProps;
const archInfo = new ArchParser().parse(arch, relatedModels, resModel);
return {
...genericProps,
archInfo,
Model: view.Model,
Renderer,
};
}
};
// Register the grid view configuration
registry.category("views").add("grid", gridView);
Include all these JavaScript files and template XML files within an asset bundle in the manifest.
'assets': {
'web.assets_backend': [
'grid_view/static/src/js/**',
'grid_view/static/src/xml/**',
],
},
Lastly, create an XML file to add the new view type to the necessary model.
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_my_grid" model="ir.ui.view">
<field name="name">My Grid View</field>
<field name="model">your.model.name</field>
<field name="arch" type="xml">
<grid string="My Grid View">
<!-- Add your columns here -->
</grid>
</field>
</record>
<record id="module_name.record_id" model="ir.actions.act_window">
<field name="view_mode">grid,form,kanban,tree</field>
</record>
<record id="record_id_view_grid" model="ir.actions.act_window.view">
<field name="sequence" eval="1"/>
<field name="view_mode">grid</field>
<field name="view_id" ref="custom_grid_module_name.view_my_grid"/>
<field name="act_window_id" ref="module_name.record_id"/>
</record>
</odoo>
After finishing these steps, proceed to open your new view type by installing your custom module. You will see the new view type and the content you included in the renderer file within the new view of your target model.
In summary, this blog outlines the steps for adding a new view type in Odoo 18, enabling developers to easily customize and enhance the platform's interface.
To read more about How to Create a New View Type in Odoo 17, refer to our blog How to Create a New View Type in Odoo 17.