[IMP] web: create a properties field component
Purpose ======= Create a component for the new Properties field. This component instantiates the right sub-component based on the property type (e.g. a DatePicker for a DateTime, a Many2XAutocomplete for relational properties, etc). This component also allow changing the properties definition (default value, model, property type, etc). Note that currently it is limited to children properties, assuming definition is done from children to containers. Add unit tests to ensure that behavior of the properties component is correct. Task-2852259 Part-of: odoo/odoo#95184
@@ -45,6 +45,10 @@ class PopoverController extends Component {
|
||||
if (this.target.contains(ev.target) || ev.target.closest(".o_popover")) {
|
||||
return;
|
||||
}
|
||||
if (this.props.preventClose && this.props.preventClose(ev)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.props.closeOnClickAway) {
|
||||
this.props.close();
|
||||
}
|
||||
|
||||
@@ -25,6 +25,7 @@ export function usePopover() {
|
||||
* @param {Object} [options]
|
||||
* @param {boolean} [options.closeOnClickAway=true]
|
||||
* @param {function()} [options.onClose]
|
||||
* @param {function()} [options.preventClose]
|
||||
* @param {string} [options.popoverClass]
|
||||
* @param {string} [options.position]
|
||||
* @returns {function()}
|
||||
|
||||
@@ -41,6 +41,7 @@ export const popoverService = {
|
||||
position: options.position,
|
||||
popoverClass: options.popoverClass,
|
||||
closeOnClickAway: options.closeOnClickAway,
|
||||
preventClose: options.preventClose,
|
||||
};
|
||||
popovers[id] = popover;
|
||||
bus.trigger("UPDATE");
|
||||
|
||||
@@ -416,6 +416,20 @@ function formatSelection(value, field, options) {
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string representing the value of the python properties field.
|
||||
*
|
||||
* @param {string|false} value
|
||||
* @param {Object} [field]
|
||||
* a description of the field (note: this parameter is ignored)
|
||||
*/
|
||||
function formatProperties(value, field) {
|
||||
if (!value || !value.length) {
|
||||
return '';
|
||||
}
|
||||
return value.map(property => property['string']).join(', ');
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// Parse
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -749,6 +763,8 @@ return {
|
||||
monetary: formatMonetary,
|
||||
one2many: formatX2Many,
|
||||
percentage: formatPercentage,
|
||||
properties: formatProperties,
|
||||
properties_definition: formatProperties,
|
||||
reference: formatMany2one,
|
||||
selection: formatSelection,
|
||||
text: formatChar,
|
||||
|
||||
@@ -371,6 +371,11 @@ export class Record extends DataPoint {
|
||||
case "integer":
|
||||
case "monetary":
|
||||
continue;
|
||||
case "properties":
|
||||
if (!this.checkPropertiesValidity(fieldName)) {
|
||||
this.setInvalidField(fieldName);
|
||||
}
|
||||
break;
|
||||
case "one2many":
|
||||
case "many2many":
|
||||
if (!(await this.checkX2ManyValidity(fieldName))) {
|
||||
@@ -449,6 +454,23 @@ export class Record extends DataPoint {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The label and the id of the properties are always required.
|
||||
*
|
||||
* @param {string} fieldName
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkPropertiesValidity(fieldName) {
|
||||
const value = this.data[fieldName];
|
||||
if (!value) {
|
||||
return true;
|
||||
}
|
||||
return value.every((propertyDefinition) =>
|
||||
!propertyDefinition.id ||
|
||||
(propertyDefinition.string && propertyDefinition.string.length)
|
||||
);
|
||||
}
|
||||
|
||||
setInvalidField(fieldName) {
|
||||
this._invalidFields.add(fieldName);
|
||||
const bm = this.model.__bm__;
|
||||
|
||||
@@ -23,7 +23,7 @@ class CreateConfirmationDialog extends Component {
|
||||
CreateConfirmationDialog.components = { Dialog };
|
||||
CreateConfirmationDialog.template = "web.Many2OneField.CreateConfirmationDialog";
|
||||
|
||||
function m2oTupleFromData(data) {
|
||||
export function m2oTupleFromData(data) {
|
||||
const id = data.id;
|
||||
let name;
|
||||
if ("display_name" in data) {
|
||||
|
||||
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 1.3 KiB |
|
After Width: | Height: | Size: 979 B |
|
After Width: | Height: | Size: 1.2 KiB |
|
After Width: | Height: | Size: 1.1 KiB |
|
After Width: | Height: | Size: 853 B |
|
After Width: | Height: | Size: 599 B |
|
After Width: | Height: | Size: 805 B |
@@ -0,0 +1,457 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { registry } from "@web/core/registry";
|
||||
import { standardFieldProps } from "../standard_field_props";
|
||||
import { uuid } from "../../utils";
|
||||
import { PropertyDefinition } from "./property_definition";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { PropertyValue } from "./property_value";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { ConfirmationDialog } from "@web/core/confirmation_dialog/confirmation_dialog";
|
||||
|
||||
const {
|
||||
Component,
|
||||
useRef,
|
||||
useState,
|
||||
useEffect,
|
||||
onWillStart,
|
||||
onWillUpdateProps,
|
||||
} = owl;
|
||||
|
||||
export class PropertiesField extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.orm = useService("orm");
|
||||
this.dialogService = useService("dialog");
|
||||
this.popover = usePopover();
|
||||
this.propertiesRef = useRef("properties");
|
||||
|
||||
this.state = useState({
|
||||
canChangeDefinition: true,
|
||||
movedPropertyName: null,
|
||||
});
|
||||
|
||||
this._saveInitialPropertiesValues();
|
||||
|
||||
const field = this.props.record.fields[this.props.name];
|
||||
this.definitionRecordField = field.definition_record;
|
||||
|
||||
onWillStart(async () => {
|
||||
await this._checkDefinitionAccess();
|
||||
});
|
||||
|
||||
onWillUpdateProps(async () => {
|
||||
// reset the initials properties values if we saved the record
|
||||
if (this.props.record.mode === "readonly") {
|
||||
this._saveInitialPropertiesValues();
|
||||
}
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
this._movePopoverIfNeeded();
|
||||
|
||||
if (this.openLastPropertyDefinition) {
|
||||
this.openLastPropertyDefinition = null;
|
||||
const propertiesList = this.propertiesList;
|
||||
const lastPropertyName =
|
||||
propertiesList[propertiesList.length - 1].name;
|
||||
const labels = this.propertiesRef.el.querySelectorAll(
|
||||
`.o_property_field[property-name="${lastPropertyName}"] .o_field_property_open_popover`
|
||||
);
|
||||
const lastLabel = labels[labels.length - 1];
|
||||
lastLabel.click();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current context
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
get context() {
|
||||
return this.props.record.getFieldContext(this.props.name);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current properties value.
|
||||
*
|
||||
* Make a deep copy of this properties values, so when we will modify it
|
||||
* in the events, we won't re-use same object (can lead to issue, e.g. if we
|
||||
* discard a form view, we should be able to restore the old props).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertiesList() {
|
||||
const propertiesValues = JSON.parse(
|
||||
JSON.stringify(this.props.value || [])
|
||||
);
|
||||
return propertiesValues.filter(
|
||||
(definition) => !definition.definition_deleted
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Return false if we should not close the popover containing the
|
||||
* properties definition based on the event received.
|
||||
*
|
||||
* If we edit the datetime, it will open a popover with the date picker
|
||||
* component, but this component won't be a child of the current popover.
|
||||
* So when we will click on it to select a date, it will close the definition
|
||||
* popover. It's the same for other similar components (many2one modal, etc).
|
||||
*
|
||||
* @param {event} event
|
||||
* @returns {boolean}
|
||||
*/
|
||||
checkPopoverClose(event) {
|
||||
if (document.activeElement.closest(".o_field_property_definition")) {
|
||||
// the focus is still on an element of the definition
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.target.closest(".bootstrap-datetimepicker-widget")) {
|
||||
// selected a datetime, do not close the definition popover
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.target.closest(".modal")) {
|
||||
// close a many2one modal
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the given property up or down in the list.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {string} direction, either "up" or "down"
|
||||
*/
|
||||
onPropertyMove(propertyName, direction) {
|
||||
const propertiesValues = this.propertiesList || [];
|
||||
const propertyIndex = propertiesValues.findIndex(
|
||||
(property) => property.name === propertyName
|
||||
);
|
||||
|
||||
const targetIndex = propertyIndex + (direction === "down" ? 1 : -1);
|
||||
if (targetIndex < 0 || targetIndex >= propertiesValues.length) {
|
||||
this.notification.add(
|
||||
direction === "down"
|
||||
? _lt("This field is already last")
|
||||
: _lt("This field is already first"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
this.state.movedPropertyName = propertyName;
|
||||
|
||||
const prop = propertiesValues[targetIndex];
|
||||
propertiesValues[targetIndex] = propertiesValues[propertyIndex];
|
||||
propertiesValues[propertyIndex] = prop;
|
||||
propertiesValues[propertyIndex].definition_changed = true;
|
||||
this.props.update(propertiesValues);
|
||||
// move the popover once the DOM is updated
|
||||
this.shouldUpdatePopoverPosition = true;
|
||||
}
|
||||
|
||||
/**
|
||||
* The value / definition of the given property has been changed.
|
||||
* `propertyValue` contains the definition of the property with the value.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {object} propertyValue
|
||||
*/
|
||||
onPropertyValueChange(propertyName, propertyValue) {
|
||||
const propertiesValues = this.propertiesList;
|
||||
propertiesValues.find(
|
||||
(property) => property.name === propertyName
|
||||
).value = propertyValue;
|
||||
this.props.update(propertiesValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the popover with the property definition.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {string} propertyName
|
||||
*/
|
||||
onPropertyEdit(event, propertyName) {
|
||||
if (event.target.classList.contains("disabled")) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
// remove the glitch if we click on the edit button
|
||||
// while the popover is already opened
|
||||
return;
|
||||
}
|
||||
|
||||
event.target.classList.add("disabled");
|
||||
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
event.currentTarget,
|
||||
PropertyDefinition,
|
||||
{
|
||||
readonly:
|
||||
this.props.readonly || !this.state.canChangeDefinition,
|
||||
canChangeDefinition: this.state.canChangeDefinition,
|
||||
propertyDefinition: this.propertiesList.find(
|
||||
(property) => property.name === propertyName
|
||||
),
|
||||
context: this.context,
|
||||
onChange: this.onPropertyDefinitionChange.bind(this),
|
||||
onDelete: () => this.onPropertyDelete(propertyName),
|
||||
onPropertyMove: (direction) =>
|
||||
this.onPropertyMove(propertyName, direction),
|
||||
},
|
||||
{
|
||||
preventClose: this.checkPopoverClose,
|
||||
popoverClass: "o_property_field_popover",
|
||||
position: "top",
|
||||
onClose: () => {
|
||||
this.state.movedPropertyName = null;
|
||||
event.target.classList.remove("disabled");
|
||||
},
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The property definition or value has been changed.
|
||||
*
|
||||
* @param {object} propertyDefinition
|
||||
*/
|
||||
onPropertyDefinitionChange(propertyDefinition) {
|
||||
propertyDefinition["definition_changed"] = true;
|
||||
const propertiesValues = this.propertiesList;
|
||||
const propertyIndex = propertiesValues.findIndex(
|
||||
(property) => property.name === propertyDefinition.name
|
||||
);
|
||||
|
||||
// if the type / model are the same, restore the original name to not reset the children
|
||||
// otherwise, generate a new value so all value of the record are reset
|
||||
const initialValues = this.initialValues[propertyDefinition.name];
|
||||
if (
|
||||
initialValues &&
|
||||
propertyDefinition.type === initialValues.type &&
|
||||
propertyDefinition.comodel === initialValues.comodel
|
||||
) {
|
||||
// restore the original name
|
||||
propertyDefinition.name = initialValues.name;
|
||||
} else if (
|
||||
initialValues &&
|
||||
initialValues.name === propertyDefinition.name
|
||||
) {
|
||||
// generate a new new to reset all values on other records
|
||||
// store the new generated name to be able to restore it
|
||||
// if needed
|
||||
const newName = uuid();
|
||||
this.initialValues[newName] = initialValues;
|
||||
propertyDefinition.name = newName;
|
||||
}
|
||||
|
||||
propertiesValues[propertyIndex] = propertyDefinition;
|
||||
this.props.update(propertiesValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark a property as "to delete".
|
||||
*
|
||||
* @param {string} propertyName
|
||||
*/
|
||||
onPropertyDelete(propertyName) {
|
||||
const dialogProps = {
|
||||
title: _lt("Delete Property Field"),
|
||||
body: sprintf(
|
||||
_lt(
|
||||
'Are you sure you want to delete this property field? It will be removed for everyone using the "%s" %s.'
|
||||
),
|
||||
this.parentName,
|
||||
this.parentString
|
||||
),
|
||||
confirm: () => {
|
||||
if (this.popoverCloseFn) {
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
const propertiesDefinitions = this.propertiesList;
|
||||
propertiesDefinitions.find(
|
||||
(property) => property.name === propertyName
|
||||
).definition_deleted = true;
|
||||
this.props.update(propertiesDefinitions);
|
||||
},
|
||||
cancel: () => {},
|
||||
};
|
||||
this.dialogService.add(ConfirmationDialog, dialogProps);
|
||||
}
|
||||
|
||||
onPropertyCreate() {
|
||||
const propertiesDefinitions = this.propertiesList || [];
|
||||
|
||||
if (
|
||||
propertiesDefinitions.length &&
|
||||
propertiesDefinitions.some(
|
||||
(prop) => !prop.string || !prop.string.length
|
||||
)
|
||||
) {
|
||||
// do not allow to add new field until we set a label on the previous one
|
||||
this.propertiesRef.el
|
||||
.closest(".o_field_properties")
|
||||
.classList.add("o_field_invalid");
|
||||
|
||||
this.notification.add(
|
||||
_lt("Please complete your properties before adding a new one"),
|
||||
{ type: "warning" }
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
this.propertiesRef.el
|
||||
.closest(".o_field_properties")
|
||||
.classList.remove("o_field_invalid");
|
||||
|
||||
propertiesDefinitions.push({
|
||||
name: uuid(),
|
||||
string: sprintf(
|
||||
_lt("Property %s"),
|
||||
propertiesDefinitions.length + 1
|
||||
),
|
||||
type: "char",
|
||||
definition_changed: true,
|
||||
});
|
||||
this.openLastPropertyDefinition = true;
|
||||
this.props.update(propertiesDefinitions);
|
||||
}
|
||||
|
||||
/**
|
||||
* The tags list has been changed.
|
||||
* If `newValue` is given, update the property value as well.
|
||||
*
|
||||
* @param {string} propertyName
|
||||
* @param {array} newTags
|
||||
* @param {array | null} newValue
|
||||
*/
|
||||
onTagsChange(propertyName, newTags, newValue = null) {
|
||||
const propertyDefinition = this.propertiesList.find(
|
||||
(property) => property.name === propertyName
|
||||
);
|
||||
propertyDefinition.tags = newTags;
|
||||
if (newValue !== null) {
|
||||
propertyDefinition.value = newValue;
|
||||
}
|
||||
propertyDefinition.definition_changed = true;
|
||||
this.onPropertyDefinitionChange(propertyDefinition);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Move the popover to the given property id.
|
||||
* Used when we change the position of the properties.
|
||||
*
|
||||
* We change the popover position after the DOM has been updated (see @useEffect)
|
||||
* because if we update it after changing the component properties,
|
||||
*/
|
||||
_movePopoverIfNeeded() {
|
||||
if (!this.shouldUpdatePopoverPosition) {
|
||||
return;
|
||||
}
|
||||
this.shouldUpdatePopoverPosition = false;
|
||||
|
||||
const propertyName = this.state.movedPropertyName;
|
||||
const popover = document
|
||||
.querySelector(".o_field_property_definition")
|
||||
.closest(".o_popover");
|
||||
const targetElement = document.querySelector(
|
||||
`.o_property_field[property-name="${propertyName}"]`
|
||||
);
|
||||
const targetPosition = targetElement.getBoundingClientRect();
|
||||
const popoverPosition = popover.getBoundingClientRect();
|
||||
|
||||
popover.style.top =
|
||||
targetPosition.top - popoverPosition.height - 10 + "px";
|
||||
popover.style.left = targetPosition.left + "px";
|
||||
popover.style.position = "absolute";
|
||||
}
|
||||
|
||||
/**
|
||||
* Verify that we can write on the parent record,
|
||||
* and therefor update the properties definition.
|
||||
*/
|
||||
async _checkDefinitionAccess() {
|
||||
const definitionRecordId =
|
||||
this.props.record.data[this.definitionRecordField][0];
|
||||
this.parentName = this.props.record.data[this.definitionRecordField][1];
|
||||
const definitionRecordModel =
|
||||
this.props.record.fields[this.definitionRecordField].relation;
|
||||
this.parentString =
|
||||
this.props.record.fields[this.definitionRecordField].string;
|
||||
|
||||
if (!definitionRecordId || !definitionRecordModel) {
|
||||
return;
|
||||
}
|
||||
|
||||
// check if we can write on the definition record
|
||||
this.state.canChangeDefinition = await this.orm.call(
|
||||
definitionRecordModel,
|
||||
"check_access_rights",
|
||||
["write", false]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* If we change the type / model of a property, we will regenerate it's name
|
||||
* (like if it was a new property) in order to reset the value of the children.
|
||||
*
|
||||
* But if we reset the old model / type, we want to be able to discard this
|
||||
* modification (even if we save) and restore the original name.
|
||||
*
|
||||
* For that purpose, we save the original properties values.
|
||||
*/
|
||||
_saveInitialPropertiesValues() {
|
||||
// initial properties values, if the type or the model changed, the
|
||||
// name will be regenerated in order to reset the value on the children
|
||||
this.initialValues = {};
|
||||
for (let propertiesValues of this.props.value || []) {
|
||||
this.initialValues[propertiesValues.name] = {
|
||||
name: propertiesValues.name,
|
||||
type: propertiesValues.type,
|
||||
comodel: propertiesValues.comodel,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
PropertiesField.template = "web.PropertiesField";
|
||||
PropertiesField.components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
PropertyDefinition,
|
||||
PropertyValue,
|
||||
};
|
||||
PropertiesField.props = {
|
||||
...standardFieldProps,
|
||||
columns: { type: Number, optional: true },
|
||||
};
|
||||
PropertiesField.extractProps = ({ attrs, field }) => {
|
||||
const columns = parseInt(attrs.columns || "1");
|
||||
return { columns };
|
||||
};
|
||||
|
||||
PropertiesField.displayName = _lt("Properties");
|
||||
PropertiesField.supportedTypes = ["properties"];
|
||||
|
||||
registry.category("fields").add("properties", PropertiesField);
|
||||
@@ -0,0 +1,149 @@
|
||||
.o_field_properties {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
// The web client will add a "o_field_invalid" class when the field is considered
|
||||
// as not valid (at least one empty label). In that case we want to have in red
|
||||
// only the empty label, and not all inputs / components.
|
||||
.o_field_properties,
|
||||
.o_field_properties.o_field_invalid,
|
||||
.o_property_field_popover {
|
||||
.o_input,
|
||||
.o_dropdown_button,
|
||||
.o_datepicker_button {
|
||||
@include print-variable(o-input-border-color, $o-form-lightsecondary);
|
||||
color: initial !important;
|
||||
* {
|
||||
color: initial !important;
|
||||
}
|
||||
}
|
||||
|
||||
& {
|
||||
.o_input:focus,
|
||||
.dropdown:focus ~ .o_dropdown_button,
|
||||
.dropdown:focus-within ~ .o_dropdown_button,
|
||||
.o_input:focus ~ .o_datepicker_button,
|
||||
.o_dropdown_button:focus {
|
||||
@include print-variable(o-input-border-color, $o-brand-primary);
|
||||
* {
|
||||
@include print-variable(o-input-border-color, $o-brand-primary);
|
||||
}
|
||||
}
|
||||
}
|
||||
// only the empty property label should be affected by `o_field_invalid`
|
||||
.o_field_property_empty_label {
|
||||
color: map-get($theme-colors, 'danger');
|
||||
}
|
||||
}
|
||||
|
||||
.o_property_field {
|
||||
.o_td_label,
|
||||
.o_property_field_value {
|
||||
padding: 0 45px 0 0;
|
||||
}
|
||||
.o_property_field_value {
|
||||
height: fit-content;
|
||||
}
|
||||
|
||||
.o_field_property_dropdown input {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
min-width: 100%;
|
||||
}
|
||||
|
||||
.popover-body {
|
||||
min-width: 500px;
|
||||
}
|
||||
.o_td_label {
|
||||
min-width: 150px;
|
||||
}
|
||||
|
||||
.o_field_property_label {
|
||||
max-width: 150px;
|
||||
width: fit-content;
|
||||
b {
|
||||
word-break: break-word;
|
||||
max-height: 100px;
|
||||
overflow-y: hidden;
|
||||
}
|
||||
|
||||
&:hover .o_field_property_open_popover,
|
||||
.o_field_property_open_popover:focus {
|
||||
opacity: 1;
|
||||
}
|
||||
.o_field_property_open_popover {
|
||||
opacity: 0;
|
||||
cursor: pointer;
|
||||
transition: 0.1s;
|
||||
}
|
||||
.o_field_property_open_popover:hover {
|
||||
color: $primary;
|
||||
}
|
||||
}
|
||||
|
||||
.o-dropdown {
|
||||
.dropdown-menu {
|
||||
max-height: 300px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_property_field_highlight .o_field_property_empty_label,
|
||||
.o_property_field_highlight b, {
|
||||
border: 1px solid $primary;
|
||||
padding: 5px;
|
||||
margin: -5px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_field_property_add {
|
||||
// cancel button padding to align the
|
||||
// text button with properties labels
|
||||
margin-left: -1rem;
|
||||
}
|
||||
|
||||
.o_property_field_popover {
|
||||
// put the popover behind datetime component
|
||||
z-index: 1000 !important;
|
||||
font-size: inherit;
|
||||
box-shadow: 0 3rem 5rem rgba($black, .18) !important;
|
||||
box-sizing: border-box;
|
||||
.end-auto {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.o_xxs_form_view .o_field_properties {
|
||||
// mobile view
|
||||
.o_property_field {
|
||||
min-width: 100%;
|
||||
flex-wrap: wrap;
|
||||
padding: 0;
|
||||
|
||||
.o_td_label,
|
||||
.o_property_field_value {
|
||||
min-width: 100%;
|
||||
padding: 0;
|
||||
&>* {
|
||||
max-width: 100%;
|
||||
}
|
||||
.o_input_dropdown {
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
}
|
||||
.o_field_property_add,
|
||||
.o_field_property_open_popover {
|
||||
display: none;
|
||||
}
|
||||
.o_field_property_open_popover.disabled {
|
||||
pointer-events: none;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertiesField" owl="1">
|
||||
<div t-ref="properties" class="w-100">
|
||||
<div class="o_group w-100 d-flex flex-row flex-wrap mt-0">
|
||||
<div
|
||||
t-foreach="propertiesList"
|
||||
t-as="propertyConfiguration"
|
||||
t-key="propertyConfiguration.name"
|
||||
class="o_property_field d-flex flex-row justify-content-start"
|
||||
t-att-property-name="propertyConfiguration.name"
|
||||
t-attf-class="col-md-{{12 / props.columns}} col-sm-12">
|
||||
<div class="o_td_label">
|
||||
<label t-attf-class="o_field_property_label o_form_label {{state.movedPropertyName === propertyConfiguration.name ? 'o_property_field_highlight' : ''}} d-flex flex-row align-items-center">
|
||||
<b
|
||||
t-if="propertyConfiguration.string && propertyConfiguration.string.length"
|
||||
t-out="propertyConfiguration.string"
|
||||
class="d-inline-block mw-100"/>
|
||||
<i t-else="" class="o_field_property_empty_label">
|
||||
New Property
|
||||
</i>
|
||||
<i
|
||||
t-if="state.canChangeDefinition"
|
||||
class="o_field_property_open_popover fa fa-pencil ms-2"
|
||||
t-on-click="(event) => this.onPropertyEdit(event, propertyConfiguration.name)"/>
|
||||
</label>
|
||||
</div>
|
||||
<div class="o_property_field_value align-top w-100">
|
||||
<PropertyValue
|
||||
readonly="props.readonly"
|
||||
canChangeDefinition="state.canChangeDefinition"
|
||||
type="propertyConfiguration.type"
|
||||
string="propertyConfiguration.string"
|
||||
value="propertyConfiguration.value"
|
||||
selection="propertyConfiguration.selection"
|
||||
tags="propertyConfiguration.tags"
|
||||
comodel="propertyConfiguration.comodel || ''"
|
||||
domain="propertyConfiguration.domain || '[]'"
|
||||
context="context"
|
||||
onChange.bind="(value) => this.onPropertyValueChange(propertyConfiguration.name, value)"
|
||||
onTagsChange.bind="(newTags, newValue) => this.onTagsChange(propertyConfiguration.name, newTags, newValue)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="o_field_property_add">
|
||||
<button
|
||||
t-if="!props.readonly && state.canChangeDefinition"
|
||||
class="btn btn-light text-muted m-0"
|
||||
t-on-click="onPropertyCreate">
|
||||
<i class="fa fa-plus"/>
|
||||
Add a Property
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,370 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { PropertyValue } from "./property_value";
|
||||
import { DomainSelector } from "@web/core/domain_selector/domain_selector";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { Many2XAutocomplete } from "@web/views/fields/relational_utils";
|
||||
import { useService, useOwnedDialogs } from "@web/core/utils/hooks";
|
||||
import { PropertyDefinitionSelection } from "./property_definition_selection";
|
||||
import { PropertyTags } from "./property_tags";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
import { SelectCreateDialog } from "@web/views/view_dialogs/select_create_dialog";
|
||||
|
||||
const { Component, useState, onWillUpdateProps, useEffect, useRef } = owl;
|
||||
|
||||
export class PropertyDefinition extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
|
||||
this.propertyDefinitionRef = useRef("propertyDefinition");
|
||||
this.addDialog = useOwnedDialogs();
|
||||
|
||||
const defaultDefinition = {
|
||||
name: false,
|
||||
string: "",
|
||||
type: "char",
|
||||
default: "",
|
||||
};
|
||||
const propertyDefinition = {
|
||||
...defaultDefinition,
|
||||
...this.props.propertyDefinition,
|
||||
};
|
||||
|
||||
this.state = useState({
|
||||
propertyDefinition: propertyDefinition,
|
||||
typeLabel: this._typeLabel(propertyDefinition.type),
|
||||
resModel: "",
|
||||
resModelId: 0,
|
||||
resModelDescription: "",
|
||||
matchingRecordsCount: undefined,
|
||||
});
|
||||
|
||||
this._syncStateWithProps(propertyDefinition);
|
||||
|
||||
// update the state and fetch needed information
|
||||
onWillUpdateProps((newProps) =>
|
||||
this._syncStateWithProps(newProps.value)
|
||||
);
|
||||
|
||||
useEffect((event) => {
|
||||
// focus the property label, when we open the property definition
|
||||
if (this.labelFocused) {
|
||||
// focus it only once
|
||||
return;
|
||||
}
|
||||
this.labelFocused = true;
|
||||
const labelInput =
|
||||
this.propertyDefinitionRef.el.querySelectorAll("input")[0];
|
||||
if (labelInput) {
|
||||
labelInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list of property types with their labels.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get availablePropertyTypes() {
|
||||
return [
|
||||
["char", _lt("Text")],
|
||||
["boolean", _lt("Checkbox")],
|
||||
["integer", _lt("Integer")],
|
||||
["float", _lt("Decimal")],
|
||||
["date", _lt("Date")],
|
||||
["datetime", _lt("Date & Time")],
|
||||
["selection", _lt("Selection")],
|
||||
["tags", _lt("Tags")],
|
||||
["many2one", _lt("Many2one")],
|
||||
["many2many", _lt("Many2many")],
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the list of tag values, that will be selected by the PropertyTags
|
||||
* component (all existing tags because we are editing the definition).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertyTagValues() {
|
||||
return (this.state.propertyDefinition.tags || []).map((tag) => tag[0]);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* We changed the string of the property.
|
||||
*
|
||||
* @param {event} event
|
||||
*/
|
||||
onPropertyLabelChange(event) {
|
||||
const newString = event.target.value;
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
string: newString,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We changed the default value of the property.
|
||||
*
|
||||
* @param {object} newDefault
|
||||
*/
|
||||
onDefaultChange(newDefault) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
default: newDefault,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We selected a new property type.
|
||||
*
|
||||
* @param {string} newType
|
||||
*/
|
||||
onPropertyTypeChange(newType) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
type: newType,
|
||||
default: false,
|
||||
value: false,
|
||||
};
|
||||
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
this.state.typeLabel = this._typeLabel(newType);
|
||||
}
|
||||
|
||||
/**
|
||||
* The model of the relational property (many2one / many2many) has been changed.
|
||||
*
|
||||
* @param {string} newModel
|
||||
*/
|
||||
async onModelChange(newModel) {
|
||||
if (!newModel || !newModel.length || !newModel[0].id) {
|
||||
// remove the model
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
comodel: false,
|
||||
default: false,
|
||||
domain: false,
|
||||
value: false,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
this.state.resModel = '';
|
||||
this.state.resModelId = 0;
|
||||
this.state.resModelDescription = '';
|
||||
await this._updateMatchingRecordsCount();
|
||||
return;
|
||||
}
|
||||
const newModelId = newModel[0].id;
|
||||
|
||||
// if we change the model, we should reset the default value and the domain
|
||||
const modelChanged = newModelId !== this.state.resModelId;
|
||||
|
||||
this.state.resModelId = newModelId;
|
||||
|
||||
const result = await this.orm.call(
|
||||
'ir.model',
|
||||
'read',
|
||||
[[newModelId], ['model', 'name']],
|
||||
);
|
||||
|
||||
if (!result || !result.length) {
|
||||
return;
|
||||
}
|
||||
this.state.resModelDescription = result[0].name;
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
default: modelChanged
|
||||
? false
|
||||
: this.state.propertyDefinition.default,
|
||||
value: modelChanged ? false : this.state.propertyDefinition.value,
|
||||
domain: modelChanged ? false : this.state.propertyDefinition.domain,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
await this._updateMatchingRecordsCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* The domain of the relational property has been changed.
|
||||
*
|
||||
* @param {string} newDomain
|
||||
*/
|
||||
async onDomainChange(newDomain) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
domain: newDomain,
|
||||
default: false,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
await this._updateMatchingRecordsCount();
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the list view of the records matching the current domain.
|
||||
*/
|
||||
onButtonDomainClick() {
|
||||
this.addDialog(SelectCreateDialog, {
|
||||
title: this.env._t("Selected records"),
|
||||
noCreate: true,
|
||||
multiSelect: false,
|
||||
resModel: this.state.propertyDefinition.comodel,
|
||||
domain: new Domain(
|
||||
this.state.propertyDefinition.domain || "[]"
|
||||
).toList(),
|
||||
context: this.props.context || {},
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* We renamed / created / removed a selection option.
|
||||
*
|
||||
* @param {array} newOptions
|
||||
*/
|
||||
onSelectionOptionChange(newOptions) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
selection: newOptions,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/**
|
||||
* We renamed / created / removed tags.
|
||||
*
|
||||
* @param {array} newTags
|
||||
*/
|
||||
onTagsChange(newTags) {
|
||||
const propertyDefinition = {
|
||||
...this.state.propertyDefinition,
|
||||
tags: newTags,
|
||||
};
|
||||
this.props.onChange(propertyDefinition);
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* The property value changed (e.g. we discard a form view editing).
|
||||
* Re-update the state with the new props.
|
||||
*
|
||||
* @param {object} propertyDefinition
|
||||
*/
|
||||
async _syncStateWithProps(propertyDefinition) {
|
||||
const currentModel = this.state.resModel;
|
||||
const newModel = propertyDefinition.comodel;
|
||||
|
||||
this.state.propertyDefinition = propertyDefinition;
|
||||
this.state.typeLabel = this._typeLabel(propertyDefinition.type);
|
||||
this.state.resModel = newModel;
|
||||
|
||||
if (newModel && newModel !== currentModel) {
|
||||
// retrieve the model id and the model description from it's name
|
||||
// "res.partner" => (5, "Contact")
|
||||
try {
|
||||
const result = await this.orm.call(
|
||||
"ir.model",
|
||||
"search_read",
|
||||
[
|
||||
[['model', '=', newModel]],
|
||||
['id', 'name'],
|
||||
],
|
||||
);
|
||||
if (!result || !result.length) {
|
||||
return;
|
||||
}
|
||||
this.state.resModelId = result[0].id;
|
||||
this.state.resModelDescription = result[0].name;
|
||||
} catch (_) {
|
||||
// can not read the ir.model
|
||||
this.state.resModelId = 0;
|
||||
this.state.resModelDescription = sprintf(
|
||||
_lt('You do not have access to the model "%s".'),
|
||||
newModel
|
||||
);
|
||||
}
|
||||
|
||||
await this._updateMatchingRecordsCount();
|
||||
} else if (!newModel) {
|
||||
this.state.resModelId = false;
|
||||
this.state.resModelDescription = "";
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Update the number of records that match the current domain.
|
||||
*/
|
||||
async _updateMatchingRecordsCount() {
|
||||
if (this.state.resModelId && this.state.propertyDefinition.comodel) {
|
||||
const domainList = new Domain(
|
||||
this.state.propertyDefinition.domain || "[]"
|
||||
).toList();
|
||||
|
||||
const result = await this.orm.call(
|
||||
this.state.propertyDefinition.comodel,
|
||||
"search_count",
|
||||
[domainList]
|
||||
);
|
||||
|
||||
this.state.matchingRecordsCount = result;
|
||||
} else {
|
||||
this.state.matchingRecordsCount = undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the property label corresponding to the property type.
|
||||
*
|
||||
* @param {string} propertyType
|
||||
* @returns {string}
|
||||
*/
|
||||
_typeLabel(propertyType) {
|
||||
const allTypes = this.availablePropertyTypes;
|
||||
return allTypes.find((type) => type[0] === propertyType)[1];
|
||||
}
|
||||
}
|
||||
|
||||
PropertyDefinition.template = "web.PropertyDefinition";
|
||||
PropertyDefinition.components = {
|
||||
DomainSelector,
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
PropertyValue,
|
||||
Many2XAutocomplete,
|
||||
PropertyDefinitionSelection,
|
||||
PropertyTags,
|
||||
};
|
||||
PropertyDefinition.props = {
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
propertyDefinition: { optional: true },
|
||||
context: { type: Object },
|
||||
onChange: { type: Function, optional: true },
|
||||
onDelete: { type: Function, optional: true },
|
||||
onPropertyMove: { type: Function, optional: true },
|
||||
|
||||
// prop needed by the popover service
|
||||
close: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
.o_field_property_definition_delete {
|
||||
min-width: fit-content;
|
||||
}
|
||||
|
||||
.o_field_property_definition {
|
||||
max-height: 70vh;
|
||||
overflow-y: auto;
|
||||
|
||||
table {
|
||||
width: 500px;
|
||||
}
|
||||
|
||||
.o_td_label {
|
||||
width: 120px;
|
||||
}
|
||||
|
||||
.o_field_property_definition_header {
|
||||
max-width: 500px;
|
||||
|
||||
input {
|
||||
font-size: 1.2rem;
|
||||
border-width: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_field_property_definition_type {
|
||||
img {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.o_input_dropdown {
|
||||
input {
|
||||
background-repeat: no-repeat;
|
||||
background-size: contain;
|
||||
padding-left: 25px;
|
||||
background-position-x: 0px;
|
||||
background-position-y: 3px;
|
||||
background-size: 20px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
td {
|
||||
padding-bottom: 15px;
|
||||
}
|
||||
.o_field_property_definition_value {
|
||||
.form-check-input {
|
||||
margin-left: 3px;
|
||||
}
|
||||
}
|
||||
|
||||
.o_domain_debug_container {
|
||||
width: 100%;
|
||||
display: inline-block !important;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyDefinition" owl="1">
|
||||
<div class="o_field_property_definition o_field_widget d-block" t-ref="propertyDefinition">
|
||||
<div class="o_field_property_definition_header d-flex flex-row align-items-center justify-content-between m-3">
|
||||
<h6 t-if="props.readonly" t-out="state.propertyDefinition.string" class="text-truncate"/>
|
||||
<input
|
||||
t-else=""
|
||||
class="o_input px-2 me-3"
|
||||
type="text"
|
||||
required=""
|
||||
placeholder="Property Name"
|
||||
t-model="state.propertyDefinition.string"
|
||||
t-on-change="onPropertyLabelChange"
|
||||
/>
|
||||
<div class="d-flex flex-row me-3">
|
||||
<button
|
||||
class="btn btn-link p-0 m-0 me-2"
|
||||
t-on-click="() => props.onPropertyMove('up')">
|
||||
<i class="fa fa-chevron-up"/>
|
||||
</button>
|
||||
<button
|
||||
class="btn btn-link p-0 m-0"
|
||||
t-on-click="() => props.onPropertyMove('down')">
|
||||
<i class="fa fa-chevron-down"/>
|
||||
</button>
|
||||
<button
|
||||
t-if="!props.readonly && props.canChangeDefinition"
|
||||
class="btn text-danger p-0 float-end o_field_property_definition_delete ms-3"
|
||||
title="Remove Property"
|
||||
t-on-click="props.onDelete">
|
||||
<i class="fa fa-trash"/> Delete
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<table class="m-3">
|
||||
<tr>
|
||||
<td class="align-top pe-2">
|
||||
<b>Field Type</b>
|
||||
</td>
|
||||
<td class="o_field_property_definition_type">
|
||||
<div
|
||||
t-if="props.readonly"
|
||||
class="d-flex align-items-center">
|
||||
<img class="me-3"
|
||||
t-attf-src="/web/static/src/views/fields/properties/icons/{{state.propertyDefinition.type}}.png"/>
|
||||
<span t-out="state.typeLabel"/>
|
||||
</div>
|
||||
<Dropdown
|
||||
t-else=""
|
||||
title="state.typeLabel" togglerClass="'btn btn-link d-flex p-0 w-100'">
|
||||
<t t-set-slot="toggler">
|
||||
<div class="o_input_dropdown w-100 o_field_property_dropdown">
|
||||
<input type="text" class="dropdown text-start w-100 o_input py-1"
|
||||
t-att-value="state.typeLabel" readonly=""
|
||||
t-attf-style="background-image: url(/web/static/src/views/fields/properties/icons/{{state.propertyDefinition.type}}.png)"/>
|
||||
<a role="button" class="o_dropdown_button"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-foreach="availablePropertyTypes" t-as="option" t-key="option[0]">
|
||||
<DropdownItem onSelected.bind="() => this.onPropertyTypeChange(option[0])">
|
||||
<div class="d-flex align-items-center">
|
||||
<img class="me-2"
|
||||
t-attf-src="/web/static/src/views/fields/properties/icons/{{option[0]}}.png"/>
|
||||
<span t-out="option[1]"/>
|
||||
</div>
|
||||
</DropdownItem>
|
||||
</t>
|
||||
</Dropdown>
|
||||
</td>
|
||||
</tr>
|
||||
<!-- Add / remove selection labels -->
|
||||
<tr t-if="state.propertyDefinition.type === 'selection'">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Values</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyDefinitionSelection
|
||||
readonly="props.readonly || !props.canChangeDefinition"
|
||||
canChangeDefinition="props.canChangeDefinition"
|
||||
default="state.propertyDefinition.default || ''"
|
||||
options="state.propertyDefinition.selection || []"
|
||||
onOptionsChange.bind="onSelectionOptionChange"
|
||||
onDefaultOptionChange.bind="onDefaultChange"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="state.propertyDefinition.type === 'tags'">
|
||||
<td class="o_td_label align-top">
|
||||
<b>Tags</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyTags
|
||||
selectedTags="propertyTagValues"
|
||||
tags="state.propertyDefinition.tags || []"
|
||||
readonly="props.readonly"
|
||||
canChangeTags="props.canChangeDefinition"
|
||||
deleteAction="'tags'"
|
||||
onTagsChange.bind="onTagsChange"/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="state.propertyDefinition.type === 'many2one' || state.propertyDefinition.type === 'many2many'" class="o_field_property_definition_model">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Model</b>
|
||||
</td>
|
||||
<td>
|
||||
<span t-if="props.readonly">
|
||||
<t t-if="state.resModelDescription" t-out="state.resModelDescription"/>
|
||||
</span>
|
||||
<Many2XAutocomplete
|
||||
t-else=""
|
||||
value="state.resModelDescription || ''"
|
||||
id="state.resModelId && state.resModelId.toString() || ''"
|
||||
placeholder="'Model'"
|
||||
resModel="'ir.model'"
|
||||
fieldString="'Target Model'"
|
||||
activeActions="[]"
|
||||
update.bind="onModelChange"
|
||||
context="props.context"
|
||||
getDomain.bind="() => [['transient', '=', false]]"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="(state.propertyDefinition.type === 'many2one' || state.propertyDefinition.type === 'many2many')
|
||||
&& state.propertyDefinition.comodel && state.propertyDefinition.comodel.length">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b>Domain</b>
|
||||
</td>
|
||||
<td>
|
||||
<DomainSelector
|
||||
resModel="state.propertyDefinition.comodel"
|
||||
value="state.propertyDefinition.domain || '[]'"
|
||||
readonly="props.readonly"
|
||||
update.bind="onDomainChange"
|
||||
isDebugMode="!!env.debug"
|
||||
className="props.readonly ? 'o_read_mode d-inline-block me-2' : 'o_edit_mode d-inline-block me-2'"
|
||||
/>
|
||||
<div t-if="state.matchingRecordsCount !== undefined" class="d-inline-block">
|
||||
<i class="fa fa-arrow-right" role="img" title="Domain"/>
|
||||
<button class="btn btn-sm btn-secondary" type="button"
|
||||
t-on-click.stop="onButtonDomainClick">
|
||||
<t t-out="state.matchingRecordsCount"/> record(s)
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr t-if="!['tags', 'selection'].includes(state.propertyDefinition.type)
|
||||
&& (!['many2one', 'many2many'].includes(state.propertyDefinition.type)
|
||||
|| (state.propertyDefinition.comodel && state.propertyDefinition.comodel.length))"
|
||||
class="o_field_property_definition_value">
|
||||
<td class="o_td_label align-top pe-2">
|
||||
<b t-if="state.propertyDefinition.type === 'boolean'">
|
||||
Default State
|
||||
</b>
|
||||
<b t-else="">
|
||||
Default Value
|
||||
</b>
|
||||
</td>
|
||||
<td>
|
||||
<PropertyValue
|
||||
readonly="props.readonly"
|
||||
canChangeDefinition="props.canChangeDefinition"
|
||||
type="state.propertyDefinition.type"
|
||||
string="state.propertyDefinition.string"
|
||||
value="state.propertyDefinition.default"
|
||||
comodel="state.propertyDefinition.comodel || ''"
|
||||
domain="state.propertyDefinition.domain || '[]'"
|
||||
context="props.context"
|
||||
onChange.bind="onDefaultChange"
|
||||
/>
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,219 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { uuid } from "../../utils";
|
||||
|
||||
const { Component, useState, useRef, useEffect } = owl;
|
||||
|
||||
export class PropertyDefinitionSelection extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
|
||||
// when we create a new option, it's added in the state
|
||||
// when we have finished to edit it (blur / enter) we propagate
|
||||
// the new value in the props
|
||||
this.state = useState({ newOption: null });
|
||||
|
||||
this.propertyDefinitionSelectionRef = useRef(
|
||||
"propertyDefinitionSelection"
|
||||
);
|
||||
this.addButtonRef = useRef("addButton");
|
||||
|
||||
useEffect(() => {
|
||||
// automatically give the focus to the new option if it is empty
|
||||
const inputs =
|
||||
this.propertyDefinitionSelectionRef.el.querySelectorAll(
|
||||
".o_field_property_selection_option input"
|
||||
);
|
||||
if (inputs && inputs.length && !inputs[inputs.length - 1].value) {
|
||||
inputs[inputs.length - 1].focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the current available options.
|
||||
*
|
||||
* Make a deep copy to not change original object to be able to restore
|
||||
* the old props if we discard the editing of the forma view.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get options() {
|
||||
return JSON.parse(JSON.stringify(this.props.options || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options visible by the UI, include the newly created option if needed.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get optionsVisible() {
|
||||
const options = this.options || [];
|
||||
return this.state.newOption
|
||||
? [...options, this.state.newOption]
|
||||
: options;
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add a new empty selection option.
|
||||
*/
|
||||
onOptionCreate() {
|
||||
this.state.newOption = [uuid(), ""];
|
||||
}
|
||||
|
||||
/**
|
||||
* We changed an option label.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionChange(event, optionIndex) {
|
||||
const target = event.target;
|
||||
const newLabel = target.value;
|
||||
|
||||
if (
|
||||
this.options[optionIndex] &&
|
||||
this.options[optionIndex][1] === newLabel
|
||||
) {
|
||||
// do not update the props if we are already up to date
|
||||
// e.g. we pressed enter already and lost focus
|
||||
return;
|
||||
}
|
||||
|
||||
const options = this.optionsVisible;
|
||||
|
||||
if (!newLabel || !newLabel.length) {
|
||||
// if the label is empty, remove the option
|
||||
options.splice(optionIndex, 1);
|
||||
} else {
|
||||
options[optionIndex][1] = newLabel;
|
||||
}
|
||||
|
||||
const nonEmptyOptions = options.filter(
|
||||
(option) => option[1] && option[1].length
|
||||
);
|
||||
this.props.onOptionsChange(nonEmptyOptions);
|
||||
|
||||
if (
|
||||
this.state.newOption &&
|
||||
this.state.newOption[1] &&
|
||||
this.state.newOption[1].length
|
||||
) {
|
||||
// the new option has been propagated in the props
|
||||
this.state.newOption = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Loose focus on an option, should cancel the newly
|
||||
* created option if we didn't write on it.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionBlur(event, optionIndex) {
|
||||
if (event.target.value && event.target.value.length) {
|
||||
// losing the focus on an non-empty option should have no effect
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.relatedTarget === this.addButtonRef.el) {
|
||||
// lost the focus because we click on the add button
|
||||
// if the value is empty, just ignore and cancel the event
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
} else if (optionIndex >= this.options.length) {
|
||||
// we remove the focus from the new empty option, remove it
|
||||
this.state.newOption = null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* We pressed Enter on an option, add it if it's not
|
||||
* empty and automatically create a new one.
|
||||
*
|
||||
* Navigate using the up / down arrows.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionKeyDown(event, optionIndex) {
|
||||
if (event.key === "Enter") {
|
||||
const newLabel = event.target.value;
|
||||
|
||||
if (!newLabel || !newLabel.length) {
|
||||
// press enter on an empty option, just ignore it, nothing to save
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
this.onOptionChange(event, optionIndex);
|
||||
this.onOptionCreate();
|
||||
} else if (["ArrowUp", "ArrowDown"].includes(event.key)) {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
|
||||
if (event.key === "ArrowUp" && optionIndex > 0) {
|
||||
const previousInput = event.target
|
||||
.closest(".o_field_property_selection_option")
|
||||
.previousElementSibling.querySelector("input");
|
||||
previousInput.focus();
|
||||
} else if (
|
||||
event.key === "ArrowDown" &&
|
||||
optionIndex < this.optionsVisible.length - 1
|
||||
) {
|
||||
const nextInput = event.target
|
||||
.closest(".o_field_property_selection_option")
|
||||
.nextElementSibling.querySelector("input");
|
||||
nextInput.focus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the default selection option.
|
||||
*
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionSetDefault(optionIndex) {
|
||||
if (!this.props.canChangeDefinition) {
|
||||
return;
|
||||
}
|
||||
const newValue = this.optionsVisible[optionIndex][0];
|
||||
this.props.onDefaultOptionChange(
|
||||
newValue !== this.props.default ? newValue : false
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to remove the selection option.
|
||||
*
|
||||
* @param {integer} optionIndex
|
||||
*/
|
||||
onOptionDelete(optionIndex) {
|
||||
const options = this.optionsVisible;
|
||||
options.splice(optionIndex, 1);
|
||||
this.props.onOptionsChange(options);
|
||||
}
|
||||
}
|
||||
|
||||
PropertyDefinitionSelection.template = "web.PropertyDefinitionSelection";
|
||||
|
||||
PropertyDefinitionSelection.props = {
|
||||
default: { type: String, optional: true },
|
||||
options: {},
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
onOptionsChange: { type: Function, optional: true }, // we add / remove / rename an option
|
||||
onDefaultOptionChange: { type: Function, optional: true }, // we select a default value
|
||||
};
|
||||
@@ -0,0 +1,10 @@
|
||||
.o_field_property_selection {
|
||||
input {
|
||||
border: 0;
|
||||
background: transparent;
|
||||
}
|
||||
.fa-star,
|
||||
.fa-star-o {
|
||||
color: gold;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyDefinitionSelection" owl="1">
|
||||
<div class="d-flex flex-column o_field_property_selection"
|
||||
t-ref="propertyDefinitionSelection">
|
||||
<div class="w-100">
|
||||
<div
|
||||
t-foreach="optionsVisible" t-as="option" t-key="option[0]"
|
||||
class="w-100 card d-flex flex-row align-items-center justify-content-between my-2 px-3 rounded o_field_property_selection_option">
|
||||
<span t-if="props.readonly" t-out="option[1]"/>
|
||||
<input
|
||||
t-else=""
|
||||
class="o_input"
|
||||
type="text"
|
||||
placeholder="Option Name"
|
||||
t-att-value="option[1]"
|
||||
t-on-change="(event) => this.onOptionChange(event, option_index)"
|
||||
t-on-blur="(event) => this.onOptionBlur(event, option_index)"
|
||||
t-on-keydown="(event) => this.onOptionKeyDown(event, option_index)"
|
||||
/>
|
||||
<button
|
||||
class="btn btn-link text-secondary p-0 m-0"
|
||||
title="Select Default"
|
||||
t-on-click="() => this.onOptionSetDefault(option_index)">
|
||||
<i t-att-class="option[0] === props.default ? 'fa fa-star' : 'fa fa-star-o'"/>
|
||||
</button>
|
||||
<button
|
||||
t-if="!props.readonly"
|
||||
class="btn btn-link p-0 m-0 ms-2"
|
||||
title="Remove Property"
|
||||
t-on-click="() => this.onOptionDelete(option_index)">
|
||||
<i class="text-danger fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<button
|
||||
t-if="!props.readonly"
|
||||
class="btn btn-link btn-sm"
|
||||
t-on-click="onOptionCreate"
|
||||
t-ref="addButton">
|
||||
<i class="fa fa-plus"/>
|
||||
Add a Value
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,294 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { _lt } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
|
||||
import { ColorList } from "@web/core/colorlist/colorlist";
|
||||
import { usePopover } from "@web/core/popover/popover_hook";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import { sprintf } from "@web/core/utils/strings";
|
||||
|
||||
const { Component } = owl;
|
||||
|
||||
class PropertyTagsColorListPopover extends Component {}
|
||||
PropertyTagsColorListPopover.template = "web.PropertyTagsColorListPopover";
|
||||
PropertyTagsColorListPopover.components = {
|
||||
ColorList,
|
||||
};
|
||||
|
||||
export class PropertyTags extends Component {
|
||||
setup() {
|
||||
this.notification = useService("notification");
|
||||
this.popover = usePopover();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the list containing tags values and actions for the TagsList component.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get tagListItems() {
|
||||
if (!this.props.selectedTags || !this.props.selectedTags.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Retrieve the tags label and color
|
||||
// ['a', 'b'] => [['a', 'A', 5], ['b', 'B', 6]]
|
||||
const value = this.props.tags.filter(
|
||||
(tag) => this.props.selectedTags.indexOf(tag[0]) >= 0
|
||||
);
|
||||
|
||||
const canDeleteTag = !this.props.readonly && this.props.canChangeTags;
|
||||
|
||||
return value.map((tag) => {
|
||||
const [tagId, tagLabel, tagColorIndex] = tag;
|
||||
return {
|
||||
id: tagId,
|
||||
text: tagLabel,
|
||||
colorIndex: tagColorIndex || 0,
|
||||
onClick: (event) =>
|
||||
this.onTagClick(event, tagId, tagColorIndex),
|
||||
onDelete: canDeleteTag && (() => this.onTagDelete(tagId)),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current selected tags.
|
||||
* Make a deep copy to not make change on the original object
|
||||
* and to be able to discard change.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get selectedTags() {
|
||||
return JSON.parse(JSON.stringify(this.props.selectedTags || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the current tags that can be selected.
|
||||
* Make a deep copy to not make change on the original object
|
||||
* and to be able to discard change.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get availableTags() {
|
||||
return JSON.parse(JSON.stringify(this.props.tags || []));
|
||||
}
|
||||
|
||||
/**
|
||||
* Options available in the autocomplete component.
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get autocompleteSources() {
|
||||
return [
|
||||
{
|
||||
options: (request) => {
|
||||
const tagsFiltered = this.props.tags.filter(
|
||||
(tag) =>
|
||||
(!this.props.selectedTags ||
|
||||
this.props.selectedTags.indexOf(tag[0]) < 0) &&
|
||||
(!request ||
|
||||
!request.length ||
|
||||
tag[1]
|
||||
.toLocaleLowerCase()
|
||||
.indexOf(request.toLocaleLowerCase()) >= 0)
|
||||
);
|
||||
if (!tagsFiltered || !tagsFiltered.length) {
|
||||
// no result, ask the user if he want to create a new tag
|
||||
if (!request || !request.length) {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
label: _lt("Start typing..."),
|
||||
classList: "fst-italic",
|
||||
},
|
||||
];
|
||||
} else if (!this.props.canChangeTags) {
|
||||
return [
|
||||
{
|
||||
value: null,
|
||||
label: _lt("No result"),
|
||||
classList: "fst-italic",
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
value: { toCreate: true, value: request },
|
||||
label: sprintf(_lt('Create "%s"'), request),
|
||||
classList: "o_field_property_dropdown_add",
|
||||
},
|
||||
];
|
||||
}
|
||||
return tagsFiltered.map((tag) => {
|
||||
return {
|
||||
value: tag[0],
|
||||
label: tag[1],
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Add one value in the current tag list values.
|
||||
*
|
||||
* @param {string | object} tagValue
|
||||
* Either
|
||||
* - {toCreate: true, value: label}, to create a new value
|
||||
* - value, to select an existing value
|
||||
*/
|
||||
onOptionSelected(tagValue) {
|
||||
if (!tagValue) {
|
||||
// clicked on "Start typing..."
|
||||
return;
|
||||
}
|
||||
|
||||
if (tagValue.toCreate) {
|
||||
this.onTagCreate(tagValue.value);
|
||||
} else {
|
||||
const selectedTags = this.selectedTags;
|
||||
const newValue = [...selectedTags, tagValue];
|
||||
this.props.onValueChange(newValue);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to create a new tag that will be added in
|
||||
* the definition and automatically selected.
|
||||
*
|
||||
* @param {string} newLabel
|
||||
*/
|
||||
onTagCreate(newLabel) {
|
||||
if (!newLabel || !newLabel.length) {
|
||||
return;
|
||||
}
|
||||
|
||||
const newValue = newLabel
|
||||
? newLabel.toLowerCase().replace(" ", "_")
|
||||
: "";
|
||||
|
||||
const existingTag = this.props.tags.find((tag) => tag[0] === newValue);
|
||||
if (existingTag) {
|
||||
this.notification.add(_lt("This tag is already available"), {
|
||||
type: "warning",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// cycle trough colors
|
||||
const tagColor =
|
||||
this.props.tags && this.props.tags.length
|
||||
? (this.props.tags[this.props.tags.length - 1][2] + 1) %
|
||||
ColorList.COLORS.length
|
||||
: parseInt(Math.random() * ColorList.COLORS.length);
|
||||
|
||||
const newTag = [newValue, newLabel, tagColor];
|
||||
const updatedTags = [...this.availableTags, newTag];
|
||||
// automatically select the newly created tag
|
||||
const newValues = [...this.props.selectedTags, newTag[0]];
|
||||
this.props.onTagsChange(updatedTags, newValues);
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on the delete button on the tag pill.
|
||||
* The behavior is defined by the prop "deleteAction".
|
||||
*
|
||||
* If we use the component for the tag configuration, clicking on "delete"
|
||||
* will remove the tags from the available tags. If we use the component
|
||||
* the the tag selection, it will unselect the tag.
|
||||
*
|
||||
* @param {string} deleteTag, ID of the tag to delete
|
||||
*/
|
||||
onTagDelete(deleteTag) {
|
||||
if (this.props.deleteAction === "value") {
|
||||
// remove the tag from the value (but keep it in the options list)
|
||||
const selectedTags = this.selectedTags;
|
||||
const newValue = selectedTags.filter((tag) => tag !== deleteTag);
|
||||
this.props.onValueChange(newValue);
|
||||
} else {
|
||||
// remove the tag from the options
|
||||
const availableTags = this.availableTags;
|
||||
this.props.onTagsChange(
|
||||
availableTags.filter((tag) => tag[0] !== deleteTag)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Click on a tag pill, open the color popover if we can change the tag definition.
|
||||
*
|
||||
* @param {event} event
|
||||
* @param {string} tagId
|
||||
* @param {integer} tagColor
|
||||
*/
|
||||
onTagClick(event, tagId, tagColor) {
|
||||
if (!this.props.canChangeTags) {
|
||||
event.currentTarget.blur();
|
||||
return;
|
||||
}
|
||||
this.popoverCloseFn = this.popover.add(
|
||||
event.currentTarget,
|
||||
this.constructor.components.Popover,
|
||||
{
|
||||
colors: [...Array(ColorList.COLORS.length).keys()],
|
||||
tag: { id: tagId, colorIndex: tagColor },
|
||||
switchTagColor: this.onTagColorSwitch.bind(this),
|
||||
},
|
||||
{
|
||||
closeOnClickAway: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to change the color of a tag.
|
||||
*
|
||||
* @param {integer} colorIndex
|
||||
* @param {object} currentTag
|
||||
*/
|
||||
onTagColorSwitch(colorIndex, currentTag) {
|
||||
const availableTags = this.availableTags;
|
||||
availableTags.find((tag) => tag[0] === currentTag.id)[2] = colorIndex;
|
||||
this.props.onTagsChange(availableTags);
|
||||
|
||||
// close the color popover
|
||||
this.popoverCloseFn();
|
||||
this.popoverCloseFn = null;
|
||||
}
|
||||
}
|
||||
|
||||
PropertyTags.template = "web.PropertyTags";
|
||||
PropertyTags.components = {
|
||||
AutoComplete,
|
||||
TagsList,
|
||||
ColorList,
|
||||
Popover: PropertyTagsColorListPopover,
|
||||
};
|
||||
|
||||
PropertyTags.props = {
|
||||
selectedTags: {}, // Tags value visible in the tags list
|
||||
tags: {}, // Tags definition visible in the dropdown
|
||||
// Define the behavior of the delete button on the tags, either
|
||||
// "value" or "tags". If "value", the delete button will unselect
|
||||
// the value, if "tags" the value will be removed from the definition.
|
||||
deleteAction: { type: String },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeTags: { type: Boolean, optional: true },
|
||||
// Select a new value
|
||||
onValueChange: { type: Function, optional: true },
|
||||
// Change the tags definition (can also receive a second
|
||||
// argument to update the current selected value)
|
||||
onTagsChange: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,25 @@
|
||||
.o_field_property_dropdown_add {
|
||||
* {
|
||||
color: $primary !important;
|
||||
}
|
||||
}
|
||||
.o_field_property_tag_readonly {
|
||||
// deactivate all click interactions with tags
|
||||
// if we can't change their definition
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.o_field_property_tag {
|
||||
.o_tag {
|
||||
margin: 1px 2px 1px 0;
|
||||
.o_delete {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
.o_delete {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.o_badge_text {
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyTags" owl="1">
|
||||
<div class="o_field_property_tag">
|
||||
<TagsList
|
||||
tags="tagListItems"
|
||||
className="props.canChangeTags ? '' : 'o_field_property_tag_readonly' "/>
|
||||
<div
|
||||
t-if="!props.readonly"
|
||||
class="o_field_property_dropdown_menu o_input_dropdown mt-2">
|
||||
<AutoComplete
|
||||
value="''"
|
||||
sources="autocompleteSources"
|
||||
onSelect.bind="(event) => this.onOptionSelected(event.value)"/>
|
||||
<a role="button" class="o_dropdown_button" draggable="false" />
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-name="web.PropertyTagsColorListPopover" owl="1">
|
||||
<div class="o_tag_popover m-2">
|
||||
<ColorList
|
||||
colors="props.colors"
|
||||
forceExpanded="true"
|
||||
onColorSelected.bind="(id) => props.switchTagColor(id, props.tag)"/>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -0,0 +1,365 @@
|
||||
/** @odoo-module **/
|
||||
|
||||
import { Dropdown } from "@web/core/dropdown/dropdown";
|
||||
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
|
||||
import { CheckBox } from "@web/core/checkbox/checkbox";
|
||||
import { DateTimePicker, DatePicker } from "@web/core/datepicker/datepicker";
|
||||
import { Domain } from "@web/core/domain";
|
||||
import {
|
||||
Many2XAutocomplete,
|
||||
useOpenMany2XRecord,
|
||||
} from "@web/views/fields/relational_utils";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { TagsList } from "@web/views/fields/many2many_tags/tags_list";
|
||||
import { m2oTupleFromData } from "@web/views/fields/many2one/many2one_field";
|
||||
import { PropertyTags } from "./property_tags";
|
||||
import { AutoComplete } from "@web/core/autocomplete/autocomplete";
|
||||
import {
|
||||
formatFloat,
|
||||
formatInteger,
|
||||
formatMany2one,
|
||||
} from "@web/views/fields/formatters";
|
||||
import { formatDate, formatDateTime } from "@web/core/l10n/dates";
|
||||
|
||||
const { Component } = owl;
|
||||
const { DateTime } = luxon;
|
||||
|
||||
// Formats to stringify the date / datetime in the JSON.
|
||||
// It's important to have the year first, then the day,
|
||||
// etc... in UTC, to be able to search on them.
|
||||
const DEFAULT_SERVER_DATETIME_FORMAT = "yyyy-LL-dd HH:mm:ss";
|
||||
const DEFAULT_SERVER_DATE_FORMAT = "yyyy-LL-dd";
|
||||
|
||||
/**
|
||||
* Represent one property value.
|
||||
* Supports many types and instantiates the appropriate component for it.
|
||||
* - Text
|
||||
* - Integer
|
||||
* - Boolean
|
||||
* - Selection
|
||||
* - Datetime & Date
|
||||
* - Many2one
|
||||
* - Many2many
|
||||
* - Tags
|
||||
* - ...
|
||||
*/
|
||||
export class PropertyValue extends Component {
|
||||
setup() {
|
||||
this.orm = useService("orm");
|
||||
this.action = useService("action");
|
||||
|
||||
this.openMany2X = useOpenMany2XRecord({
|
||||
resModel: this.props.model,
|
||||
activeActions: {
|
||||
canCreate: false,
|
||||
canCreateEdit: false,
|
||||
canWrite: true,
|
||||
},
|
||||
isToMany: false,
|
||||
onRecordSaved: async (record) => {
|
||||
if (!record) {
|
||||
return;
|
||||
}
|
||||
// maybe the record display name has changed
|
||||
await record.load();
|
||||
const recordData = m2oTupleFromData(record.data);
|
||||
await this.onValueChange([
|
||||
{ id: recordData[0], name: recordData[1] },
|
||||
]);
|
||||
},
|
||||
fieldString: this.props.string,
|
||||
});
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Public methods / Getters
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Return the value of the current property,
|
||||
* that will be used by the sub-components.
|
||||
*
|
||||
* @returns {object}
|
||||
*/
|
||||
get propertyValue() {
|
||||
const value = this.props.value;
|
||||
|
||||
if (this.props.type === "float") {
|
||||
// force to show at least 1 digit, even for integers
|
||||
return value;
|
||||
} else if (this.props.type === "datetime") {
|
||||
if (typeof value === "string") {
|
||||
// convert the datetime from the UTC format to the current timezone
|
||||
const datetimeValue = DateTime.fromFormat(
|
||||
value + " +00:00",
|
||||
DEFAULT_SERVER_DATETIME_FORMAT + " Z"
|
||||
);
|
||||
return datetimeValue.invalid ? false : datetimeValue;
|
||||
}
|
||||
return value instanceof DateTime ? value : false;
|
||||
} else if (this.props.type === "date") {
|
||||
if (typeof value === "string") {
|
||||
const datetimeValue = DateTime.fromFormat(
|
||||
value + " +00:00",
|
||||
DEFAULT_SERVER_DATE_FORMAT + " Z"
|
||||
);
|
||||
return datetimeValue.invalid ? false : datetimeValue;
|
||||
}
|
||||
return value instanceof DateTime ? value : false;
|
||||
} else if (this.props.type === "boolean") {
|
||||
return !!value;
|
||||
} else if (this.props.type === "selection") {
|
||||
const options = this.props.selection || [];
|
||||
const option = options.find((option) => option[0] === value);
|
||||
return option && option.length === 2 && option[0] ? option[0] : "";
|
||||
} else if (this.props.type === "many2one") {
|
||||
return !value || value.length !== 2 || !value[0] ? false : value;
|
||||
} else if (this.props.type === "many2many") {
|
||||
if (!value || !value.length) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Convert to TagList component format
|
||||
return value.map((many2manyValue) => {
|
||||
return {
|
||||
id: many2manyValue[0],
|
||||
text: many2manyValue[1],
|
||||
onClick: async () =>
|
||||
await this._openRecord(
|
||||
this.props.comodel,
|
||||
many2manyValue[0]
|
||||
),
|
||||
onDelete:
|
||||
!this.props.readonly &&
|
||||
(() => this.onMany2manyDelete(many2manyValue[0])),
|
||||
colorIndex: 0,
|
||||
};
|
||||
});
|
||||
} else if (this.props.type === "tags") {
|
||||
return value || [];
|
||||
}
|
||||
|
||||
return value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the model domain (related to many2one and many2many properties).
|
||||
*
|
||||
* @returns {array}
|
||||
*/
|
||||
get propertyDomain() {
|
||||
if (!this.props.domain || !this.props.domain.length) {
|
||||
return [];
|
||||
}
|
||||
return new Domain(this.props.domain).toList();
|
||||
}
|
||||
|
||||
/**
|
||||
* Formatted value displayed in readonly mode.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
get displayValue() {
|
||||
const value = this.propertyValue;
|
||||
|
||||
if (this.props.type === "many2one" && value && value.length === 2) {
|
||||
return formatMany2one(value);
|
||||
} else if (!value) {
|
||||
return false;
|
||||
} else if (this.props.type === "datetime" && value) {
|
||||
return formatDateTime(value);
|
||||
} else if (this.props.type === "date" && value) {
|
||||
return formatDate(value);
|
||||
} else if (this.props.type === "selection") {
|
||||
return this.props.selection.find(
|
||||
(option) => option[0] === value
|
||||
)[1];
|
||||
} else if (this.props.type === "float") {
|
||||
return formatFloat(value);
|
||||
} else if (this.props.type === "integer") {
|
||||
return formatInteger(value);
|
||||
}
|
||||
return value.toString();
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Event handlers
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Parse the value received by the sub-components and trigger an onChange event.
|
||||
*
|
||||
* @param {object} newValue
|
||||
*/
|
||||
async onValueChange(newValue) {
|
||||
if (this.props.type === "datetime") {
|
||||
if (typeof newValue === "string") {
|
||||
newValue = DateTime.fromISO(newValue);
|
||||
}
|
||||
newValue = newValue
|
||||
.toUTC()
|
||||
.toFormat(DEFAULT_SERVER_DATETIME_FORMAT);
|
||||
} else if (this.props.type === "date") {
|
||||
if (typeof newValue === "string") {
|
||||
newValue = DateTime.fromISO(newValue);
|
||||
}
|
||||
newValue = newValue.toFormat(DEFAULT_SERVER_DATE_FORMAT);
|
||||
} else if (this.props.type === "integer") {
|
||||
newValue = parseInt(newValue) || 0;
|
||||
} else if (this.props.type === "float") {
|
||||
newValue = parseFloat(newValue) || 0;
|
||||
} else if (["many2one", "many2many"].includes(this.props.type)) {
|
||||
// {id: 5, name: 'Demo'} => [5, 'Demo']
|
||||
newValue =
|
||||
newValue && newValue.length && newValue[0].id
|
||||
? [newValue[0].id, newValue[0].name]
|
||||
: false;
|
||||
|
||||
if (newValue && newValue[0] && newValue[1] === undefined) {
|
||||
// The "Search More" option in the Many2XAutocomplete component
|
||||
// only return the record ID, and not the name. But we need to name
|
||||
// in the component props to be able to display it.
|
||||
// Make a RPC call to resolve the display name of the record.
|
||||
newValue = await this._nameGet(newValue[0]);
|
||||
}
|
||||
|
||||
if (this.props.type === "many2many" && newValue) {
|
||||
// add the record in the current many2many list
|
||||
const currentValue = this.props.value || [];
|
||||
const recordId = newValue[0];
|
||||
const exists = currentValue.find((rec) => rec[0] === recordId);
|
||||
if (exists) {
|
||||
return;
|
||||
}
|
||||
newValue = [...currentValue, newValue];
|
||||
}
|
||||
}
|
||||
|
||||
// trigger the onchange event to notify the parent component
|
||||
this.props.onChange(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the form view of the current record.
|
||||
*
|
||||
* @param {event} event
|
||||
*/
|
||||
async onMany2oneClick(event) {
|
||||
if (this.props.readonly) {
|
||||
event.stopPropagation();
|
||||
await this._openRecord(this.props.comodel, this.propertyValue[0]);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Open the current many2one record form view in a modal.
|
||||
*/
|
||||
onExternalLinkClick() {
|
||||
return this.openMany2X({
|
||||
resId: this.propertyValue[0],
|
||||
forceModel: this.props.comodel,
|
||||
context: this.context,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Removed a record from the many2many list.
|
||||
*
|
||||
* @param {integer} many2manyId
|
||||
*/
|
||||
onMany2manyDelete(many2manyId) {
|
||||
// deep copy
|
||||
const currentValue = JSON.parse(JSON.stringify(this.props.value || []));
|
||||
const newValue = currentValue.filter(
|
||||
(value) => value[0] !== many2manyId
|
||||
);
|
||||
this.props.onChange(newValue);
|
||||
}
|
||||
|
||||
/**
|
||||
* Ask to create a record from a relational property.
|
||||
*
|
||||
* @param {string} name
|
||||
* @param {object} params
|
||||
*/
|
||||
async onQuickCreate(name, params = {}) {
|
||||
if (params.triggeredOnBlur) {
|
||||
this.onValueChange(false);
|
||||
return;
|
||||
}
|
||||
const result = await this.orm.call(
|
||||
this.props.comodel,
|
||||
"name_create",
|
||||
[name],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
this.onValueChange([{ id: result[0], name: result[1] }]);
|
||||
}
|
||||
|
||||
/* --------------------------------------------------------
|
||||
* Private methods
|
||||
* -------------------------------------------------------- */
|
||||
|
||||
/**
|
||||
* Open the form view of the given record id / model.
|
||||
*
|
||||
* @param {string} recordModel
|
||||
* @param {integer} recordId
|
||||
*/
|
||||
async _openRecord(recordModel, recordId) {
|
||||
const action = await this.orm.call(
|
||||
recordModel,
|
||||
"get_formview_action",
|
||||
[[recordId]],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
|
||||
this.action.doAction(action);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the display name of the given record.
|
||||
* Model is taken from the current selected model.
|
||||
*
|
||||
* @param {string} recordId
|
||||
* @returns {array} [record id, record name]
|
||||
*/
|
||||
async _nameGet(recordId) {
|
||||
const result = await this.orm.call(
|
||||
this.props.comodel,
|
||||
"name_get",
|
||||
[[recordId]],
|
||||
{ context: this.props.context }
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
}
|
||||
|
||||
PropertyValue.template = "web.PropertyValue";
|
||||
|
||||
PropertyValue.components = {
|
||||
Dropdown,
|
||||
DropdownItem,
|
||||
CheckBox,
|
||||
DateTimePicker,
|
||||
DatePicker,
|
||||
Many2XAutocomplete,
|
||||
TagsList,
|
||||
AutoComplete,
|
||||
PropertyTags,
|
||||
};
|
||||
|
||||
PropertyValue.props = {
|
||||
type: { type: String, optional: true },
|
||||
comodel: { type: String, optional: true },
|
||||
domain: { type: String, optional: true },
|
||||
string: { type: String, optional: true },
|
||||
value: { optional: true },
|
||||
context: { type: Object },
|
||||
readonly: { type: Boolean, optional: true },
|
||||
canChangeDefinition: { type: Boolean, optional: true },
|
||||
selection: { type: Array, optional: true },
|
||||
tags: { type: Array, optional: true },
|
||||
onChange: { type: Function, optional: true },
|
||||
onTagsChange: { type: Function, optional: true },
|
||||
};
|
||||
@@ -0,0 +1,34 @@
|
||||
.o_field_property_dropdown_menu {
|
||||
.o-autocomplete--dropdown-menu {
|
||||
max-height: 200px!important;
|
||||
}
|
||||
}
|
||||
|
||||
// remove the down / up arrow for number input
|
||||
input.o_field_property_input::-webkit-outer-spin-button,
|
||||
input.o_field_property_input::-webkit-inner-spin-button {
|
||||
-webkit-appearance: none;
|
||||
margin: 0;
|
||||
}
|
||||
input.o_field_property_input[type=number] {
|
||||
-moz-appearance: textfield;
|
||||
}
|
||||
|
||||
.o_field_property_dropdown_empty {
|
||||
height: 25px;
|
||||
}
|
||||
|
||||
.o_field_property_many2many_value {
|
||||
.o_tag {
|
||||
margin: 1px 2px 1px 0;
|
||||
.o_delete {
|
||||
padding-left: 4px;
|
||||
}
|
||||
}
|
||||
.o_delete {
|
||||
margin-top: 2px;
|
||||
}
|
||||
.o_badge_text {
|
||||
line-height: 1.1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,129 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates xml:space="preserve">
|
||||
<t t-name="web.PropertyValue" owl="1">
|
||||
<t t-if="props.readonly && !['boolean', 'many2one', 'many2many', 'tags'].includes(props.type)">
|
||||
<span t-if="displayValue" t-out="displayValue"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<t t-if="props.type === 'boolean'">
|
||||
<CheckBox
|
||||
value="propertyValue"
|
||||
disabled="props.readonly"
|
||||
onChange.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'datetime'">
|
||||
<DateTimePicker
|
||||
date="propertyValue"
|
||||
onDateTimeChanged.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'date'">
|
||||
<DatePicker
|
||||
date="propertyValue"
|
||||
onDateTimeChanged.bind="onValueChange"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'integer'">
|
||||
<input
|
||||
class="o_input o_field_property_input"
|
||||
type="number"
|
||||
t-att-value="propertyValue"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'float'">
|
||||
<input
|
||||
class="o_input o_field_property_input"
|
||||
type="number"
|
||||
t-att-value="propertyValue"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
<t t-elif="props.type === 'selection'">
|
||||
<select class="o_input" t-on-change="event => this.onValueChange(event.target.value)">
|
||||
<option t-att-value="false" />
|
||||
<t t-foreach="props.selection || []" t-as="option" t-key="option[0]">
|
||||
<option
|
||||
t-att-selected="option[0] === propertyValue"
|
||||
t-att-value="option[0]"
|
||||
t-out="option[1]"
|
||||
/>
|
||||
</t>
|
||||
</select>
|
||||
</t>
|
||||
<t t-elif="props.type === 'tags'">
|
||||
<PropertyTags
|
||||
selectedTags="propertyValue"
|
||||
tags="props.tags || []"
|
||||
deleteAction="'value'"
|
||||
readonly="props.readonly"
|
||||
canChangeTags="props.canChangeDefinition"
|
||||
onValueChange.bind="onValueChange"
|
||||
onTagsChange.bind="props.onTagsChange"/>
|
||||
</t>
|
||||
<div
|
||||
t-elif="props.type === 'many2one'"
|
||||
class="d-flex flex-row">
|
||||
<t t-if="props.comodel">
|
||||
<a
|
||||
t-if="props.readonly"
|
||||
class="o_form_uri"
|
||||
t-att-href="propertyValue[0] ? `#id=${propertyValue[0]}&model=${props.comodel}` : '#'"
|
||||
t-on-click.prevent="onMany2oneClick">
|
||||
<span t-out="propertyValue && propertyValue[1] || ''" />
|
||||
</a>
|
||||
<Many2XAutocomplete
|
||||
t-else=""
|
||||
value="propertyValue && propertyValue[1] || ''"
|
||||
id="propertyValue[0] ? propertyValue[0].toString() : ''"
|
||||
resModel="props.comodel"
|
||||
autoSelect="true"
|
||||
fieldString="props.string"
|
||||
update.bind="onValueChange"
|
||||
context="props.context"
|
||||
getDomain.bind="() => propertyDomain"
|
||||
quickCreate.bind="onQuickCreate"
|
||||
activeActions="{canCreate: true, canCreateEdit: true, canWrite: true}"
|
||||
/>
|
||||
<button
|
||||
t-if="!props.readonly && propertyValue && propertyValue[0]"
|
||||
type="button"
|
||||
class="btn btn-secondary fa fa-external-link o_external_button o_properties_external_button py-0"
|
||||
tabindex="-1"
|
||||
draggable="false"
|
||||
aria-label="External link"
|
||||
data-tooltip="External link"
|
||||
t-on-click="onExternalLinkClick"
|
||||
/>
|
||||
</t>
|
||||
</div>
|
||||
<t t-elif="props.type === 'many2many'">
|
||||
<div t-if="props.comodel" class="o_field_property_many2many_value">
|
||||
<TagsList tags="propertyValue"/>
|
||||
<Many2XAutocomplete
|
||||
t-if="!props.readonly"
|
||||
id="''"
|
||||
resModel="props.comodel"
|
||||
autoSelect="true"
|
||||
fieldString="props.string"
|
||||
update.bind="onValueChange"
|
||||
context="props.context"
|
||||
getDomain.bind="() => propertyDomain"
|
||||
quickCreate.bind="onQuickCreate"
|
||||
activeActions="{canCreate: true, canCreateEdit: true, canWrite: true}"
|
||||
isToMany="true"
|
||||
/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<input
|
||||
class="o_input"
|
||||
type="text"
|
||||
t-att-value="propertyValue || ''"
|
||||
t-on-change="(event) => this.onValueChange(event.target.value)"
|
||||
/>
|
||||
</t>
|
||||
</t>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -352,10 +352,11 @@ export function useOpenMany2XRecord({
|
||||
const addDialog = useOwnedDialogs();
|
||||
const orm = useService("orm");
|
||||
|
||||
return async function openDialog({ resId = false, title, context }, immediate = false) {
|
||||
return async function openDialog({ resId = false, forceModel = null, title, context }, immediate = false) {
|
||||
const model = forceModel || resModel;
|
||||
let viewId;
|
||||
if (resId !== false) {
|
||||
viewId = await orm.call(resModel, "get_formview_id", [[resId]], {
|
||||
viewId = await orm.call(model, "get_formview_id", [[resId]], {
|
||||
context,
|
||||
});
|
||||
}
|
||||
@@ -379,7 +380,7 @@ export function useOpenMany2XRecord({
|
||||
context,
|
||||
mode,
|
||||
resId,
|
||||
resModel,
|
||||
resModel: model,
|
||||
viewId,
|
||||
onRecordSaved,
|
||||
isToMany,
|
||||
|
||||
@@ -252,3 +252,15 @@ export function stringToOrderBy(string) {
|
||||
export function toStringExpression(str) {
|
||||
return `\`${str.replaceAll("`", "\\`")}\``;
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a unique identifier.
|
||||
*
|
||||
* @returns {string}
|
||||
*/
|
||||
export function uuid() {
|
||||
const array = new Uint8Array(16);
|
||||
window.crypto.getRandomValues(array);
|
||||
// Uint8Array to hex
|
||||
return [...array].map(b => b.toString(16).padStart(2, '0')).join('');
|
||||
}
|
||||
|
||||