[IMP] web: introduce MultiRecordSelector and RecordSelector
This commit introduces two new components, MultiRecordSelector and RecordSelector, which are used to select multiple records and a single record respectively. The MultiRecordSelector component is used to display the selected records with a tag-like style. It also allows to add new records by typing/selecting a valut in the input field. The RecordSelector component is used to display the selected record and allows to select a new record by typing/selecting a value in the input field. These components are created from the DomainSelectorAutocomplete and DomainSelectorSingleAutocomplete components, which are now an extension of the MultiRecordSelector and RecordSelector components respectively. This commit also add tests for these two components. Part of task-id 3433922 closes odoo/odoo#137650 Related: odoo/enterprise#48423 Signed-off-by: Aaron Bohy (aab) <aab@odoo.com>
This commit is contained in:
@@ -1,150 +1,14 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { MultiRecordSelector } from "../record_selectors/multi_record_selector";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { formatAST, toPyValue } from "@web/core/py_js/py_utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||
import { Expression } from "@web/core/domain_tree";
|
||||
import { RecordSelector } from "../record_selectors/record_selector";
|
||||
|
||||
const SEARCH_LIMIT = 7;
|
||||
const SEARCH_MORE_LIMIT = 320;
|
||||
export const isId = (val) => Number.isInteger(val) && val >= 1;
|
||||
|
||||
const isId = (val) => Number.isInteger(val) && val >= 1;
|
||||
|
||||
class AutoCompleteWithSources extends Component {
|
||||
static props = {
|
||||
resModel: String,
|
||||
update: Function,
|
||||
multiSelect: Boolean,
|
||||
getIds: Function,
|
||||
value: String,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
fieldString: { type: String, optional: true },
|
||||
};
|
||||
static components = { AutoComplete };
|
||||
static template = "web.DomainSelector.AutoCompleteWithSources";
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.nameService = useService("name");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.sources = [
|
||||
{
|
||||
placeholder: _t("Loading..."),
|
||||
options: this.loadOptionsSource.bind(this),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
addNames(nameGets) {
|
||||
const displayNames = {};
|
||||
for (const [id, label] of nameGets) {
|
||||
displayNames[id] = label.split("\n")[0];
|
||||
}
|
||||
this.nameService.addDisplayNames(this.props.resModel, displayNames);
|
||||
}
|
||||
|
||||
getIds() {
|
||||
return this.props.getIds();
|
||||
}
|
||||
|
||||
async loadOptionsSource(name) {
|
||||
if (this.lastProm) {
|
||||
this.lastProm.abort(false);
|
||||
}
|
||||
this.lastProm = this.search(name, SEARCH_LIMIT + 1);
|
||||
const nameGets = await this.lastProm;
|
||||
this.addNames(nameGets);
|
||||
const options = nameGets.map(([value, label]) => ({ value, label: label.split("\n")[0] }));
|
||||
if (SEARCH_LIMIT < nameGets.length) {
|
||||
options.push({
|
||||
label: _t("Search More..."),
|
||||
action: this.onSearchMore.bind(this, name),
|
||||
classList: "o_m2o_dropdown_option",
|
||||
});
|
||||
}
|
||||
if (options.length === 0) {
|
||||
options.push({ label: _t("(no result)"), unselectable: true });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async onSearchMore(name) {
|
||||
const { fieldString, multiSelect, resModel } = this.props;
|
||||
let operator;
|
||||
const ids = [];
|
||||
if (name) {
|
||||
const nameGets = await this.search(name, SEARCH_MORE_LIMIT);
|
||||
this.addNames(nameGets);
|
||||
operator = "in";
|
||||
ids.push(...nameGets.map((nameGet) => nameGet[0]));
|
||||
} else {
|
||||
operator = "not in";
|
||||
ids.push(...this.getIds());
|
||||
}
|
||||
const dynamicFilters = ids.length
|
||||
? [
|
||||
{
|
||||
description: _t("Quick search: %s", name),
|
||||
domain: [["id", operator, ids]],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
// fine for now but we don't like this kind of dependence of core to views
|
||||
const SelectCreateDialog = registry.category("dialogs").get("select_create");
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: _t("Search: %s", fieldString),
|
||||
dynamicFilters,
|
||||
resModel,
|
||||
noCreate: true,
|
||||
multiSelect,
|
||||
context: this.props.context || {},
|
||||
onSelected: (resId) => {
|
||||
const resIds = Array.isArray(resId) ? resId : [resId];
|
||||
this.props.update([...resIds]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDomain() {
|
||||
const domainIds = Domain.not([["id", "in", this.getIds()]]);
|
||||
if (this.props.domain) {
|
||||
return Domain.and([this.props.domain, domainIds]).toList();
|
||||
}
|
||||
return domainIds.toList();
|
||||
}
|
||||
|
||||
onSelect({ value: resId, action }, params) {
|
||||
if (action) {
|
||||
return action(params);
|
||||
}
|
||||
this.props.update([resId]);
|
||||
}
|
||||
|
||||
search(name, limit) {
|
||||
const domain = this.getDomain();
|
||||
return this.orm.call(this.props.resModel, "name_search", [], {
|
||||
name,
|
||||
args: domain,
|
||||
limit,
|
||||
context: this.props.context || {},
|
||||
});
|
||||
}
|
||||
|
||||
onChange({ inputValue }) {
|
||||
if (!inputValue.length) {
|
||||
this.props.update([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const getFormat = (val, displayNames) => {
|
||||
export const getFormat = (val, displayNames) => {
|
||||
let text;
|
||||
let colorIndex;
|
||||
if (isId(val)) {
|
||||
@@ -163,105 +27,52 @@ const getFormat = (val, displayNames) => {
|
||||
return { text, colorIndex };
|
||||
};
|
||||
|
||||
export class DomainSelectorAutocomplete extends Component {
|
||||
export class DomainSelectorAutocomplete extends MultiRecordSelector {
|
||||
static props = {
|
||||
resModel: String,
|
||||
update: Function,
|
||||
value: true,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
fieldString: { type: String, optional: true },
|
||||
...MultiRecordSelector.props,
|
||||
resIds: true, //resIds could be an array of ids or an array of expressions
|
||||
};
|
||||
static components = { AutoCompleteWithSources, TagsList };
|
||||
static template = "web.DomainSelector.DomainSelectorAutocomplete";
|
||||
|
||||
setup() {
|
||||
this.nameService = useService("name");
|
||||
onWillStart(() => this.computeDerivedParams());
|
||||
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
|
||||
}
|
||||
|
||||
async computeDerivedParams(props = this.props) {
|
||||
const displayNames = await this.getDisplayNames(props);
|
||||
this.tags = this.getTags(props, displayNames);
|
||||
}
|
||||
|
||||
async getDisplayNames(props) {
|
||||
const ids = this.getIds(props);
|
||||
return this.nameService.loadDisplayNames(props.resModel, ids);
|
||||
}
|
||||
|
||||
getIds(props = this.props) {
|
||||
return props.value.filter((val) => isId(val));
|
||||
return props.resIds.filter((val) => isId(val));
|
||||
}
|
||||
|
||||
getTags(props, displayNames) {
|
||||
return props.value.map((val, index) => {
|
||||
return props.resIds.map((val, index) => {
|
||||
const { text, colorIndex } = getFormat(val, displayNames);
|
||||
return {
|
||||
text,
|
||||
colorIndex,
|
||||
onDelete: () => {
|
||||
this.props.update([
|
||||
...this.props.value.slice(0, index),
|
||||
...this.props.value.slice(index + 1),
|
||||
...this.props.resIds.slice(0, index),
|
||||
...this.props.resIds.slice(index + 1),
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
update(resIds) {
|
||||
this.props.update([...this.props.value, ...resIds]);
|
||||
}
|
||||
}
|
||||
|
||||
export class DomainSelectorSingleAutocomplete extends Component {
|
||||
export class DomainSelectorSingleAutocomplete extends RecordSelector {
|
||||
static props = {
|
||||
resModel: String,
|
||||
update: Function,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
value: true,
|
||||
fieldString: { type: String, optional: true },
|
||||
...RecordSelector.props,
|
||||
resId: true,
|
||||
};
|
||||
static components = { AutoCompleteWithSources };
|
||||
static template = "web.DomainSelector.DomainSelectorSingleAutocomplete";
|
||||
|
||||
setup() {
|
||||
this.nameService = useService("name");
|
||||
onWillStart(() => this.computeDerivedParams());
|
||||
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
|
||||
}
|
||||
|
||||
async computeDerivedParams(props = this.props) {
|
||||
const displayNames = await this.getDisplayNames(props);
|
||||
this.displayName = this.getDisplayName(props, displayNames);
|
||||
}
|
||||
|
||||
async getDisplayNames(props) {
|
||||
const ids = this.getIds(props);
|
||||
return this.nameService.loadDisplayNames(props.resModel, ids);
|
||||
}
|
||||
|
||||
getDisplayName(props = this.props, displayNames) {
|
||||
const { value } = props;
|
||||
if (value === false) {
|
||||
const { resId } = props;
|
||||
if (resId === false) {
|
||||
return "";
|
||||
}
|
||||
const { text } = getFormat(value, displayNames);
|
||||
const { text } = getFormat(resId, displayNames);
|
||||
return text;
|
||||
}
|
||||
|
||||
getIds(props = this.props) {
|
||||
if (isId(props.value)) {
|
||||
return [props.value];
|
||||
if (isId(props.resId)) {
|
||||
return [props.resId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
update(resIds) {
|
||||
this.props.update(resIds[0] || false);
|
||||
this.render(true);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,47 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.DomainSelector.AutoCompleteWithSources" >
|
||||
<AutoComplete
|
||||
value="props.value"
|
||||
autoSelect="true"
|
||||
resetOnSelect="props.multiSelect"
|
||||
class="props.className"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
onChange.bind="onChange"
|
||||
/>
|
||||
</t>
|
||||
|
||||
<t t-name="web.DomainSelector.DomainSelectorAutocomplete" >
|
||||
<div class="o_input d-flex flex-wrap h-100 gap-1">
|
||||
<TagsList tags="tags"/>
|
||||
<AutoCompleteWithSources
|
||||
resModel="props.resModel"
|
||||
value="''"
|
||||
domain="props.domain"
|
||||
context="props.context"
|
||||
className="'o_input o_domain_leaf_value_input flex-grow-1'"
|
||||
fieldString="props.fieldString"
|
||||
multiSelect="true"
|
||||
getIds.bind="getIds"
|
||||
update.bind="update"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
<t t-name="web.DomainSelector.DomainSelectorSingleAutocomplete" >
|
||||
<AutoCompleteWithSources
|
||||
resModel="props.resModel"
|
||||
value="displayName"
|
||||
domain="props.domain"
|
||||
context="props.context"
|
||||
className="'o_input o_domain_leaf_value_input h-100'"
|
||||
fieldString="props.fieldString"
|
||||
multiSelect="false"
|
||||
getIds="() => []"
|
||||
update.bind="update"
|
||||
/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -319,7 +319,7 @@ const RELATIONAL_EDITOR_EQUALITY = makeEditor(DomainSelectorSingleAutocomplete,
|
||||
resModel: fieldDef.relation,
|
||||
fieldString: fieldDef.string,
|
||||
update,
|
||||
value,
|
||||
resId: value,
|
||||
};
|
||||
},
|
||||
isSupported: () => true,
|
||||
@@ -331,8 +331,8 @@ const RELATIONAL_EDITOR_IN = makeEditor(DomainSelectorAutocomplete, {
|
||||
return {
|
||||
resModel: fieldDef.relation,
|
||||
fieldString: fieldDef.string,
|
||||
update: (value) => update([...new Set(value)]),
|
||||
value: Array.isArray(value) ? [...new Set(value)] : value,
|
||||
update: (resIds) => update([...new Set(resIds)]),
|
||||
resIds: Array.isArray(value) ? [...new Set(value)] : value,
|
||||
};
|
||||
},
|
||||
isSupported: (value) => Array.isArray(value),
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
import { TagsList } from "@web/core/tags_list/tags_list";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { RecordAutocomplete } from "./record_autocomplete";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class MultiRecordSelector extends Component {
|
||||
static props = {
|
||||
resIds: { type: Array, element: Number },
|
||||
resModel: String,
|
||||
update: Function,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
fieldString: { type: String, optional: true },
|
||||
};
|
||||
static components = { RecordAutocomplete, TagsList };
|
||||
static template = "web.MultiRecordSelector";
|
||||
|
||||
setup() {
|
||||
this.nameService = useService("name");
|
||||
onWillStart(() => this.computeDerivedParams());
|
||||
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
|
||||
}
|
||||
|
||||
async computeDerivedParams(props = this.props) {
|
||||
const displayNames = await this.getDisplayNames(props);
|
||||
this.tags = this.getTags(props, displayNames);
|
||||
}
|
||||
|
||||
async getDisplayNames(props) {
|
||||
const ids = this.getIds(props);
|
||||
return this.nameService.loadDisplayNames(props.resModel, ids);
|
||||
}
|
||||
|
||||
getIds(props = this.props) {
|
||||
return props.resIds;
|
||||
}
|
||||
|
||||
getTags(props, displayNames) {
|
||||
return props.resIds.map((id, index) => {
|
||||
const text =
|
||||
typeof displayNames[id] === "string"
|
||||
? displayNames[id]
|
||||
: _t("Inaccessible/missing record ID: %s", id);
|
||||
return {
|
||||
text,
|
||||
onDelete: () => {
|
||||
this.props.update([
|
||||
...this.props.resIds.slice(0, index),
|
||||
...this.props.resIds.slice(index + 1),
|
||||
]);
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
update(resIds) {
|
||||
this.props.update([...this.props.resIds, ...resIds]);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.MultiRecordSelector" >
|
||||
<div class="o_input d-flex flex-wrap h-100 gap-1 o_multi_record_selector">
|
||||
<TagsList tags="tags"/>
|
||||
<RecordAutocomplete
|
||||
resModel="props.resModel"
|
||||
value="''"
|
||||
domain="props.domain"
|
||||
context="props.context"
|
||||
className="'o_input o_domain_leaf_value_input flex-grow-1'"
|
||||
fieldString="props.fieldString"
|
||||
multiSelect="true"
|
||||
getIds.bind="getIds"
|
||||
update.bind="update"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,140 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component } from "@odoo/owl";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { useOwnedDialogs, useService } from "@web/core/utils/hooks";
|
||||
|
||||
const SEARCH_LIMIT = 7;
|
||||
const SEARCH_MORE_LIMIT = 320;
|
||||
|
||||
export class RecordAutocomplete extends Component {
|
||||
static props = {
|
||||
resModel: String,
|
||||
update: Function,
|
||||
multiSelect: Boolean,
|
||||
getIds: Function,
|
||||
value: String,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
className: { type: String, optional: true },
|
||||
fieldString: { type: String, optional: true },
|
||||
};
|
||||
static components = { AutoComplete };
|
||||
static template = "web.RecordAutocomplete";
|
||||
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.nameService = useService("name");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
this.sources = [
|
||||
{
|
||||
placeholder: _t("Loading..."),
|
||||
options: this.loadOptionsSource.bind(this),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
addNames(nameGets) {
|
||||
const displayNames = {};
|
||||
for (const [id, label] of nameGets) {
|
||||
displayNames[id] = label.split("\n")[0];
|
||||
}
|
||||
this.nameService.addDisplayNames(this.props.resModel, displayNames);
|
||||
}
|
||||
|
||||
getIds() {
|
||||
return this.props.getIds();
|
||||
}
|
||||
|
||||
async loadOptionsSource(name) {
|
||||
if (this.lastProm) {
|
||||
this.lastProm.abort(false);
|
||||
}
|
||||
this.lastProm = this.search(name, SEARCH_LIMIT + 1);
|
||||
const nameGets = await this.lastProm;
|
||||
this.addNames(nameGets);
|
||||
const options = nameGets.map(([value, label]) => ({ value, label: label.split("\n")[0] }));
|
||||
if (SEARCH_LIMIT < nameGets.length) {
|
||||
options.push({
|
||||
label: _t("Search More..."),
|
||||
action: this.onSearchMore.bind(this, name),
|
||||
classList: "o_m2o_dropdown_option",
|
||||
});
|
||||
}
|
||||
if (options.length === 0) {
|
||||
options.push({ label: _t("(no result)"), unselectable: true });
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
async onSearchMore(name) {
|
||||
const { fieldString, multiSelect, resModel } = this.props;
|
||||
let operator;
|
||||
const ids = [];
|
||||
if (name) {
|
||||
const nameGets = await this.search(name, SEARCH_MORE_LIMIT);
|
||||
this.addNames(nameGets);
|
||||
operator = "in";
|
||||
ids.push(...nameGets.map((nameGet) => nameGet[0]));
|
||||
} else {
|
||||
operator = "not in";
|
||||
ids.push(...this.getIds());
|
||||
}
|
||||
const dynamicFilters = ids.length
|
||||
? [
|
||||
{
|
||||
description: _t("Quick search: %s", name),
|
||||
domain: [["id", operator, ids]],
|
||||
},
|
||||
]
|
||||
: undefined;
|
||||
// fine for now but we don't like this kind of dependence of core to views
|
||||
const SelectCreateDialog = registry.category("dialogs").get("select_create");
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: _t("Search: %s", fieldString),
|
||||
dynamicFilters,
|
||||
resModel,
|
||||
noCreate: true,
|
||||
multiSelect,
|
||||
context: this.props.context || {},
|
||||
onSelected: (resId) => {
|
||||
const resIds = Array.isArray(resId) ? resId : [resId];
|
||||
this.props.update([...resIds]);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
getDomain() {
|
||||
const domainIds = Domain.not([["id", "in", this.getIds()]]);
|
||||
if (this.props.domain) {
|
||||
return Domain.and([this.props.domain, domainIds]).toList();
|
||||
}
|
||||
return domainIds.toList();
|
||||
}
|
||||
|
||||
onSelect({ value: resId, action }, params) {
|
||||
if (action) {
|
||||
return action(params);
|
||||
}
|
||||
this.props.update([resId]);
|
||||
}
|
||||
|
||||
search(name, limit) {
|
||||
const domain = this.getDomain();
|
||||
return this.orm.call(this.props.resModel, "name_search", [], {
|
||||
name,
|
||||
args: domain,
|
||||
limit,
|
||||
context: this.props.context || {},
|
||||
});
|
||||
}
|
||||
|
||||
onChange({ inputValue }) {
|
||||
if (!inputValue.length) {
|
||||
this.props.update([]);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.RecordAutocomplete" >
|
||||
<AutoComplete
|
||||
value="props.value"
|
||||
autoSelect="true"
|
||||
resetOnSelect="props.multiSelect"
|
||||
class="props.className"
|
||||
sources="sources"
|
||||
onSelect.bind="onSelect"
|
||||
onChange.bind="onChange"
|
||||
/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,57 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Component, onWillStart, onWillUpdateProps } from "@odoo/owl";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { RecordAutocomplete } from "./record_autocomplete";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
|
||||
export class RecordSelector extends Component {
|
||||
static props = {
|
||||
resId: [Number, { value: false }],
|
||||
resModel: String,
|
||||
update: Function,
|
||||
domain: { type: Array, optional: true },
|
||||
context: { type: Object, optional: true },
|
||||
fieldString: { type: String, optional: true },
|
||||
};
|
||||
static components = { RecordAutocomplete };
|
||||
static template = "web.RecordSelector";
|
||||
|
||||
setup() {
|
||||
this.nameService = useService("name");
|
||||
onWillStart(() => this.computeDerivedParams());
|
||||
onWillUpdateProps((nextProps) => this.computeDerivedParams(nextProps));
|
||||
}
|
||||
|
||||
async computeDerivedParams(props = this.props) {
|
||||
const displayNames = await this.getDisplayNames(props);
|
||||
this.displayName = this.getDisplayName(props, displayNames);
|
||||
}
|
||||
|
||||
async getDisplayNames(props) {
|
||||
const ids = this.getIds(props);
|
||||
return this.nameService.loadDisplayNames(props.resModel, ids);
|
||||
}
|
||||
|
||||
getDisplayName(props = this.props, displayNames) {
|
||||
const { resId } = props;
|
||||
if (resId === false) {
|
||||
return "";
|
||||
}
|
||||
return typeof displayNames[resId] === "string"
|
||||
? displayNames[resId]
|
||||
: _t("Inaccessible/missing record ID: %s", resId);
|
||||
}
|
||||
|
||||
getIds(props = this.props) {
|
||||
if (props.resId) {
|
||||
return [props.resId];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
update(resIds) {
|
||||
this.props.update(resIds[0] || false);
|
||||
this.render(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
|
||||
<t t-name="web.RecordSelector" >
|
||||
<RecordAutocomplete
|
||||
resModel="props.resModel"
|
||||
value="displayName"
|
||||
domain="props.domain"
|
||||
context="props.context"
|
||||
className="'o_input o_domain_leaf_value_input h-100 o_record_selector'"
|
||||
fieldString="props.fieldString"
|
||||
multiSelect="false"
|
||||
getIds="() => []"
|
||||
update.bind="update"
|
||||
/>
|
||||
</t>
|
||||
|
||||
</templates>
|
||||
@@ -0,0 +1,166 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { MultiRecordSelector } from "@web/core/record_selectors/multi_record_selector";
|
||||
import { makeTestEnv } from "../../helpers/mock_env";
|
||||
import { getFixture, mount, click } from "../../helpers/utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { nameService } from "@web/core/name_service";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
|
||||
QUnit.module("Web Components", (hooks) => {
|
||||
QUnit.module("MultiRecordSelector");
|
||||
|
||||
let target;
|
||||
const serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Display name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "Alice" },
|
||||
{ id: 2, display_name: "Bob" },
|
||||
{ id: 3, display_name: "Charlie" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function makeMultiRecordSelector(props, { mockRPC } = {}) {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ resIds: props.resIds });
|
||||
}
|
||||
|
||||
get recordProps() {
|
||||
return {
|
||||
...props,
|
||||
resIds: this.state.resIds,
|
||||
update: (resIds) => this._update(resIds),
|
||||
};
|
||||
}
|
||||
|
||||
_update(resIds) {
|
||||
this.state.resIds = resIds;
|
||||
}
|
||||
}
|
||||
Parent.components = { MultiRecordSelector };
|
||||
Parent.template = xml`
|
||||
<MultiRecordSelector t-props="recordProps" />`;
|
||||
|
||||
const env = await makeTestEnv({ serverData, mockRPC });
|
||||
await mount(Parent, target, { env });
|
||||
}
|
||||
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
registry.category("services").add("hotkey", hotkeyService);
|
||||
registry.category("services").add("dialog", dialogService);
|
||||
registry.category("services").add("name", nameService);
|
||||
});
|
||||
|
||||
QUnit.test("Can be renderer with no values", async (assert) => {
|
||||
await makeMultiRecordSelector({
|
||||
resModel: "partner",
|
||||
resIds: [],
|
||||
});
|
||||
const input = target.querySelector(".o_multi_record_selector input");
|
||||
assert.strictEqual(input.value, "", "The input should be empty");
|
||||
assert.hasClass(input, "o_input");
|
||||
});
|
||||
|
||||
QUnit.test("Can be renderer with a value", async (assert) => {
|
||||
await makeMultiRecordSelector({
|
||||
resModel: "partner",
|
||||
resIds: [1],
|
||||
});
|
||||
const input = target.querySelector(".o_multi_record_selector input");
|
||||
assert.strictEqual(input.value, "");
|
||||
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
|
||||
assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice");
|
||||
});
|
||||
|
||||
QUnit.test("Can be renderer with multiple values", async (assert) => {
|
||||
await makeMultiRecordSelector({
|
||||
resModel: "partner",
|
||||
resIds: [1, 2],
|
||||
});
|
||||
const input = target.querySelector(".o_multi_record_selector input");
|
||||
assert.strictEqual(input.value, "");
|
||||
assert.strictEqual(target.querySelectorAll(".o_tag").length, 2);
|
||||
assert.deepEqual(
|
||||
[...target.querySelectorAll(".o_tag")].map((el) => el.textContent),
|
||||
["Alice", "Bob"]
|
||||
);
|
||||
});
|
||||
|
||||
QUnit.test("Can be updated from autocomplete", async (assert) => {
|
||||
await makeMultiRecordSelector({
|
||||
resModel: "partner",
|
||||
resIds: [],
|
||||
});
|
||||
const input = target.querySelector(".o_multi_record_selector input");
|
||||
assert.containsNone(target, ".o_tag");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
await click(input);
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1];
|
||||
await click(secondItem);
|
||||
assert.containsOnce(target, ".o_tag");
|
||||
assert.strictEqual(target.querySelector(".o_tag").textContent, "Bob");
|
||||
});
|
||||
|
||||
QUnit.test("Display name is correctly fetched", async (assert) => {
|
||||
await makeMultiRecordSelector(
|
||||
{
|
||||
resModel: "partner",
|
||||
resIds: [1],
|
||||
},
|
||||
{
|
||||
mockRPC: (route, args) => {
|
||||
if (args.method === "web_search_read") {
|
||||
assert.step("web_search_read");
|
||||
assert.strictEqual(args.model, "partner");
|
||||
assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
assert.strictEqual(target.querySelectorAll(".o_tag").length, 1);
|
||||
assert.strictEqual(target.querySelector(".o_tag").textContent, "Alice");
|
||||
assert.verifySteps(["web_search_read"]);
|
||||
});
|
||||
|
||||
QUnit.test("Can give domain and context props for the name search", async (assert) => {
|
||||
await makeMultiRecordSelector(
|
||||
{
|
||||
resModel: "partner",
|
||||
resIds: [1],
|
||||
domain: [["display_name", "=", "Bob"]],
|
||||
context: { blip: "blop " },
|
||||
},
|
||||
{
|
||||
mockRPC: (route, args) => {
|
||||
if (args.method === "name_search") {
|
||||
assert.step("name_search");
|
||||
assert.strictEqual(args.model, "partner");
|
||||
assert.deepEqual(args.kwargs.args, [
|
||||
"&",
|
||||
["display_name", "=", "Bob"],
|
||||
"!",
|
||||
["id", "in", [1]],
|
||||
]);
|
||||
assert.strictEqual(args.kwargs.context.blip, "blop ");
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
const input = target.querySelector(".o_multi_record_selector input");
|
||||
assert.verifySteps([]);
|
||||
await click(input);
|
||||
assert.verifySteps(["name_search"]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,150 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { RecordSelector } from "@web/core/record_selectors/record_selector";
|
||||
import { makeTestEnv } from "../../helpers/mock_env";
|
||||
import { getFixture, mount, click } from "../../helpers/utils";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { hotkeyService } from "@web/core/hotkeys/hotkey_service";
|
||||
|
||||
import { Component, useState, xml } from "@odoo/owl";
|
||||
import { nameService } from "@web/core/name_service";
|
||||
import { dialogService } from "@web/core/dialog/dialog_service";
|
||||
|
||||
QUnit.module("Web Components", (hooks) => {
|
||||
QUnit.module("RecordSelector");
|
||||
|
||||
let target;
|
||||
const serverData = {
|
||||
models: {
|
||||
partner: {
|
||||
fields: {
|
||||
display_name: { string: "Display name", type: "char" },
|
||||
},
|
||||
records: [
|
||||
{ id: 1, display_name: "Alice" },
|
||||
{ id: 2, display_name: "Bob" },
|
||||
{ id: 3, display_name: "Charlie" },
|
||||
],
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
async function makeRecordSelector(props, { mockRPC } = {}) {
|
||||
class Parent extends Component {
|
||||
setup() {
|
||||
this.state = useState({ resId: props.resId });
|
||||
}
|
||||
|
||||
get recordProps() {
|
||||
return {
|
||||
...props,
|
||||
resId: this.state.resId,
|
||||
update: (resId) => this._update(resId),
|
||||
};
|
||||
}
|
||||
|
||||
_update(resId) {
|
||||
this.state.resId = resId;
|
||||
}
|
||||
}
|
||||
Parent.components = { RecordSelector };
|
||||
Parent.template = xml`
|
||||
<RecordSelector t-props="recordProps" />`;
|
||||
|
||||
const env = await makeTestEnv({ serverData, mockRPC });
|
||||
await mount(Parent, target, { env });
|
||||
}
|
||||
|
||||
hooks.beforeEach(async () => {
|
||||
target = getFixture();
|
||||
registry.category("services").add("hotkey", hotkeyService);
|
||||
registry.category("services").add("dialog", dialogService);
|
||||
registry.category("services").add("name", nameService);
|
||||
});
|
||||
|
||||
QUnit.test("Can be renderer with no values", async (assert) => {
|
||||
await makeRecordSelector({
|
||||
resModel: "partner",
|
||||
resId: false,
|
||||
});
|
||||
const input = target.querySelector(".o_record_selector input");
|
||||
assert.strictEqual(input.value, "", "The input should be empty");
|
||||
assert.hasClass(input, "o_input");
|
||||
});
|
||||
|
||||
QUnit.test("Can be renderer with a value", async (assert) => {
|
||||
await makeRecordSelector({
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
});
|
||||
const input = target.querySelector(".o_record_selector input");
|
||||
assert.strictEqual(input.value, "Alice");
|
||||
});
|
||||
|
||||
QUnit.test("Can be updated from autocomplete", async (assert) => {
|
||||
await makeRecordSelector({
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
});
|
||||
const input = target.querySelector(".o_record_selector input");
|
||||
assert.strictEqual(input.value, "Alice");
|
||||
assert.containsNone(target, ".o-autocomplete--dropdown-menu");
|
||||
await click(input);
|
||||
assert.containsOnce(target, ".o-autocomplete--dropdown-menu");
|
||||
const secondItem = target.querySelectorAll("li.o-autocomplete--dropdown-item")[1];
|
||||
await click(secondItem);
|
||||
assert.strictEqual(input.value, "Bob");
|
||||
});
|
||||
|
||||
QUnit.test("Display name is correctly fetched", async (assert) => {
|
||||
await makeRecordSelector(
|
||||
{
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
},
|
||||
{
|
||||
mockRPC: (route, args) => {
|
||||
if (args.method === "web_search_read") {
|
||||
assert.step("web_search_read");
|
||||
assert.strictEqual(args.model, "partner");
|
||||
assert.deepEqual(args.kwargs.domain, [["id", "in", [1]]]);
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
const input = target.querySelector(".o_record_selector input");
|
||||
assert.strictEqual(input.value, "Alice");
|
||||
assert.verifySteps(["web_search_read"]);
|
||||
});
|
||||
|
||||
QUnit.test("Can give domain and context props for the name search", async (assert) => {
|
||||
await makeRecordSelector(
|
||||
{
|
||||
resModel: "partner",
|
||||
resId: 1,
|
||||
domain: [["display_name", "=", "Bob"]],
|
||||
context: { blip: "blop " },
|
||||
},
|
||||
{
|
||||
mockRPC: (route, args) => {
|
||||
if (args.method === "name_search") {
|
||||
assert.step("name_search");
|
||||
assert.strictEqual(args.model, "partner");
|
||||
assert.deepEqual(args.kwargs.args, [
|
||||
"&",
|
||||
["display_name", "=", "Bob"],
|
||||
"!",
|
||||
["id", "in", []],
|
||||
]);
|
||||
assert.strictEqual(args.kwargs.context.blip, "blop ");
|
||||
}
|
||||
},
|
||||
}
|
||||
);
|
||||
const input = target.querySelector(".o_record_selector input");
|
||||
assert.strictEqual(input.value, "Alice");
|
||||
assert.verifySteps([]);
|
||||
await click(input);
|
||||
assert.verifySteps(["name_search"]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user