跳到主要内容

Views绑定动作Actions的源码溯源

本文中示例均为代码片段,完整代码见底部。

本章节以list视图为例,查看其绑定动作Actions的源码。

ListView

首先来看web.ListView模板,关于动作菜单的部分,模板中在solt="control-panel-bottom-left"中使用了ActionMenus组件

关于动作菜单的渲染均在ActionMenus组件中完成,所有items便是通过props传入。可以看到这里props的items来源于getActionMenuItems()方法。

list_controller.xml
<t t-name="web.ListView" owl="1">
<!-- ... -->
<t t-set-slot="control-panel-bottom-left">
<t t-if="props.info.actionMenus and model.root.selection.length">
<ActionMenus
getActiveIds="() => model.root.selection.map((r) => r.resId)"
context="props.context"
domain="props.domain"
items="getActionMenuItems()"
isDomainSelected="model.root.isDomainSelected"
resModel="model.root.resModel"
onActionExecuted="() => model.load()"/>
</t>
</t>
<!-- ... -->
</t>

ActionMenus的items主要分为以下类别:

  • printItems: 取值于props.items.print.
  • actionItems:
    • callbackActions: 取值于props.items.other.
    • formattedActions: 取值于props.items.action.
    • registryActions: 取值于registry.category("action_menus").getAll().

getActionMenuItems()中可以看到,items主要由props.info.actionMenusotherActionItems组成。

getActionMenuItems()
list_controller.js
export class ListController extends Component{
// ...
getActionMenuItems() {
const isM2MGrouped = this.model.root.isM2MGrouped;
const otherActionItems = [];
if (this.isExportEnable) {
otherActionItems.push({
key: "export",
description: this.env._t("Export"),
callback: () => this.onExportData(),
});
}
if (this.archiveEnabled && !isM2MGrouped) {
otherActionItems.push({
key: "archive",
description: this.env._t("Archive"),
callback: () => {
const dialogProps = {
body: this.env._t(
"Are you sure that you want to archive all the selected records?"
),
confirmLabel: this.env._t("Archive"),
confirm: () => {
this.toggleArchiveState(true);
},
cancel: () => {},
};
this.dialogService.add(ConfirmationDialog, dialogProps);
},
});
otherActionItems.push({
key: "unarchive",
description: this.env._t("Unarchive"),
callback: () => this.toggleArchiveState(false),
});
}
if (this.activeActions.delete && !isM2MGrouped) {
otherActionItems.push({
key: "delete",
description: this.env._t("Delete"),
callback: () => this.onDeleteSelectedRecords(),
});
}
return Object.assign({}, this.props.info.actionMenus, { other: otherActionItems });
}
}

在当前function中,可以看到关于exportarchivedelete的相关逻辑,这分别对应着数据的导出、归档、删除。

其余actionItems则来源于this.props.info.actionMenus。 list_view的props信息就要向上追溯到View组件,其props信息来源于View组件的loadView().

loadView()
view.js
export class View extends Component {
// ...
async loadView(props) {
// determine view type
let descr = viewRegistry.get(props.type);
const type = descr.type;

// determine views for which descriptions should be obtained
let { viewId, searchViewId } = props;

const views = deepCopy(props.views || this.env.config.views);
const view = views.find((v) => v[1] === type) || [];
if (view.length) {
view[0] = viewId !== undefined ? viewId : view[0];
viewId = view[0];
} else {
view.push(viewId || false, type);
views.push(view); // viewId will remain undefined if not specified and loadView=false
}

const searchView = views.find((v) => v[1] === "search");
if (searchView) {
searchView[0] = searchViewId !== undefined ? searchViewId : searchView[0];
searchViewId = searchView[0];
} else if (searchViewId !== undefined) {
views.push([searchViewId, "search"]);
}
// searchViewId will remains undefined if loadSearchView=false

// prepare view description
const { context, resModel, loadActionMenus, loadIrFilters } = props;
let {
arch,
fields,
relatedModels,
searchViewArch,
searchViewFields,
irFilters,
actionMenus,
} = props;

const loadView = !arch || (!actionMenus && loadActionMenus);
const loadSearchView =
(searchViewId !== undefined && !searchViewArch) || (!irFilters && loadIrFilters);

let viewDescription = { viewId, resModel, type };
let searchViewDescription;
if (loadView || loadSearchView) {
// view description (or search view description if required) is incomplete
// a loadViews is done to complete the missing information
const result = await this.viewService.loadViews(
{ context, resModel, views },
{ actionId: this.env.config.actionId, loadActionMenus, loadIrFilters }
);
// Note: if props.views is different from views, the cached descriptions
// will certainly not be reused! (but for the standard flow this will work as
// before)
viewDescription = result.views[type];
searchViewDescription = result.views.search;
if (loadSearchView) {
searchViewId = searchViewId || searchViewDescription.id;
if (!searchViewArch) {
searchViewArch = searchViewDescription.arch;
searchViewFields = result.fields;
}
if (!irFilters) {
irFilters = searchViewDescription.irFilters;
}
}
this.env.config.views = views;
fields = fields || markRaw(result.fields);
relatedModels = relatedModels || markRaw(result.relatedModels);
}

if (!arch) {
arch = viewDescription.arch;
}
if (!actionMenus) {
actionMenus = viewDescription.actionMenus;
}

const parser = new DOMParser();
const xml = parser.parseFromString(arch, "text/xml");
const rootNode = xml.documentElement;

let subType = rootNode.getAttribute("js_class");
const bannerRoute = rootNode.getAttribute("banner_route");
const sample = rootNode.getAttribute("sample");

// determine ViewClass to instantiate (if not already done)
if (subType) {
if (viewRegistry.contains(subType)) {
descr = viewRegistry.get(subType);
} else {
subType = null;
}
}

Object.assign(this.env.config, {
viewArch: rootNode,
viewId: viewDescription.id,
viewType: type,
viewSubType: subType,
bannerRoute,
noBreadcrumbs: props.noBreadcrumbs,
...extractLayoutComponents(descr),
});
const info = {
actionMenus,
mode: props.display.mode,
irFilters,
searchViewArch,
searchViewFields,
searchViewId,
};

// prepare the view props
const viewProps = {
info,
arch,
fields,
relatedModels,
resModel,
useSampleModel: false,
className: `${props.className} o_view_controller o_${this.env.config.viewType}_view`,
};
if (viewDescription.custom_view_id) {
// for dashboard
viewProps.info.customViewId = viewDescription.custom_view_id;
}
if (props.globalState) {
viewProps.globalState = props.globalState;
}

if ("useSampleModel" in props) {
viewProps.useSampleModel = props.useSampleModel;
} else if (sample) {
viewProps.useSampleModel = Boolean(evaluateExpr(sample));
}

for (const key in props) {
if (!STANDARD_PROPS.includes(key)) {
viewProps[key] = props[key];
}
}

const { noContentHelp } = props;
if (noContentHelp) {
viewProps.info.noContentHelp = noContentHelp;
}

const searchMenuTypes =
props.searchMenuTypes || descr.searchMenuTypes || this.constructor.searchMenuTypes;
viewProps.searchMenuTypes = searchMenuTypes;

const finalProps = descr.props ? descr.props(viewProps, descr, this.env.config) : viewProps;
// prepare the WithSearch component props
this.Controller = descr.Controller;
this.componentProps = finalProps;
this.withSearchProps = {
...toRaw(props),
hideCustomGroupBy: props.hideCustomGroupBy || descr.hideCustomGroupBy,
searchMenuTypes,
SearchModel: descr.SearchModel,
};

if (searchViewId !== undefined) {
this.withSearchProps.searchViewId = searchViewId;
}
if (searchViewArch) {
this.withSearchProps.searchViewArch = searchViewArch;
this.withSearchProps.searchViewFields = searchViewFields;
}
if (irFilters) {
this.withSearchProps.irFilters = irFilters;
}

if (descr.display) {
// FIXME: there's something inelegant here: display might come from
// the View's defaultProps, in which case, modifying it in place
// would have unwanted effects.
const viewDisplay = deepCopy(descr.display);
const display = { ...this.withSearchProps.display };
for (const key in viewDisplay) {
if (typeof display[key] === "object") {
Object.assign(display[key], viewDisplay[key]);
} else if (!(key in display) || display[key]) {
display[key] = viewDisplay[key];
}
}
this.withSearchProps.display = display;
}

for (const key in this.withSearchProps) {
if (!(key in WithSearch.props)) {
delete this.withSearchProps[key];
}
}
}
}

props对应函数中的this.componentProps,我们详细查看代码就很容易发现actionMenus来源于this.viewService.loadViews(),下面是信息流向。

this.viewService.loadViews()

viewDescription.actionMenus

info.actionMenus

viewProps.info

this.componentProps

viewService

下面来查看viewService.loadViews()的实现。

查看loadView()可以发现,actionMenus的信息又来源于Model.get_views.

viewDescription.actionMenus = toolbar;

toolbar = views[viewType].toolbar;

views = orm.call(resModel, "get_views", [], { context, views, options: loadViewsOptions })
viewService.loadViews()
view_service.js
export const viewService = {
dependencies: ["orm"],
start(env, { orm }) {
// ...

async function loadViews(params, options = {}) {
const loadViewsOptions = {
action_id: options.actionId || false,
load_filters: options.loadIrFilters || false,
toolbar: options.loadActionMenus || false,
};
if (env.isSmall) {
loadViewsOptions.mobile = true;
}
const { context, resModel, views } = params;
const filteredContext = Object.fromEntries(
Object.entries(context || {}).filter((k, v) => !String(k).startsWith("default_"))
);
const key = JSON.stringify([resModel, views, filteredContext, loadViewsOptions]);
if (!cache[key]) {
cache[key] = orm
.call(resModel, "get_views", [], { context, views, options: loadViewsOptions })
.then((result) => {
const { models, views } = result;
const modelsCopy = deepCopy(models); // for legacy views
const viewDescriptions = {
__legacy__: generateLegacyLoadViewsResult(resModel, views, modelsCopy),
fields: models[resModel],
relatedModels: models,
views: {},
};
for (const [resModel, fields] of Object.entries(modelsCopy)) {
const key = JSON.stringify(["fields", resModel, undefined, undefined]);
cache[key] = Promise.resolve(fields);
}
for (const viewType in views) {
const { arch, toolbar, id, filters, custom_view_id } = views[viewType];
const viewDescription = { arch, id, custom_view_id };
if (toolbar) {
viewDescription.actionMenus = toolbar;
}
if (filters) {
viewDescription.irFilters = filters;
}
viewDescriptions.views[viewType] = viewDescription;
}
return viewDescriptions;
})
.catch((error) => {
delete cache[key];
return Promise.reject(error);
});
}
return cache[key];
}
return { loadViews, loadFields };
},
};

base Model

以下是toolbar的数据流向,可以找到最终来源为self.env['ir.actions.actions'].get_bindings(self._name)

result['views'][view_type]['toolbar'].setdefault(key, []).append(action)

action = bindings.get(action_type, [])

for action_type, key in (('report', 'print'), ('action', 'action'))

bindings = self.env['ir.actions.actions'].get_bindings(self._name)
get_views()
ir_ui_view.py
class Model(models.AbstractModel):
_inherit = 'base'

# ...

@api.model
def get_views(self, views, options=None):
""" Returns the fields_views of given views, along with the fields of
the current model, and optionally its filters for the given action.

:param views: list of [view_id, view_type]
:param dict options: a dict optional boolean flags, set to enable:

``toolbar``
includes contextual actions when loading fields_views
``load_filters``
returns the model's filters
``action_id``
id of the action to get the filters, otherwise loads the global
filters or the model

:return: dictionary with fields_views, fields and optionally filters
"""
options = options or {}
result = {}

result['views'] = {
v_type: self.get_view(
v_id, v_type if v_type != 'list' else 'tree',
**options
)
for [v_id, v_type] in views
}

models = {}
for view in result['views'].values():
for model, model_fields in view.pop('models').items():
models.setdefault(model, set()).update(model_fields)

result['models'] = {}

for model, model_fields in models.items():
result['models'][model] = self.env[model].fields_get(
allfields=model_fields, attributes=self._get_view_field_attributes()
)

# Add related action information if asked
if options.get('toolbar'):
for view in result['views'].values():
view['toolbar'] = {}

bindings = self.env['ir.actions.actions'].get_bindings(self._name)
for action_type, key in (('report', 'print'), ('action', 'action')):
for action in bindings.get(action_type, []):
view_types = (
action['binding_view_types'].split(',')
if action.get('binding_view_types')
else result['views'].keys()
)
for view_type in view_types:
view_type = view_type if view_type != 'tree' else 'list'
if view_type in result['views']:
result['views'][view_type]['toolbar'].setdefault(key, []).append(action)

if options.get('load_filters') and 'search' in result['views']:
result['views']['search']['filters'] = self.env['ir.filters'].get_filters(
self._name, options.get('action_id')
)

return result

完整代码

list_controller.xml
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">

<t t-name="web.ListView" owl="1">
<div t-att-class="className" t-ref="root">
<Layout className="model.useSampleModel ? 'o_view_sample_data' : ''" display="display">
<t t-set-slot="layout-buttons">
<t t-if="env.isSmall and nbSelected">
<t t-call="web.ListView.Selection" />
</t>
<t t-else="">
<div class="o_cp_buttons" role="toolbar" aria-label="Control panel buttons" t-ref="buttons">
<t t-call="{{ props.buttonTemplate }}"/>
</div>
</t>
</t>
<t t-set-slot="control-panel-bottom-left">
<t t-if="props.info.actionMenus and model.root.selection.length">
<ActionMenus
getActiveIds="() => model.root.selection.map((r) => r.resId)"
context="props.context"
domain="props.domain"
items="getActionMenuItems()"
isDomainSelected="model.root.isDomainSelected"
resModel="model.root.resModel"
onActionExecuted="() => model.load()"/>
</t>
</t>
<t t-component="props.Renderer" list="model.root" activeActions="activeActions" archInfo="archInfo" allowSelectors="props.allowSelectors" editable="editable" openRecord.bind="openRecord" noContentHelp="props.info.noContentHelp" onAdd.bind="createRecord" onOptionalFieldsChanged.bind="onOptionalFieldsChanged"/>
</Layout>
</div>
</t>

<t t-name="web.ListView.Buttons" owl="1">
<div class="o_list_buttons d-flex" role="toolbar" aria-label="Main actions">
<t t-if="props.showButtons">
<t t-if="model.root.editedRecord">
<button type="button" class="btn btn-primary o_list_button_save" data-hotkey="s" t-on-click.stop="onClickSave">
Save
</button>
<button type="button" class="btn btn-secondary o_list_button_discard" data-hotkey="j" t-on-click="onClickDiscard" t-on-mousedown="onMouseDownDiscard">
Discard
</button>
</t>
<t t-elif="activeActions.create">
<button type="button" class="btn btn-primary o_list_button_add" data-hotkey="c" t-on-click="onClickCreate" data-bounce-button="">
New
</button>
</t>
<t t-if="nbTotal and !nbSelected and activeActions.exportXlsx and isExportEnable and !env.isSmall">
<button type="button" class="btn btn-secondary fa fa-download o_list_export_xlsx" data-tooltip="Export All" aria-label="Export All" t-on-click="onDirectExportData"/>
</t>
</t>
<t t-if="nbSelected">
<t t-foreach="archInfo.headerButtons" t-as="button" t-key="button.id">
<ListViewHeaderButton
list="model.root"
clickParams="button.clickParams"
defaultRank="button.defaultRank"
domain="props.domain"
icon="button.icon"
string="button.string"
title="button.title"
/>
</t>
<t t-if="!env.isSmall">
<t t-call="web.ListView.Selection"/>
</t>
</t>
</div>
</t>

<t t-name="web.ListView.Selection" owl="1">
<div class="o_list_selection_box alert alert-info d-inline-flex align-items-center ps-0 px-lg-2 py-0 mb-0 ms-0 ms-md-2" role="alert">
<t t-if="env.isSmall">
<button class="btn btn-link py-0 o_discard_selection" t-on-click="discardSelection">
<span class="fa-2x">&#215;</span>
</button>
</t>
<span t-if="isDomainSelected">All <span class="font-monospace" t-esc="nbTotal"/> selected</span>
<t t-else="">
<span class="font-monospace me-1" t-esc="nbSelected"/> selected
<a t-if="isPageSelected and nbTotal > nbSelected" href="#" class="o_list_select_domain ms-2 btn btn-sm btn-info px-2 py-1 border-0 fw-normal" t-on-click="onSelectDomain">
<i class="fa fa-arrow-right"/> Select all <span class="font-monospace" t-esc="nbTotal"/>
</a>
</t>
</div>
</t>

</templates>