/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */

/* eslint-disable no-redeclare */
var Sao = {
    __version__: '7.2.7',
};
/* eslint-enable no-redeclare */

(function() {
    'use strict';

    // Browser compatibility: polyfill
    if (!('contains' in String.prototype)) {
        String.prototype.contains = function(str, startIndex) {
            return -1 !== String.prototype.indexOf.call(this, str, startIndex);
        };
    }
    if (!String.prototype.startsWith) {
        Object.defineProperty(String.prototype, 'startsWith', {
            enumerable: false,
            configurable: false,
            writable: false,
            value: function(searchString, position) {
                position = position || 0;
                return this.indexOf(searchString, position) === position;
            }
        });
    }
    if (!String.prototype.endsWith) {
        Object.defineProperty(String.prototype, 'endsWith', {
            enumerable: false,
            configurable: false,
            writable: false,
            value: function(searchString, position) {
                position = position || this.length;
                position = position - searchString.length;
                var lastIndex = this.lastIndexOf(searchString);
                return lastIndex !== -1 && lastIndex === position;
            }
        });
    }
    if (!String.prototype.padEnd) {
        String.prototype.padEnd = function padEnd(targetLength, padString) {
            targetLength = targetLength >> 0;
            padString = String(
                typeof padString !== 'undefined' ? padString : ' ');
            if (this.length > targetLength) {
                return String(this);
            } else {
                targetLength = targetLength - this.length;
                if (targetLength > padString.length) {
                    padString += padString.repeat(
                        targetLength / padString.length);
                }
                return String(this) + padString.slice(0, targetLength);
            }
        };
    }
    if (!String.prototype.padStart) {
        String.prototype.padStart = function padStart(targetLength, padString) {
            targetLength = targetLength >> 0;
            padString = String(
                typeof padString !== 'undefined' ? padString : ' ');
            if (this.length > targetLength) {
                return String(this);
            } else {
                targetLength = targetLength - this.length;
                if (targetLength > padString.length) {
                    padString += padString.repeat(
                        targetLength / padString.length);
                }
                return padString.slice(0, targetLength) + String(this);
            }
        };
    }
    if (!Array.prototype.some) {
        Array.prototype.some = function(fun /*, thisp */) {
            if (this === null) {
                throw new TypeError();
            }
            var thisp, i,
                t = Object(this),
                len = t.length >>> 0;
            if (typeof fun !== 'function') {
                throw new TypeError();
            }
            thisp = arguments[1];
            for (i = 0; i < len; i++) {
                if (i in t && fun.call(thisp, t[i], i, t)) {
                    return true;
                }
            }
            return false;
        };
    }

    if (!Array.from) {
        Array.from = function (value) {
            // Implementation is not strictly equivalent but works for most
            // cases
            var result = [];
            for (const e of value) {
                result.push(e);
            }
            return result;
        };
    }

    if (!Set.prototype.intersection) {
        Set.prototype.intersection = function(other) {
            if (this === null) {
                throw new TypeError();
            }
            const result = new Set();
            for (const key of other.keys()) {
                if (this.has(key)) {
                    result.add(key)
                }
            }
            return result;
        }
    }

    if (!Set.prototype.isSubsetOf) {
        Set.prototype.isSubsetOf = function(other) {
            if (this === null) {
                throw new TypeError();
            }
            for (const key of this.keys()) {
                if (!other.has(key)) {
                    return false;
                }
            }
            return true;
        }
    }

    Sao.setdefault = function(object, key, value) {
        if (!Object.prototype.hasOwnProperty.call(object, key)) {
            object[key] = value;
        }
        return object[key];
    };

    // Ensure RichText doesn't use style with css
    try {
        document.execCommand('styleWithCSS', false, false);
    } catch (e) {
        // continue
    }
    try {
        document.execCommand('useCSS', false, true);
    } catch (e) {
        // continue
    }

    // Add .uniqueId to jQuery
    jQuery.fn.extend({
        uniqueId: (function() {
            var uuid = 0;
            return function() {
                return this.each(function() {
                    if (!this.id) {
                        this.id = "ui-id-" + (++uuid);
                    }
                });
            };
        })()
    });

    window.onbeforeunload = function(e) {
        if (Sao.main_menu_screen) {
            Sao.main_menu_screen.save_tree_state(true);
        }
        if (Sao.Tab.tabs.length) {
            var dialog = Sao.i18n.gettext("Are your sure to leave?");
            e.returnValue = dialog;
            return dialog;
        }
    };

    Sao.class_ = function(Parent, props) {
        var ClassConstructor = function() {
            if (!(this instanceof ClassConstructor))
                throw new Error('Constructor function requires new operator');
            this.Class = ClassConstructor;
            if (this.init) {
                this.init.apply(this, arguments);
            }
        };

        // Plug prototype chain
        ClassConstructor.prototype = Object.create(Parent.prototype);
        ClassConstructor._super = Parent.prototype;
        if (props) {
            for (var name in props) {
                Object.defineProperty(ClassConstructor.prototype, name,
                    Object.getOwnPropertyDescriptor(props, name));
            }
        }


        // Method to create new instance with a list of arguments
        function F(args) {
            return ClassConstructor.apply(this, args);
        }
        F.prototype = ClassConstructor.prototype;
        ClassConstructor.new_ = function(args) {
            return new F(args);
        };
        return ClassConstructor;
    };

    Sao.Logging = Sao.class_(Object, {
        init: function() {
            this.level = Sao.Logging.ERROR;
        },
        set_level: function(level) {
            this.level = level;
        },
        _log: function(level, logger, args) {
            if (this.level <= level) {
                logger.apply(console, args);
            }
        },
        debug: function() {
            this._log(Sao.Logging.DEBUG, console.log, arguments);
        },
        info: function() {
            this._log(Sao.Logging.INFO, console.info, arguments);
        },
        warn: function() {
            this._log(Sao.Logging.WARNING, console.warn, arguments);
        },
        error: function() {
            this._log(Sao.Logging.ERROR, console.error, arguments);
        },
        critical: function() {
            this._log(Sao.Logging.CRITICAL, console.error, arguments);
        },
        assert: function() {
            if (this.level <= Sao.Logging.DEBUG) {
                console.assert.apply(console, arguments);
            }
        },
    });
    Sao.Logging.CRITICAL = 50;
    Sao.Logging.ERROR = 40;
    Sao.Logging.WARNING = 30;
    Sao.Logging.INFO = 20;
    Sao.Logging.DEBUG = 10;
    Sao.Logging.NOTSET = 0;
    Sao.Logger = new Sao.Logging();

    Sao.Decimal = Number;

    var _moment_to_string = moment.prototype.toString;
    moment.prototype.toString = function() {
        if (this.isDate) {
            return this.format('YYYY-MM-DD');
        } else if (this.isDateTime) {
            if (this.milliseconds()) {
                return this.format('YYYY-MM-DD HH:mm:ss.SSSSSS');
            } else {
                return this.format('YYYY-MM-DD HH:mm:ss');
            }
        } else if (this.isTime) {
            if (this.milliseconds()) {
                return this.format('HH:mm:ss.SSSSSS');
            } else {
                return this.format('HH:mm:ss');
            }
        } else {
            return _moment_to_string.call(this);
        }
    };

    Sao.Date = function(year, month, day) {
        var date;
        if (month === undefined) {
            date = moment(year);
            year = undefined;
        }
        else {
            date = moment();
        }
        date.year(year);
        date.month(month);
        date.date(day);
        date.set({hour: 0, minute: 0, second: 0, millisecond: 0});
        date.isDate = true;
        return date;
    };

    // Add 1 day to the limit because setting time make it out of the range
    Sao.Date.min = moment(new Date((-100000000 + 1) * 86400000));
    Sao.Date.min.set({hour: 0, minute: 0, second: 0, millisecond: 0});
    Sao.Date.min.isDate = true;
    Sao.Date.max = moment(new Date(100000000 * 86400000));
    Sao.Date.max.set({hour: 0, minute: 0, second: 0, millisecond: 0});
    Sao.Date.max.isDate = true;

    Sao.DateTime = function(
        year, month, day,
        hour=0, minute=0, second=0, millisecond=0, utc=false) {
        var datetime;
        if (month === undefined) {
            datetime = moment(year);
            year = undefined;
        }
        else {
            datetime = moment();
        }
        if (utc) {
            datetime.utc();
        }
        datetime.year(year);
        datetime.month(month);
        datetime.date(day);
        if (month !== undefined) {
            datetime.hour(hour);
            datetime.minute(minute);
            datetime.second(second);
            datetime.milliseconds(millisecond);
        }
        datetime.isDateTime = true;
        datetime.local();
        datetime.todate = function() {
            return Sao.Date(this.year(), this.month(), this.date());
        };
        datetime.totime = function() {
            return Sao.Time(
                this.hour(), this.minute(), this.second(), this.millisecond());
        };
        return datetime;
    };

    Sao.DateTime.combine = function(date, time) {
        return Sao.DateTime(
            date.year(), date.month(), date.date(),
            time.hour(), time.minute(), time.second(), time.millisecond());
    };

    Sao.DateTime.min = moment(new Date(-100000000 * 86400000)).local();
    Sao.DateTime.min.isDateTime = true;
    Sao.DateTime.max = moment(new Date(100000000 * 86400000)).local();
    Sao.DateTime.max.isDateTime = true;

    Sao.Time = function(hour, minute, second, millisecond) {
        var time = moment({hour: hour, minute: minute, second: second,
           millisecond: millisecond || 0});
        time.isTime = true;
        return time;
    };

    Sao.TimeDelta = function(days, seconds,
            milliseconds, minutes, hours, weeks) {
        var timedelta = moment.duration({
            days: days,
            seconds: seconds,
            milliseconds: milliseconds,
            minutes: minutes,
            hours: hours,
            weeks: weeks
        });
        timedelta.isTimeDelta = true;
        return timedelta;
    };

    Sao.config = {};
    Sao.config.limit = 1000;
    Sao.config.display_size = 20;
    Sao.config.use_ag_grid = true;
    var doc_version = Sao.__version__.split('.').slice(0, 2);
    if (parseInt(doc_version[1], 10) % 2) {
        doc_version = 'latest';
    } else {
        doc_version = doc_version.join('.');
    }
    Sao.config.doc_url = `https://docs.tryton.org/${doc_version}`;
    Sao.config.bug_url = 'https://bugs.tryton.org/';
    Sao.config.title = "Trad'on";
    Sao.config.icon_colors = '#267f82,#3e4950,#e78e42'.split(',');
    Sao.config.calendar_colors = '#fff,#267f82'.split(',');
    Sao.config.graph_color = '#267f82';
    Sao.config.bus_timeout = 10 * 60 * 1000;
    Sao.config.image_max_size = Math.pow(10, 6);
    Sao.config.hide_filters = window.SAO_HIDE_FILTERS !== undefined ?
        Boolean(window.SAO_HIDE_FILTERS) : true;
    Sao.set_hide_filters = function(hidden) {
        Sao.config.hide_filters = Boolean(hidden);
        if (document && document.documentElement) {
            document.documentElement.classList.toggle(
                'sao-hide-filters', Sao.config.hide_filters);
        }
    };
    Sao.set_hide_filters(Sao.config.hide_filters);

    Sao.i18n = i18n();
    Sao.i18n.setlang = function(lang) {
        if (!lang) {
            lang = (navigator.language ||
                 navigator.browserLanguage ||
                 navigator.userLanguage ||
                 'en').replace('-', '_');
        }
        jQuery('html').attr('lang', lang);
        Sao.i18n.setLocale(lang);
        moment.locale(lang.slice(0, 2));
        return jQuery.getJSON('locale/' + lang + '.json').then(function(data) {
            if (!data[''].language) {
                data[''].language = lang;
            }
            if (!data['']['plural-forms']) {
                data['']['plural-forms'] = 'nplurals=2; plural=(n!=1);';
            }
            // gettext.js requires to dump untranslated keys
            for (var key in data) {
                if ('' === key) {
                    continue;
                }
                data[key] = 2 == data[key].length ? data[key][1] : data[key].slice(1);
            }
            Sao.i18n.loadJSON(data);
        }, function() {
            if (~lang.indexOf('_')) {
                return Sao.i18n.setlang(lang.split('_').slice(0, -1).join('_'));
            }
        });
    };
    Sao.i18n.getlang = function() {
        return Sao.i18n.getLocale();
    };
    Sao.i18n.BC47 = function(lang) {
        return lang.replace('_', '-');
    };
    Sao.i18n.set_direction = function(direction) {
        Sao.i18n.rtl = (direction === 'rtl');
        jQuery('html').attr('dir', direction);
        jQuery('.row-offcanvas')
            .removeClass('row-offcanvas-left row-offcanvas-right')
            .addClass(Sao.i18n.rtl ? 'row-offcanvas-right' : 'row-offcanvas-left');
    };
    Sao.i18n.locale = {};

    Sao.BOM_UTF8 = '\uFEFF';

    Sao.get_preferences = function() {
        var session = Sao.Session.current_session;
        return session.reload_context().then(function() {
            return Sao.rpc({
                'method': 'model.res.user.get_preferences',
                'params': [false, {}]
            }, session).then(function(preferences) {
                var deferreds = [];
                deferreds.push(Sao.common.MODELACCESS.load_models());
                deferreds.push(Sao.common.MODELHISTORY.load_history());
                deferreds.push(Sao.common.MODELNOTIFICATION.load_names());
                deferreds.push(Sao.common.VIEW_SEARCH.load_searches());
                return jQuery.when.apply(jQuery, deferreds).then(function() {
                    for (const action_id of (preferences.actions || [])) {
                        Sao.Action.execute(action_id, {}, null, {});
                    }
                    Sao.set_title();
                    var new_lang = preferences.language != Sao.i18n.getLocale();
                    var prm = jQuery.Deferred();
                    Sao.i18n.setlang(preferences.language).always(function() {
                        if (new_lang) {
                            Sao.user_menu(preferences);
                        }
                        prm.resolve(preferences);
                    });
                    Sao.i18n.set_direction(preferences.language_direction);
                    Sao.i18n.locale = preferences.locale;
                    Sao.common.MODELNAME.clear();
                    return prm;
                });
            });
        });
    };
    Sao.set_title = function(name) {
        var title = [name, Sao.config.title];
        document.title = title.filter(function(e) {return e;}).join(' - ');
        
        // Remplacer le texte par une image avec le bon style
        var logo_src = 'dist/tradon_wbg.png';
        var app_version = 'v1.3';
        if (jQuery('#title').length) {
            jQuery('#title').replaceWith(
                '<img id="title-logo" src="' + logo_src + '" alt="Logo" title="' + app_version + '" style="height: 24px; display: inline-block; vertical-align: middle; margin-left: 8px;">'
            );
        } else if (jQuery('#title-logo').length) {
            jQuery('#title-logo')
                .attr('src', logo_src)
                .attr('title', app_version);
        }
        
        // Ajouter un style au conteneur parent si nécessaire
        jQuery('.navbar-brand').css({
            'display': 'flex',
            'align-items': 'center'
        });
    };
    
    Sao.set_url = function(path, name) {
        var session = Sao.Session.current_session;
        if (session) {
            var url = '#' + session.database;
            if (path) {
                url += '/' + path;
            }
            window.location = url;
        }
        Sao.set_title(name);
    };

    window.onhashchange = function() {
        var session = Sao.Session.current_session;
        if (!session) {
            return;
        }
        var url,
            database = '#' + session.database;
        if (window.location.hash == database) {
            url = '';
        } else if (window.location.hash.startsWith(database + '/')) {
            url = window.location.hash.substr(database.length + 1);
        } else {
            return;
        }
        var tab;
        if (!url) {
            tab = Sao.Tab.tabs.get_current();
            if (tab) {
                Sao.set_url(tab.get_url(), tab.name);
            }
        } else {
            url = decodeURIComponent(url);
            for (const tab of Sao.Tab.tabs) {
                if (decodeURIComponent(tab.get_url()) == url) {
                    tab.show();
                    return;
                }
            }
            Sao.open_url();
        }
    };

    Sao.open_url = function(url) {
        function loads(value) {
            return Sao.rpc.convertJSONObject(jQuery.parseJSON(value));
        }
        if (url === undefined) {
            url = window.location.hash.substr(1);
        }
        var i = url.indexOf(';');
        var path, params = {};
        if (i >= 0) {
            path = url.substring(0, i);
            for (const part of url.substring(i + 1).split('&')) {
                if (part) {
                    var item = part.split('=').map(decodeURIComponent);
                    params[item[0]] = item[1];
                }
            }
        } else {
            path = url;
        }
        path = path.split('/').slice(1);
        var type = path.shift();

        function open_model(path) {
            var attributes = {};
            attributes.model = path.shift();
            if (!attributes.model) {
                return;
            }
            try {
                attributes.view_ids = loads(params.views || '[]');
                if (params.limit !== undefined) {
                    attributes.limit = loads(params.limit || 'null');
                }
                attributes.name = loads(params.name || '""');
                attributes.search_value = loads(params.search_value || '[]');
                attributes.domain = loads(params.domain || '[]');
                attributes.context = loads(params.context || '{}');
                attributes.context_model = params.context_model;
                attributes.tab_domain = loads(params.tab_domain || '[]');
            } catch (e) {
                return;
            }
            var res_id = path.shift();
            if (res_id) {
                res_id = Number(res_id);
                if (isNaN(res_id)) {
                    return;
                }
                attributes.res_id = res_id;
                attributes.mode = ['form', 'tree'];
            }
            try {
                Sao.Tab.create(attributes);
            } catch (e) {
                // Prevent crashing the client
                return;
            }
        }
        function open_wizard(path) {
            var attributes = {};
            attributes.action = path[0];
            if (!attributes.action) {
                return;
            }
            try {
                attributes.data = loads(params.data || '{}');
                attributes.direct_print = loads(params.direct_print || 'false');
                attributes.name = loads(params.name || '""');
                attributes.window = loads(params.window || 'false');
                attributes.context = loads(params.context || '{}');
            } catch (e) {
                return;
            }
            try {
                Sao.Wizard.create(attributes);
            } catch (e) {
                // Prevent crashing the client
                return;
            }
        }
        function open_report(path) {
            var attributes = {};
            attributes.name = path[0];
            if (!attributes.name) {
                return;
            }
            try {
                attributes.data = loads(params.data || '{}');
                attributes.direct_print = loads(params.direct_print || 'false');
                attributes.context = loads(params.context || '{}');
            } catch (e) {
                return;
            }
            try {
                Sao.Action.exec_report(attributes);
            } catch (e) {
                // Prevent crashing the client
                return;
            }
        }
        function open_url() {
            var url;
            try {
                url = loads(params.url || 'false');
            } catch (e) {
                return;
            }
            if (url) {
                window.open(url, '_blank', 'noreferrer,noopener');
            }
        }

        switch (type) {
            case 'model':
                open_model(path);
                break;
            case 'wizard':
                open_wizard(path);
                break;
            case 'report':
                open_report(path);
                break;
            case 'url':
                open_url();
                break;
        }
    };

    Sao.login = function() {
        Sao.set_title();
        (function loadInterFont() {
            const link = document.createElement('link');
            link.href = 'https://fonts.googleapis.com/css2?family=Inter:wght@400;600&display=swap';
            link.rel = 'stylesheet';
            document.head.appendChild(link);
        })();
        Sao.i18n.setlang().always(function() {
            Sao.Session.server_version()
                .then(function(version) {
                    if (JSON.stringify(version.split('.').slice(0, 2)) !==
                        JSON.stringify(Sao.__version__.split('.').slice(0, 2)))
                    {
                        Sao.common.warning.run(
                            Sao.i18n.gettext(
                                "Incompatible version of the server."),
                            Sao.i18n.gettext("Version mismatch"));
                    } else {
                        Sao.Session.get_credentials()
                            .then(function(session) {
                                Sao.Session.current_session = session;
                                return session.reload_context();
                            })
                            .then(Sao.get_preferences)
                            .then(function(preferences) {
                                Sao.menu(preferences);
                                Sao.user_menu(preferences);
                                Sao.open_url();
                                Sao.Bus.listen();
                            });
                    }
                }, function() {
                    Sao.common.warning.run(
                        Sao.i18n.gettext(
                            "Could not connect to the server."),
                        Sao.i18n.gettext("Connection error"));
                });
        });
    };

    Sao.logout = function() {
        var session = Sao.Session.current_session;
        if (!session || !session.session) {
            // Do not save states if there is no session
            Sao.main_menu_screen = null;
        }
        Sao.Tab.tabs.close(true).done(function() {
            jQuery('#user-preferences').empty();
            jQuery('#global-search').empty();
            jQuery('#menu').empty();
            session.do_logout().always(Sao.login);
            Sao.set_title();
        });
    };

    Sao.preferences = function() {
        return Sao.Tab.tabs.close(true).then(function() {
            jQuery('#user-preferences').empty();
            jQuery('#global-search').empty();
            jQuery('#menu').empty();
            new Sao.Window.Preferences(function() {
                Sao.Session.current_session.reset_context();
                Sao.get_preferences().then(function(preferences) {
                    Sao.menu(preferences);
                    Sao.user_menu(preferences);
                });
            });
        });
    };
    Sao.favorites_menu = function() {
        jQuery(window).click(function() {
            Sao.favorites_menu_clear();
        });
        if (!jQuery('#user-favorites').children('.dropdown-menu').length) {
            var name = Sao.main_menu_screen.model_name + '.favorite';
            var session = Sao.Session.current_session;
            var args = {
                'method': 'model.' + name + '.get',
            };
            var menu = jQuery('<ul/>', {
                'class': 'dropdown-menu',
                'aria-expanded': 'false',
                'aria-labelledby': 'user-favorites',
            });
            jQuery('#user-favorites').append(menu);
            Sao.rpc(args, session).then(function(fav) {
                fav.forEach(function(menu_item) {
                    var a = jQuery('<a/>', {
                        'href': '#'
                    });
                    var id = menu_item[0];
                    var li = jQuery('<li/>', {
                        'role': 'presentation'
                    });
                    var icon = Sao.common.ICONFACTORY.get_icon_img(
                        menu_item[2], {'class': 'favorite-icon'});
                    a.append(icon);
                    li.append(a);
                    a.append(menu_item[1]);
                    a.click(function(evt) {
                        evt.preventDefault();
                        Sao.favorites_menu_clear();
                        // ids is not defined to prevent to add suffix
                        Sao.Action.exec_keyword('tree_open', {
                            'model': Sao.main_menu_screen.model_name,
                            'id': id,
                        });
                    });
                    menu.append(li);
                });
                menu.append(jQuery('<li/>', {
                        'class': 'divider'
                }));
                jQuery('<li/>', {
                    'role': 'presentation'
                }).append(jQuery('<a/>', {
                        'href': '#'
                    }).click(function(evt) {
                        evt.preventDefault();
                        Sao.favorites_menu_clear();
                        Sao.Tab.create({
                            'model': Sao.main_menu_screen.model_name +
                            '.favorite',
                            'mode': ['tree', 'form'],
                            'name': Sao.i18n.gettext('Favorites')
                        });
                    }).text(Sao.i18n.gettext('Manage...'))).appendTo(
                       menu);
            });
        }
    };
    Sao.favorites_menu_clear = function() {
        jQuery('#user-favorites').children('.dropdown-menu').remove();
    };

    Sao.user_menu = function(preferences) {
        jQuery('#user-preferences').empty();
        var user = jQuery('<a/>', {
            'href': '#',
            'title': preferences.status_bar,
        }).click(function(evt) {
            evt.preventDefault();
            user.prop('disabled', true);
            Sao.preferences().then(() => user.prop('disabled', false));
        }).text(preferences.status_bar);
        jQuery('#user-preferences').append(user);
        if (preferences.avatar_badge_url) {
            user.prepend(jQuery('<img/>', {
                'src': preferences.avatar_badge_url + '?s=15',
                'class': 'img-circle img-badge',
            }));
        }
        if (preferences.avatar_url) {
            user.prepend(jQuery('<img/>', {
                'src': preferences.avatar_url + '?s=30',
                'class': 'img-circle',
            }));
        }
        var title = Sao.i18n.gettext("Logout");
        jQuery('#user-logout > a')
            .attr('title', title)
            .attr('aria-label', title)
            .off()
            .click(Sao.logout)
            .find('span:not(.icon)').text(title);
    };

    Sao.main_menu_row_activate = function() {
        var screen = Sao.main_menu_screen;
        const id = screen.get_id();
        if (id) {
            // ids is not defined to prevent to add suffix
            Sao.Action.exec_keyword('tree_open', {
                'model': screen.model_name,
                'id': screen.get_id(),
            }, null, false);
        }
    };

    Sao.menu = function(preferences) {
        if (!preferences) {
            var session = Sao.Session.current_session;
            Sao.rpc({
                'method': 'model.res.user.get_preferences',
                'params': [false, {}],
            }, session).then(Sao.menu);
            return;
        }
        var decoder = new Sao.PYSON.Decoder();
        var action = decoder.decode(preferences.pyson_menu);
        var view_ids = false;
        if (!jQuery.isEmptyObject(action.views)) {
            view_ids = action.views.map(function(view) {
                return view[0];
            });
        } else if (action.view_id) {
            view_ids = [action.view_id[0]];
        }
        decoder = new Sao.PYSON.Decoder(Sao.Session.current_session.context);
        var action_ctx = decoder.decode(action.pyson_context || '{}');
        var domain = decoder.decode(action.pyson_domain);
        var form = new Sao.Tab.Form(action.res_model, {
            'mode': ['tree'],
            'view_ids': view_ids,
            'domain': domain,
            'context': action_ctx,
            'selection_mode': Sao.common.SELECTION_NONE,
            'limit': null,
            'row_activate': Sao.main_menu_row_activate,
        });
        Sao.main_menu_screen = form.screen;
        Sao.main_menu_screen.switch_callback = null;
        Sao.Tab.tabs.splice(Sao.Tab.tabs.indexOf(form), 1);
        form.view_prm.done(function() {
            var view = form.screen.current_view;
            view.table.removeClass('table table-bordered');
            view.table.addClass('no-responsive');
            view.table.find('thead').hide();
            view.table.find('colgroup > col.tree-menu').css('width', 0);
            var gs = new Sao.GlobalSearch();
            jQuery('#global-search').empty();
            jQuery('#global-search').append(gs.el);
            jQuery('#menu').empty();
            jQuery('#menu').append(
                form.screen.screen_container.content_box.detach());
            var column = new FavoriteColumn(form.screen.model.fields.favorite);
            form.screen.views[0].table.find('> colgroup').append(column.col);
            form.screen.views[0].table.find('> thead > tr').append(column.header);
            form.screen.views[0].columns.push(column);
            jQuery(document).trigger('tradon:main-menu-ready');
        });
    };
    Sao.main_menu_screen = null;

    var FavoriteColumn = Sao.class_(Object, {
        init: function(favorite) {
            this.field = favorite;
            this.col = jQuery('<col/>', {
                'class': 'favorite',
            });
            this.header = jQuery('<th/>');
            this.footers = [];
            this.attributes = jQuery.extend({}, this.field.description);
            this.attributes.name = this.field.name;

        },
        get_cell: function() {
            var cell = jQuery('<img/>', {
                'class': 'column-affix',
                'tabindex': 0,
            });
            return cell;
        },
        render: function(record, cell) {
            if (!cell) {
                cell = this.get_cell();
            }
            record.load(this.field.name).done(() => {
                if (record._values.favorite !== null) {
                    var icon = 'tryton-star';
                    if (!record._values.favorite) {
                        icon += '-border';
                    }
                    cell.data('star', Boolean(record._values.favorite));
                    Sao.common.ICONFACTORY.get_icon_url(icon)
                        .then(url => {
                            cell.attr('src', url);
                        });
                    cell.click({'record': record, 'button': cell},
                        this.favorite_click);
                    }
                });
            return cell;
        },
        set_visible: function() {
        },
        get_visible: function() {
            return true;
        },
        favorite_click: function(e) {
            // Prevent activate the action of the row
            e.stopImmediatePropagation();
            var button = e.data.button;
            var method, icon;
            var star = button.data('star');
            if (!star) {
                icon = 'tryton-star';
                method = 'set';
            } else {
                icon = 'tryton-star-border';
                method = 'unset';
            }
            button.data('star', !star);
            Sao.common.ICONFACTORY.get_icon_url(icon)
                .then(function(url) {
                    button.attr('src', url);
                });
            var name = Sao.main_menu_screen.model_name + '.favorite';
            var session = Sao.Session.current_session;
            var args = {
                'method': 'model.' + name + '.' + method,
                'params': [e.data.record.id, session.context]
            };
            Sao.rpc(args, session);
            Sao.favorites_menu_clear();
        }
    });

    Sao.Dialog = Sao.class_(Object, {
        init: function(
            title, class_, size='sm', keyboard=true, small=null) {
            this.modal = jQuery('<div/>', {
                'class': class_ + ' modal fade',
                'role': 'dialog',
                'data-backdrop': 'static',
                'data-keyboard': keyboard,
            });
            this.content = jQuery('<form/>', {
                'class': 'modal-content'
            }).appendTo(jQuery('<div/>', {
                'class': 'modal-dialog modal-' + size
            }).appendTo(this.modal));
            this.header = jQuery('<div/>', {
                'class': 'modal-header'
            }).appendTo(this.content);
            if (title) {
                this.add_title(title, small);
            }
            this.body = jQuery('<div/>', {
                'class': 'modal-body'
            }).appendTo(this.content);
            this.footer = jQuery('<div/>', {
                'class': 'modal-footer'
            }).appendTo(this.content);

            this.modal.on('shown.bs.modal', function() {
                var currently_focused = jQuery(document.activeElement);
                var has_focus = currently_focused.closest(this.el) > 0;
                if (!has_focus) {
                    jQuery(this).find(':input:visible' +
                        ':not([readonly]):not([tabindex^="-"]):first')
                        .focus();
                }
            });
        },
        add_title: function(title, small=null) {
            var titleElement = jQuery('<h4/>', {
                'class': 'modal-title',
                'title': title,
            }).text(Sao.common.ellipsize(title, 120)  + (small? ' ' : ''));
            if (small) {
                titleElement.append(jQuery('<small/>').text(small));
            }
            this.header.append(titleElement);
        }
    });

    Sao.GlobalSearch = Sao.class_(Object, {
        init: function() {
            this.el = jQuery('<div/>', {
                'class': 'global-search-container',
            });
            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm',
            }).appendTo(this.el);

            jQuery('<div/>', {
                'id': 'user-favorites',
                'class': 'input-group-btn',
            }).append(jQuery('<button/>', {
                'class': 'btn btn-default dropdown-toggle',
                'data-toggle': 'dropdown',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext("Favorites"),
                'aria-label': Sao.i18n.gettext("Favorites"),
            }).click(Sao.favorites_menu).append(
                Sao.common.ICONFACTORY.get_icon_img('tryton-bookmarks')))
                .appendTo(group);

            this.search_entry = jQuery('<input>', {
                'id': 'global-search-entry',
                'class': 'form-control mousetrap',
                'placeholder': Sao.i18n.gettext('Action')
            }).appendTo(group);

            var completion = new Sao.common.InputCompletion(
                    this.el,
                    this.update.bind(this),
                    this.match_selected.bind(this),
                    this.format.bind(this));
            completion.input.keydown(function(evt) {
                if (evt.which == Sao.common.RETURN_KEYCODE) {
                    if (!completion.dropdown.hasClass('open')) {
                        evt.preventDefault();
                        completion.menu.dropdown('toggle');
                    }
                }
            });
            window.setTimeout(function() {
                try {
                    if (window.localStorage.getItem('tradonTraceDensity') != '1') {
                        return;
                    }
                    var input = jQuery('#global-search-entry');
                    var button = jQuery('#user-favorites > button');
                    var container = jQuery('#global-search .global-search-container');
                    var menu = container.children('.dropdown-menu');
                    console.debug('[Tradon density] global search metrics', {
                        container: container.length && container[0].getBoundingClientRect(),
                        input: input.length && input[0].getBoundingClientRect(),
                        button: button.length && button[0].getBoundingClientRect(),
                        menu: menu.length && menu[0].getBoundingClientRect(),
                        inputFontSize: input.css('font-size'),
                        inputLineHeight: input.css('line-height'),
                        buttonFontSize: button.css('font-size')
                    });
                } catch (error) {
                    console.debug('[Tradon density] trace failed', error);
                }
            }, 0);
        },
        format: function(content) {
            var el = jQuery('<div/>');
            Sao.common.ICONFACTORY.get_icon_img(
                content.icon, {'class': 'global_search-icon'})
                .appendTo(el);
            jQuery('<span/>', {
                'class': 'global-search-text'
            }).text(content.record_name).appendTo(el);
            return el;
        },
        update: function(text) {
            var ir_model = new Sao.Model('ir.model');
            if (!text) {
                return jQuery.when([]);
            }
            return ir_model.execute('global_search',
                    [text, Sao.config.limit, Sao.main_menu_screen.model_name],
                    Sao.main_menu_screen.context, true, false)
                .then(s_results => {
                    var results = [];
                    for (const result of s_results) {
                        results.push({
                            'model': result[1],
                            'model_name': result[2],
                            'record_id': result[3],
                            'record_name': result[4],
                            'icon': result[5],
                        });
                    }
                    return results;
                });
        },
        match_selected: function(item) {
            if (item.model == Sao.main_menu_screen.model_name) {
                // ids is not defined to prevent to add suffix
                Sao.Action.exec_keyword('tree_open', {
                    'model': item.model,
                    'id': item.record_id,
                });
            } else {
                var params = {
                    'model': item.model,
                    'res_id': item.record_id,
                    'mode': ['form', 'tree'],
                    'name': item.model_name
                };
                Sao.Tab.create(params);
            }
            this.search_entry.val('');
        }
    });

    function shortcuts_defs() {
        // Shortcuts available on Tab on this format:
        // {shortcut, label, id of tab button or callback method}
        return [
            {
                shortcut: 'alt+n',
                label: Sao.i18n.gettext('New'),
                id: 'new_',
            }, {
                shortcut: 'ctrl+s',
                label: Sao.i18n.gettext('Save'),
                id: 'save',
            }, {
                shortcut: 'ctrl+l',
                label: Sao.i18n.gettext('Switch'),
                id: 'switch_',
            }, {
                shortcut: 'ctrl+r',
                label: Sao.i18n.gettext('Reload/Undo'),
                id: 'reload',
            }, {
                shortcut: 'ctrl+shift+d',
                label: Sao.i18n.gettext('Duplicate'),
                id: 'copy',
            }, {
                shortcut: 'ctrl+d',
                label: Sao.i18n.gettext('Delete'),
                id: 'delete_',
            }, {
                shortcut: 'ctrl+up',
                label: Sao.i18n.gettext('Previous'),
                id: 'previous',
            }, {
                shortcut: 'ctrl+down',
                label: Sao.i18n.gettext('Next'),
                id: 'next',
            }, {
                shortcut: 'ctrl+f',
                label: Sao.i18n.gettext('Search'),
                id: 'search',
            }, {
                shortcut: 'alt+w',
                label: Sao.i18n.gettext('Close Tab'),
                id: 'close',
            }, {
                shortcut: 'ctrl+shift+t',
                label: Sao.i18n.gettext('Attachment'),
                id: 'attach',
            }, {
                shortcut: 'ctrl+shift+o',
                label: Sao.i18n.gettext('Note'),
                id: 'note',
            }, {
                shortcut: 'ctrl+e',
                label: Sao.i18n.gettext('Action'),
                id: 'action',
            }, {
                shortcut: 'ctrl+shift+r',
                label: Sao.i18n.gettext('Relate'),
                id: 'relate',
            }, {
                shortcut: 'ctrl+p',
                label: Sao.i18n.gettext('Print'),
                id: 'print',
            }, {
                shortcut: 'ctrl+shift+e',
                label: Sao.i18n.gettext('E-Mail'),
                id: 'email',
            }, {
                shortcut: 'alt+q',
                label: Sao.i18n.gettext('Previous tab'),
                callback: function() {
                    Sao.Tab.previous_tab();
                },
            }, {
                shortcut: 'alt+s',
                label: Sao.i18n.gettext('Next tab'),
                callback: function() {
                    Sao.Tab.next_tab();
                },
            }, {
                shortcut: 'ctrl+k',
                label: Sao.i18n.gettext('Global search'),
                callback: function() {
                    jQuery('#main_navbar:hidden').collapse('show');
                    jQuery('#global-search-entry').focus();
                },
            }, {
                shortcut: 'f1',
                label: Sao.i18n.gettext('Show this help'),
                callback: function() {
                    help_dialog();
                },
            }, {
                shortcut: 'ctrl+f1',
                label: Sao.i18n.gettext('Show/Hide access keys'),
                callback: function() {
                    jQuery('html').toggleClass('accesskey');
                },
            },
        ];
    }

    jQuery(document).ready(function() {
        var url = new URL(window.location);
        if (url.searchParams.has('session')) {
            var database = url.searchParams.get('database');
            var session = {
                login_service: url.searchParams.get('login_service'),
                login: url.searchParams.get('login'),
                user_id: parseInt(url.searchParams.get('user_id'), 10),
                session: url.searchParams.get('session'),
            };
            if (url.searchParams.has('renew')) {
                var renew_id = parseInt(url.searchParams.get('renew'), 10);
                if (session.user_id !== renew_id) {
                    window.close();
                    return;
                }
            }
            session = JSON.stringify(session);
            localStorage.setItem('sao_session_' + database, session);
            window.close();
            return;
        }
        set_shortcuts();
        try {
            Notification.requestPermission();
        } catch (e) {
            Sao.Logger.error(e.message, e.stack);
        }
        Sao.login();
    });

    function set_shortcuts() {
        if (typeof Mousetrap != 'undefined') {
            shortcuts_defs().forEach(function(definition) {
                Mousetrap.bind(definition.shortcut, function() {
                    if (definition.id){
                        var current_tab = Sao.Tab.tabs.get_current();
                        if (current_tab) {
                            var focused = jQuery(':focus');
                            focused.blur();
                            current_tab.el.find('a[id="' + definition.id + '"]').click();
                            focused.focus();
                        }
                    } else if (definition.callback) {
                        jQuery.when().then(definition.callback);
                    }
                    return false;
                });
            });
        }
    }

    // --- Custom: raccourci pour afficher/masquer le menu offcanvas ---
    if (typeof Mousetrap !== 'undefined') {
        Mousetrap.bind(['ctrl+m', 'command+m'], function(e) {
            e.preventDefault();
            jQuery('.row-offcanvas').toggleClass('active');
            [0, 80, 180, 320].forEach(function(delay) {
                window.setTimeout(function() {
                    window.dispatchEvent(new Event('resize'));
                }, delay);
            });
            return false;
        });
    }

    var _help_dialog = null;

    function help_dialog() {
        Mousetrap.pause();
        var dialog;
        if (!_help_dialog) {
            dialog = new Sao.Dialog(
                Sao.i18n.gettext("Help"), 'help-dialog', 'md', true,
                Sao.__version__);
            jQuery('<button>', {
                'class': 'close',
                'data-dismiss': 'modal',
                'aria-label': Sao.i18n.gettext("Close"),
            }).append(jQuery('<span>', {
                'aria-hidden': true,
            }).append('&times;')).prependTo(dialog.header);
            jQuery('<a/>', {
                'class': 'btn btn-link',
                'href': Sao.config.doc_url,
                'target': '_blank',
            }).text(Sao.i18n.gettext("Documentation..."))
                .appendTo(dialog.footer);
            jQuery('<h4/>')
                .text(Sao.i18n.gettext("Keyboard shortcuts"))
                .appendTo(dialog.body);
            var row = jQuery('<div/>', {
                'class': 'row'
            }).appendTo(dialog.body);
            var global_shortcuts_dl = jQuery('<dl/>', {
                'class': 'dl-horizontal col-md-6'
            }).append(jQuery('<h5/>')
                .text(Sao.i18n.gettext('Global shortcuts')))
                .appendTo(row);
            var tab_shortcuts_dl = jQuery('<dl/>', {
                'class': 'dl-horizontal col-md-6'
            }).append(jQuery('<h5/>')
                .text(Sao.i18n.gettext('Tab shortcuts')))
                .appendTo(row);

            for (const definition of shortcuts_defs()) {
                var dt = jQuery('<dt/>').text(definition.label);
                var dd = jQuery('<dd/>').append(jQuery('<kbd>')
                    .text(definition.shortcut));
                var dest_dl;
                if (definition.id) {
                    dest_dl = tab_shortcuts_dl;
                } else {
                    dest_dl = global_shortcuts_dl;
                }
                dt.appendTo(dest_dl);
                dd.appendTo(dest_dl);
            }
            dialog.modal.on('hidden.bs.modal', function() {
                jQuery(this).remove();
            });
            _help_dialog = dialog;
        } else {
            dialog = _help_dialog;
        }

        dialog.modal.modal('toggle');
        dialog.modal.one('shown.bs.modal hidden.bs.modal', Mousetrap.unpause);
        return false;
    }

    Sao.Plugins = [];

    // Fix stacked modal
    jQuery(document)
        .on('show.bs.modal', '.modal', function(event) {
            jQuery(this).appendTo(jQuery('body'));
        })
    .on('shown.bs.modal', '.modal.in', function(event) {
        setModalsAndBackdropsOrder();
    })
    .on('hidden.bs.modal', '.modal', function(event) {
        setModalsAndBackdropsOrder();
        if (jQuery('.modal:visible').length) {
            jQuery(document.body).addClass('modal-open');
        }
    });

    // Fix Chrome bug: https://bugs.chromium.org/p/chromium/issues/detail?id=890248
    jQuery(document).on('keydown', 'textarea', function(event) {
        if (event.key === 'PageUp' || event.key === 'PageDown') {
            event.preventDefault();
            var cursorPosition = (
                event.key === 'PageUp' ? 0 : event.target.textLength);
            event.target.setSelectionRange(cursorPosition, cursorPosition);
        }
    });

    function setModalsAndBackdropsOrder() {
        var modalZIndex = 1040;
        jQuery('.modal.in').each(function(index) {
            var $modal = jQuery(this);
            modalZIndex++;
            $modal.css('zIndex', modalZIndex);
            $modal.next('.modal-backdrop.in').addClass('hidden')
            .css('zIndex', modalZIndex - 1);
        });
        jQuery('.modal.in:visible:last').focus()
        .next('.modal-backdrop.in').removeClass('hidden');
    }

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.rpc = function(args, session=null, async=true, process_exception=true) {
        var dfd = jQuery.Deferred(),
            result;
        if (!session) {
            session = new Sao.Session();
        }
        var params = jQuery.extend([], args.params);
        params.push(jQuery.extend({}, session.context, params.pop()));

        if (session.cache && session.cache.cached(args.method)) {
            result = session.cache.get(
                args.method,
                JSON.stringify(Sao.rpc.prepareObject(params)));
            if (result !== undefined) {
                if (async) {
                    return jQuery.when(result);
                } else {
                    return result;
                }
            }
        }

        var timeoutID = Sao.common.processing.show();
        const id_ = Sao.rpc.id++;

        var ajax_success = function(data, status_, query) {
            if (data === null) {
                Sao.common.warning.run('',
                        Sao.i18n.gettext('Unable to reach the server.'))
                    .always(dfd.reject);
            } else if (data.id != id_) {
                Sao.common.warning.run('',
                    Sao.i18n.gettext(
                        `Invalid response id (${data.id}) expected ${id_}`))
                    .always(dfd.reject);
            } else if (data.error && !process_exception) {
                console.debug(`RPC error calling ${args}: ${data.error[0]}: ${data.error[1]}.`);
                dfd.reject(data.error);
            } else if (data.error && process_exception) {
                var name, msg, description;
                if (data.error[0] == 'UserWarning') {
                    name = data.error[1][0];
                    msg = data.error[1][1];
                    description = data.error[1][2];
                    Sao.common.userwarning.run(description, msg)
                        .then(function(result) {
                            if (!~['always', 'ok'].indexOf(result)) {
                                dfd.reject();
                                return;
                            }
                            Sao.rpc({
                                'method': 'model.res.user.warning.skip',
                                'params': [name, result == 'always', {}],
                            }, session).done(function() {
                                if (async) {
                                    Sao.rpc(args, session).then(
                                        dfd.resolve, dfd.reject);
                                } else {
                                    dfd.resolve();
                                }
                            });
                        }, dfd.reject);
                } else if (data.error[0] == 'UserError') {
                    msg = data.error[1][0];
                    description = data.error[1][1];
                    var domain = data.error[1][2];
                    if (!jQuery.isEmptyObject(domain)) {
                        var fields = domain[1];
                        domain = domain[0];
                        var domain_parser = new Sao.common.DomainParser(fields);
                        if (domain_parser.stringable(domain)) {
                            description += '\n' + domain_parser.string(domain);
                        }
                    }
                    Sao.common.warning.run(description, msg)
                        .always(dfd.reject);
                } else if (data.error[0] == 'ConcurrencyException') {
                    if (async &&
                        args.method.startsWith('model.') &&
                        (args.method.endsWith('.write') ||
                            args.method.endsWith('.delete')) &&
                        (args.params[0].length == 1)) {
                        var model = args.method.split('.').slice(1, -1).join('.');
                        Sao.common.concurrency.run(model, args.params[0][0],
                                args.params.slice(-1)[0])
                            .then(function() {
                                delete args.params.slice(-1)[0]._timestamp;
                                Sao.rpc(args, session).then(
                                    dfd.resolve, dfd.reject);
                            }, dfd.reject);
                    } else {
                        Sao.common.message.run('Concurrency Exception',
                                'tryton-warning').always(dfd.reject);
                    }
                } else {
                    Sao.common.error.run(data.error[0], data.error[1])
                        .always(dfd.reject);
                }
            } else {
                result = data.result;
                if (session.cache) {
                    var cache = query.getResponseHeader('X-Tryton-Cache');
                    if (cache) {
                        cache = parseInt(cache, 10);
                        session.cache.set(
                            args.method,
                            JSON.stringify(Sao.rpc.prepareObject(params)),
                            cache,
                            result);
                    }
                }
                dfd.resolve(result);
            }
        };

        var ajax_error = function(query, status_, error) {
            if (!process_exception) {
                console.debug(`RPC error calling ${args}: ${status_}: ${error}.`);
                dfd.reject();
                return;
            }
            if (query.status == 401) {
                //Try to relog
                Sao.Session.renew(session).then(function() {
                    if (async) {
                        Sao.rpc(args, session, async, process_exception)
                            .then(dfd.resolve, dfd.reject);
                    } else {
                        dfd.resolve();
                    }
                }, dfd.reject);
            } else {
                var err_msg = `[${query.status}] ${error}`;
                Sao.common.message.run(
                    Sao.i18n.gettext('Error: "%1". Try again later.', err_msg),
                    'tryton-error').always(dfd.reject);
            }
        };

        jQuery.ajax({
            'async': async,
            'headers': {
                'Authorization': 'Session ' + session.get_auth()
            },
            'contentType': 'application/json',
            'data': JSON.stringify(Sao.rpc.prepareObject({
                'id': id_,
                'method': args.method,
                'params': params
            })),
            'dataType': 'json',
            'url': '/' + (session.database || '') + '/',
            'type': 'post',
            'complete': [function() {
                Sao.common.processing.hide(timeoutID);
            }],
            'success': ajax_success,
            'error': ajax_error,
        });
        if (async) {
            return dfd.promise();
        } else if (result === undefined) {
            throw dfd;
        } else {
            return result;
        }
    };

    Sao.rpc.id = 0;

    Sao.rpc.convertJSONObject = function(value, index, parent) {
       if (value instanceof Array) {
           for (var i = 0, length = value.length; i < length; i++) {
               Sao.rpc.convertJSONObject(value[i], i, value);
           }
       } else if ((typeof(value) != 'string') &&
           (typeof(value) != 'number') && (value !== null)) {
           if (value && value.__class__) {
               switch (value.__class__) {
                   case 'datetime':
                       value = Sao.DateTime(value.year,
                               value.month - 1, value.day, value.hour,
                               value.minute, value.second,
                               Math.ceil(value.microsecond / 1000), true);
                       break;
                   case 'date':
                       value = Sao.Date(value.year,
                           value.month - 1, value.day);
                       break;
                   case 'time':
                       value = Sao.Time(value.hour, value.minute,
                           value.second, Math.ceil(value.microsecond / 1000));
                       break;
                    case 'timedelta':
                       value = Sao.TimeDelta(null, value.seconds);
                       break;
                   case 'bytes':
                       value = Sao.common.atob(value);
                       break;
                   case 'Decimal':
                       value = new Sao.Decimal(value.decimal);
                       break;
               }
               if (parent) {
                   parent[index] = value;
               }
           } else {
               for (var p in value) {
                   Sao.rpc.convertJSONObject(value[p], p, value);
               }
           }
       }
       return parent || value;
    };

    Sao.rpc.prepareObject = function(value, index, parent) {
        if (value instanceof Array) {
            value = jQuery.extend([], value);
            for (var i = 0, length = value.length; i < length; i++) {
                Sao.rpc.prepareObject(value[i], i, value);
            }
        } else if ((typeof(value) != 'string') &&
                (typeof(value) != 'number') &&
                (typeof(value) != 'boolean') &&
                (value !== null) &&
                (value !== undefined)) {
            if (value.isDate){
                value = {
                    '__class__': 'date',
                    'year': value.year(),
                    'month': value.month() + 1,
                    'day': value.date()
                };
            } else if (value.isDateTime) {
                value = value.clone();
                value = {
                    '__class__': 'datetime',
                    'year': value.utc().year(),
                    'month': value.utc().month() + 1,
                    'day': value.utc().date(),
                    'hour': value.utc().hour(),
                    'minute': value.utc().minute(),
                    'second': value.utc().second(),
                    'microsecond': value.utc().millisecond() * 1000
                };
            } else if (value.isTime) {
                value = {
                    '__class__': 'time',
                    'hour': value.hour(),
                    'minute': value.minute(),
                    'second': value.second(),
                    'microsecond': value.millisecond() * 1000
                };
            } else if (value.isTimeDelta) {
                value = {
                    '__class__': 'timedelta',
                    'seconds': value.asSeconds()
                };
            } else if (value instanceof Sao.Decimal) {
                value = {
                    '__class__': 'Decimal',
                    'decimal': value.toString()
                };
            } else if (value instanceof Uint8Array) {
                value = {
                    '__class__': 'bytes',
                    'base64': Sao.common.btoa(value),
                };
            } else {
                value = jQuery.extend({}, value);
                for (var p in value) {
                    Sao.rpc.prepareObject(value[p], p, value);
                }
            }
        }
        if (parent) {
            parent[index] = value;
        }
        return parent || value;
    };

    jQuery.ajaxSetup({
        converters: {
           'text json': function(json) {
               return Sao.rpc.convertJSONObject(jQuery.parseJSON(json));
           }
        }
    });
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.PYSON = {};
    Sao.PYSON.eval = {
        True: true,
        False: false,
    };
    Sao.PYSON.toString = function(value) {
        if (value instanceof Sao.PYSON.PYSON) {
            return value.toString();
        } else if (value instanceof Array) {
            return '[' + value.map(Sao.PYSON.toString).join(', ') + ']';
        } else if (value instanceof Object) {
            return '{' + Object.keys(value).map(key => {
                return Sao.PYSON.toString(key) + ': ' +
                    Sao.PYSON.toString(value[key]);
            }).join(', ') + '}';
        } else {
            return JSON.stringify(value);
        }
    };

    Sao.PYSON.PYSON = class PYSON {
        constructor() {
            this._operator = null;
        }
        pyson() {
            throw 'NotImplementedError';
        }
        types() {
            throw 'NotImplementedError';
        }
        get(k, d='') {
            return new Sao.PYSON.Get(this, k, d);
        }
        in_(obj) {
            return new Sao.PYSON.In(this, obj);
        }
        contains(k) {
            return new Sao.PYSON.In(k, this);
        }
        toString() {
            const params = this.__string_params__();
            if (this._operator && (params[0] instanceof Sao.PYSON.PYSON)) {
                const args = params.slice(1).map(Sao.PYSON.toString);
                return `${params[0]}.${this._operator}(` + args.join(', ') + ')';
            } else {
                var klass = this.pyson().__class__;
                var args = params.map(Sao.PYSON.toString);
                return klass + '(' + args.join(', ') + ')';
            }
        }
        __string_params__() {
            throw 'NotImplementedError';
        }
    };

    Sao.PYSON.PYSON.eval_ = function(value, context) {
        throw 'NotImplementedError';
    };
    Sao.PYSON.PYSON.init_from_object = function(object) {
        throw 'NotImplementedError';
    };

    Sao.PYSON.Encoder = class Encoder{
        prepare(value, index, parent) {
            if (value !== null && value !== undefined) {
                if (value instanceof Array) {
                    value = jQuery.extend([], value);
                    for (var i = 0, length = value.length; i < length; i++) {
                        this.prepare(value[i], i, value);
                    }
                } else if (moment.isMoment(value)) {
                    if (value.isDate) {
                        value = new Sao.PYSON.Date(
                            value.year(),
                            value.month() + 1,
                            value.date()).pyson();
                    } else {
                        value = new Sao.PYSON.DateTime(
                            value.year(),
                            value.month() + 1,
                            value.date(),
                            value.hours(),
                            value.minutes(),
                            value.seconds(),
                            value.milliseconds() * 1000).pyson();
                    }
                } else if (moment.isDuration(value)) {
                    value = new Sao.PYSON.TimeDelta(
                        Math.round(value.asDays()),
                        value.seconds(),
                        value.milliseconds() * 1000).pyson();
                } else if (value instanceof Sao.Decimal) {
                    value = value.valueOf();
                } else if ((value instanceof Object) &&
                    !(value instanceof Sao.PYSON.PYSON)) {
                    value = jQuery.extend({}, value);
                    for (var p in value) {
                        this.prepare(value[p], p, value);
                    }
                }
            }
            if (parent) {
                parent[index] = value;
            }
            return parent || value;
        }

        encode(pyson) {
            pyson = this.prepare(pyson);
            return JSON.stringify(pyson, (k, v) => {
                if (v instanceof Sao.PYSON.PYSON) {
                    return this.prepare(v.pyson());
                } else if (v === null || v === undefined) {
                    return null;
                }
                return v;
            });
        }
    };

    Sao.PYSON.Decoder = class Decoder{
        constructor(context, noeval) {
            this.__context = context || {};
            this.noeval = noeval || false;
        }
        decode(str) {
            const reviver = (k, v) => {
                if (typeof v == 'object' && v !== null) {
                    var cls = Sao.PYSON[v.__class__];
                    if (cls) {
                        if (!this.noeval) {
                            return cls.eval_(v, this.__context);
                        } else {
                            var args = jQuery.extend({}, v);
                            delete args.__class__;
                            return Sao.PYSON[v.__class__].init_from_object(
                                args);
                        }
                    }
                }
                return v;
            };
            return JSON.parse(str, reviver);
        }
    };

    Sao.PYSON.eval.Eval = function(value, default_) {
        return new Sao.PYSON.Eval(value, default_);
    };
    Sao.PYSON.Eval = class Eval extends Sao.PYSON.PYSON {
        constructor(value, default_='') {
            super();
            this._value = value;
            this._default = default_;
        }
        pyson() {
            return {
                '__class__': 'Eval',
                'v': this._value,
                'd': this._default
            };
        }
        types() {
            if (this._default instanceof Sao.PYSON.PYSON) {
                return this._default.types();
            } else {
                return [typeof this._default];
            }
        }
        __string_params__() {
            const params = [this._value];
            if (this._default !== '') {
                params.push(this._default);
            }
            return params;
        }
        get basename() {
            var name = this._value;
            if (name.startsWith('_parent_')) {
                name = name.slice('_parent_'.length);
            }
            var idx = name.indexOf('.');
            if (idx >= 0) {
                name = name.substring(0, idx);
            }
            return name;
        }
        static create(...args) {
            return new Eval(...args);
        }
    };

    Sao.PYSON.Eval.eval_ = function(value, context) {
        var idx = value.v.indexOf('.');
        if ((idx >= 0) && !(value.v in context)) {
            return Sao.PYSON.Eval.eval_({
                'v': value.v.substring(idx + 1),
                'd': value.d,
            }, context[value.v.substring(0, idx)] || {});
        }
        if ((value.v in context) && (context[value.v] !== undefined)) {
            return context[value.v];
        } else {
            return value.d;
        }
    };
    Sao.PYSON.Eval.init_from_object = function(obj) {
        return new Sao.PYSON.Eval(obj.v, obj.d);
    };

    Sao.PYSON.eval.Not = function(value) {
        return new Sao.PYSON.Not(value);
    };
    Sao.PYSON.Not = class Not extends Sao.PYSON.PYSON {
        constructor(value) {
            super();
            if (value instanceof Sao.PYSON.PYSON) {
                if (jQuery(value.types()).not(['boolean', 'object']).length ||
                    jQuery(['boolean']).not(value.types()).length) {
                    value = new Sao.PYSON.Bool(value);
                    }
            } else if (typeof value != 'boolean') {
                value = new Sao.PYSON.Bool(value);
            }
            this._value = value;
        }
        pyson() {
            return {
                '__class__': 'Not',
                'v': this._value
                };
        }
        types() {
            return ['boolean'];
        }
        __string_params__() {
            return [this._value];
        }
    };

    Sao.PYSON.Not.eval_ = function(value, context) {
        return !Sao.PYSON.Bool.eval_(value, context);
    };
    Sao.PYSON.Not.init_from_object = function(obj) {
        return new Sao.PYSON.Not(obj.v);
    };

    Sao.PYSON.eval.Bool = function(value) {
        return new Sao.PYSON.Bool(value);
    };
    Sao.PYSON.Bool = class Bool extends Sao.PYSON.PYSON {
        constructor(value) {
            super();
            this._value = value;
        }
        pyson() {
            return {
                '__class__': 'Bool',
                'v': this._value
            };
        }
        types() {
            return ['boolean'];
        }
        __string_params__() {
            return [this._value];
        }
    };

    Sao.PYSON.Bool.eval_ = function(value, context) {
        if (moment.isMoment(value.v) && value.v.isTime) {
            return Boolean(value.v.hour() || value.v.minute() ||
                    value.v.second() || value.v.millisecond());
        } else if (moment.isDuration(value.v)) {
            return Boolean(value.v.valueOf());
        } else if (value.v instanceof Number) {
            return Boolean(value.v.valueOf());
        } else if (value.v instanceof Object) {
            return !jQuery.isEmptyObject(value.v);
        } else {
            return Boolean(value.v);
        }
    };
    Sao.PYSON.Bool.init_from_object = function(obj) {
        return new Sao.PYSON.Bool(obj.v);
    };


    Sao.PYSON.eval.And = function() {
        return new Sao.PYSON.And(...arguments);
    };
    Sao.PYSON.And = class And extends Sao.PYSON.PYSON {
        constructor() {
            var statements = jQuery.extend([], arguments);
            super();
            for (var i = 0, len = statements.length; i < len; i++) {
                var statement = statements[i];
                if (statement instanceof Sao.PYSON.PYSON) {
                    if (jQuery(statement.types()).not(['boolean']).length ||
                        jQuery(['boolean']).not(statement.types()).length) {
                        statements[i] = new Sao.PYSON.Bool(statement);
                        }
                } else if (typeof statement != 'boolean') {
                    statements[i] = new Sao.PYSON.Bool(statement);
                }
            }
            if (statements.length < 2) {
                throw 'must have at least 2 statements';
            }
            this._statements = statements;
        }
        pyson() {
            return {
                '__class__': 'And',
                's': this._statements
            };
        }
        types() {
            return ['boolean'];
        }
        __string_params__() {
            return this._statements;
        }
    };

    Sao.PYSON.And.eval_ = function(value, context) {
        var result = true;
        for (const statement of value.s) {
            result = result && statement;
        }
        return result;
    };
    Sao.PYSON.And.init_from_object = function(obj) {
        return new Sao.PYSON.And(...obj.s);
    };


    Sao.PYSON.eval.Or = function() {
        return new Sao.PYSON.Or(...arguments);
    };
    Sao.PYSON.Or = class Or extends Sao.PYSON.And {
        pyson() {
            var result = super.pyson();
            result.__class__ = 'Or';
            return result;
        }
    };

    Sao.PYSON.Or.eval_ = function(value, context) {
        var result = false;
        for (const statement of value.s) {
            result = result || statement;
        }
        return result;
    };
    Sao.PYSON.Or.init_from_object= function(obj) {
        return new Sao.PYSON.Or(...obj.s);
    };

    Sao.PYSON.eval.Equal = function(statement1, statement2) {
        return new Sao.PYSON.Equal(statement1, statement2);
    };
    Sao.PYSON.Equal = class Equal extends Sao.PYSON.PYSON {
        constructor(statement1, statement2) {
            super();
            var types1, types2;
            if (statement1 instanceof Sao.PYSON.PYSON) {
                types1 = statement1.types();
            } else {
                types1 = [typeof statement1];
            }
            if (statement2 instanceof Sao.PYSON.PYSON) {
                types2 = statement2.types();
            } else {
                types2 = [typeof statement2];
            }
            if (jQuery(types1).not(types2).length ||
                jQuery(types2).not(types1).length) {
                throw 'statements must have the same type';
                }
            this._statement1 = statement1;
            this._statement2 = statement2;
        }
        pyson() {
            return {
                '__class__': 'Equal',
                's1': this._statement1,
                's2': this._statement2
            };
        }
        types() {
            return ['boolean'];
        }
        __string_params__() {
            return [this._statement1, this._statement2];
        }
    };

    Sao.PYSON.Equal.eval_ = function(value, context) {
        if (value.s1 instanceof Array  && value.s2 instanceof Array) {
            return Sao.common.compare(value.s1, value.s2);
        } else if (moment.isMoment(value.s1) && moment.isMoment(value.s2)) {
            return ((value.s1.isDate == value.s2.isDate) &&
                (value.s1.isDateTime == value.s2.isDateTime) &&
                (value.s1.valueOf() == value.s2.valueOf()));
        } else if (moment.isDuration(value.s1) && moment.isDuration(value.s2)) {
            return value.s1.valueOf() == value.s2.value();
        } else {
            return value.s1 == value.s2;
        }
    };
    Sao.PYSON.Equal.init_from_object = function(obj) {
        return new Sao.PYSON.Equal(obj.s1, obj.s2);
    };

    Sao.PYSON.eval.Greater = function(statement1, statement2, equal) {
        return new Sao.PYSON.Greater(statement1, statement2, equal);
    };
    Sao.PYSON.Greater = class Greater extends Sao.PYSON.PYSON {
        constructor(statement1, statement2, equal=false) {
            super();
            var statements = [statement1, statement2];
            for (var i = 0; i < 2; i++) {
                var statement = statements[i];
                if (statement instanceof Sao.PYSON.PYSON) {
                    if ( (!(statement instanceof Sao.PYSON.DateTime ||
                        statement instanceof Sao.PYSON.Date ||
                        statement instanceof Sao.PYSON.TimeDelta)) &&
                        (jQuery(statement.types()).not(['number']).length) ) {
                        throw 'statement must be an integer, float, ' +
                            'date, datetime or timedelta';
                    }
                } else {
                    if (!~['number', 'object'].indexOf(typeof statement)) {
                        throw 'statement must be an integer, float, ' +
                            'date, datetime or timedelta';
                    }
                }
            }
            if (equal instanceof Sao.PYSON.PYSON) {
                if (jQuery(equal.types()).not(['boolean']).length ||
                    jQuery(['boolean']).not(equal.types()).length) {
                    equal = new Sao.PYSON.Bool(equal);
                    }
            } else if (typeof equal != 'boolean') {
                equal = new Sao.PYSON.Bool(equal);
            }
            this._statement1 = statement1;
            this._statement2 = statement2;
            this._equal = equal;
        }
        pyson() {
            return {
                '__class__': 'Greater',
                's1': this._statement1,
                's2': this._statement2,
                'e': this._equal
            };
        }
        types() {
            return ['boolean'];
        }
        __string_params__() {
            return [this._statement1, this._statement2, this._equal];
        }
    };

    Sao.PYSON.Greater._convert = function(value) {
        value = jQuery.extend({}, value);
        var values = [value.s1, value.s2];
        for (var i=0; i < 2; i++) {
            if (moment.isMoment(values[i])) {
                values[i] = values[i].valueOf();
            } else if (moment.isDuration(values[i])) {
                values[i] = values[i].valueOf();
            } else {
                values[i] = Number(values[i]);
            }
        }
        value.s1 = values[0];
        value.s2 = values[1];
        return value;
    };

    Sao.PYSON.Greater.eval_ = function(value, context) {
        if (value.s1 == null || value.s2 == null) {
            return false;
        }
        value = Sao.PYSON.Greater._convert(value);
        if (value.e) {
            return value.s1 >= value.s2;
        } else {
            return value.s1 > value.s2;
        }
    };
    Sao.PYSON.Greater.init_from_object = function(obj) {
        return new Sao.PYSON.Greater(obj.s1, obj.s2, obj.e);
    };

    Sao.PYSON.eval.Less = function(statement1, statement2, equal) {
        return new Sao.PYSON.Less(statement1, statement2, equal);
    };
    Sao.PYSON.Less = class Less extends Sao.PYSON.Greater {
        pyson() {
            var result = super.pyson();
            result.__class__ = 'Less';
            return result;
        }
    };

    Sao.PYSON.Less._convert = Sao.PYSON.Greater._convert;

    Sao.PYSON.Less.eval_ = function(value, context) {
        if (value.s1 == null || value.s2 == null) {
            return false;
        }
        value = Sao.PYSON.Less._convert(value);
        if (value.e) {
            return value.s1 <= value.s2;
        } else {
            return value.s1 < value.s2;
        }
    };
    Sao.PYSON.Less.init_from_object = function(obj) {
        return new Sao.PYSON.Less(obj.s1, obj.s2, obj.e);
    };

    Sao.PYSON.eval.If = function(condition, then_statement, else_statement) {
        return new Sao.PYSON.If(condition, then_statement, else_statement);
    };
    Sao.PYSON.If = class If extends Sao.PYSON.PYSON {
        constructor(condition, then_statement, else_statement=null) {
            super();
            if (condition instanceof Sao.PYSON.PYSON) {
                if (jQuery(condition.types()).not(['boolean']).length ||
                    jQuery(['boolean']).not(condition.types()).length) {
                    condition = new Sao.PYSON.Bool(condition);
                }
            } else if (typeof condition != 'boolean') {
                condition = new Sao.PYSON.Bool(condition);
            }
            this._condition = condition;
            this._then_statement = then_statement;
            this._else_statement = else_statement;
        }
        pyson() {
            return {
                '__class__': 'If',
                'c': this._condition,
                't': this._then_statement,
                'e': this._else_statement
            };
        }
        types() {
            var types;
            if (this._then_statement instanceof Sao.PYSON.PYSON) {
                types = this._then_statement.types();
            } else {
                types = [typeof this._then_statement];
            }
            if (this._else_statement instanceof Sao.PYSON.PYSON) {
                for (const type of this._else_statement.types()) {
                    if (!~types.indexOf(type)) {
                        types.push(type);
                    }
                }
            } else {
                const type = typeof this._else_statement;
                if (!~types.indexOf(type)) {
                    types.push(type);
                }
            }
            return types;
        }
        __string_params__() {
            return [this._condition, this._then_statement,
                this._else_statement];
        }
    };

    Sao.PYSON.If.eval_ = function(value, context) {
        if (value.c) {
            return value.t;
        } else {
            return value.e;
        }
    };
    Sao.PYSON.If.init_from_object = function(obj) {
        return new Sao.PYSON.If(obj.c, obj.t, obj.e);
    };

    Sao.PYSON.eval.Get = function(obj, key, default_) {
        return new Sao.PYSON.Get(obj, key, default_);
    };
    Sao.PYSON.Get = class Get extends Sao.PYSON.PYSON {
        constructor(obj, key, default_=null) {
            super();
            this._operator = 'get';
            if (obj instanceof Sao.PYSON.PYSON) {
                if (jQuery(obj.types()).not(['object']).length ||
                    jQuery(['object']).not(obj.types()).length) {
                    throw 'obj must be a dict';
                }
            } else {
                if (!(obj instanceof Object)) {
                    throw 'obj must be a dict';
                }
            }
            this._obj = obj;
            if (key instanceof Sao.PYSON.PYSON) {
                if (jQuery(key.types()).not(['string']).length ||
                    jQuery(['string']).not(key.types()).length) {
                    throw 'key must be a string';
                }
            } else {
                if (typeof key != 'string') {
                    throw 'key must be a string';
                }
            }
            this._key = key;
            this._default = default_;
        }
        pyson() {
            return {
                '__class__': 'Get',
                'v': this._obj,
                'k': this._key,
                'd': this._default
            };
        }
        types() {
            if (this._default instanceof Sao.PYSON.PYSON) {
                return this._default.types();
            } else {
                return [typeof this._default];
            }
        }
        __string_params__() {
            const params = [this._obj, this._key];
            if (this._default !== '') {
                params.push(this._default);
            }
            return params;
        }
    };

    Sao.PYSON.Get.eval_ = function(value, context) {
        if (value.k in value.v) {
            return value.v[value.k];
        } else {
            return value.d;
        }
    };
    Sao.PYSON.Get.init_from_object = function(obj) {
        return new Sao.PYSON.Get(obj.v, obj.k, obj.d);
    };

    Sao.PYSON.eval.In = function(key, obj) {
        return new Sao.PYSON.In(key, obj);
    };
    Sao.PYSON.In = class In extends Sao.PYSON.PYSON {
        constructor(key, obj) {
            super();
            this._operator = 'in_';
            if (key instanceof Sao.PYSON.PYSON) {
                if (jQuery(key.types()).not(['string', 'number']).length) {
                    throw 'key must be a string or a number';
                }
            } else {
                if (!~['string', 'number'].indexOf(typeof key)) {
                    throw 'key must be a string or a number';
                }
            }
            if (obj instanceof Sao.PYSON.PYSON) {
                if (jQuery(obj.types()).not(['object']).length ||
                    jQuery(['object']).not(obj.types()).length) {
                    throw 'obj must be a dict or a list';
                }
            } else {
                if (!(obj instanceof Object)) {
                    throw 'obj must be a dict or a list';
                }
            }
            this._key = key;
            this._obj = obj;
        }
        pyson() {
            return {'__class__': 'In',
                'k': this._key,
                'v': this._obj
            };
        }
        types() {
            return ['boolean'];
        }
        toString() {
            const params = this.__string_params__();
            if (params[1] instanceof Sao.PYSON.PYSON) {
                const args = params.slice().map(Sao.PYSON.toString);
                args.splice(1, 1);
                return `${params[1]}.contains(` + args.join(', ') + ')';
            } else {
                return super.toString();
            }
        }
        __string_params__() {
            return [this._key, this._obj];
        }
    };

    Sao.PYSON.In.eval_ = function(value, context) {
        if (value.v) {
            if (value.v.indexOf) {
                return Boolean(~value.v.indexOf(value.k));
            } else {
                return !!value.v[value.k];
            }
        } else {
            return false;
        }
    };
    Sao.PYSON.In.init_from_object = function(obj) {
        return new Sao.PYSON.In(obj.k, obj.v);
    };

    Sao.PYSON.eval.Date = function(year, month, day, delta_years, delta_months,
            delta_days) {
        return new Sao.PYSON.Date(year, month, day, delta_years, delta_months,
                delta_days);
    };
    Sao.PYSON.Date = class Date extends Sao.PYSON.PYSON {
        constructor(
            year=null, month=null, day=null,
            delta_years=0, delta_months=0, delta_days=0, start=null) {
            super();
            this._test(year, 'year');
            this._test(month, 'month');
            this._test(day, 'day');
            this._test(delta_years, 'delta_years');
            this._test(delta_days, 'delta_days');
            this._test(delta_months, 'delta_months');

            this._year = year;
            this._month = month;
            this._day = day;
            this._delta_years = delta_years;
            this._delta_months = delta_months;
            this._delta_days = delta_days;
            this._start = start;
        }
        pyson() {
            return {
                '__class__': 'Date',
                'y': this._year,
                'M': this._month,
                'd': this._day,
                'dy': this._delta_years,
                'dM': this._delta_months,
                'dd': this._delta_days,
                'start': this._start,
            };
        }
        types() {
            return ['object'];
        }
        _test(value, name) {
            if (value instanceof Sao.PYSON.PYSON) {
                if (jQuery(value.types()).not(
                        ['number', typeof null]).length) {
                    throw name + ' must be an integer or None';
                }
            } else {
                if ((typeof value != 'number') && (value !== null)) {
                    throw name + ' must be an integer or None';
                }
            }
        }
        __string_params__() {
            return [this._year, this._month, this._day, this._delta_years,
                this._delta_months, this._delta_days, this._start];
        }
    };

    Sao.PYSON.Date.eval_ = function(value, context) {
        var date = value.start;
        if (date && date.isDateTime) {
            date = Sao.Date(date.year(), date.month(), date.date());
        }
        if (!date || !date.isDate) {
            date = Sao.Date();
        }
        if (value.y) date.year(value.y);
        if (value.M) date.month(value.M - 1);
        if (value.d) date.date(value.d);
        if (value.dy) date.add(value.dy, 'y');
        if (value.dM) date.add(value.dM, 'M');
        if (value.dd) date.add(value.dd, 'd');
        return date;
    };
    Sao.PYSON.Date.init_from_object = function(obj) {
        return new Sao.PYSON.Date(
            obj.y, obj.M, obj.d, obj.dy, obj.dM, obj.dd, obj.start);
    };

    Sao.PYSON.eval.DateTime = function(year, month, day, hour, minute, second,
            microsecond, delta_years, delta_months, delta_days, delta_hours,
            delta_minutes, delta_seconds, delta_microseconds) {
        return new Sao.PYSON.DateTime(year, month, day, hour, minute, second,
            microsecond, delta_years, delta_months, delta_days, delta_hours,
            delta_minutes, delta_seconds, delta_microseconds);
    };
    Sao.PYSON.DateTime = class DateTime extends Sao.PYSON.Date {
        constructor(
            year=null, month=null, day=null,
            hour=null, minute=null, second=null, microsecond=null,
            delta_years=0, delta_months=0, delta_days=0, 
            delta_hours=0, delta_minutes=0, delta_seconds=0,
            delta_microseconds=0, start=null) {
            super(
                year, month, day, delta_years, delta_months, delta_days,
                start);
            this._test(hour, 'hour');
            this._test(minute, 'minute');
            this._test(second, 'second');
            this._test(microsecond, 'microsecond');
            this._test(delta_hours, 'delta_hours');
            this._test(delta_minutes, 'delta_minutes');
            this._test(delta_seconds, 'delta_seconds');
            this._test(delta_microseconds, 'delta_microseconds');

            this._hour = hour;
            this._minute = minute;
            this._second = second;
            this._microsecond = microsecond;
            this._delta_hours = delta_hours;
            this._delta_minutes = delta_minutes;
            this._delta_seconds = delta_seconds;
            this._delta_microseconds = delta_microseconds;
        }
        pyson() {
            var result = super.pyson();
            result.__class__ = 'DateTime';
            result.h = this._hour;
            result.m = this._minute;
            result.s = this._second;
            result.ms = this._microsecond;
            result.dh = this._delta_hours;
            result.dm = this._delta_minutes;
            result.ds = this._delta_seconds;
            result.dms = this._delta_microseconds;
            return result;
        }
        __string_params__() {
            var date_params = super.__string_params__();
            return [date_params[0], date_params[1], date_params[2],
                this._hour, this._minute, this._second, this._microsecond,
                date_params[3], date_params[4], date_params[5],
                this._delta_hours, this._delta_minutes, this._delta_seconds,
                this._delta_microseconds, date_params[6]];
        }
    };

    Sao.PYSON.DateTime.eval_ = function(value, context) {
        var date = value.start;
        if (date && date.isDate) {
            date = Sao.DateTime.combine(date, Sao.Time());
        }
        if (!date || !date.isDateTime) {
            date = Sao.DateTime();
            date.utc();
        }
        if (value.y) date.year(value.y);
        if (value.M) date.month(value.M - 1);
        if (value.d) date.date(value.d);
        if (value.h !== null) date.hour(value.h);
        if (value.m !== null) date.minute(value.m);
        if (value.s !== null) date.second(value.s);
        if (value.ms !== null) date.milliseconds(value.ms / 1000);
        if (value.dy) date.add(value.dy, 'y');
        if (value.dM) date.add(value.dM, 'M');
        if (value.dd) date.add(value.dd, 'd');
        if (value.dh) date.add(value.dh, 'h');
        if (value.dm) date.add(value.dm, 'm');
        if (value.ds) date.add(value.ds, 's');
        if (value.dms) date.add(value.dms / 1000, 'ms');
        return date;
    };
    Sao.PYSON.DateTime.init_from_object = function(obj) {
        return new Sao.PYSON.DateTime(obj.y, obj.M, obj.d, obj.h, obj.m, obj.s,
            obj.ms, obj.dy, obj.dM, obj.dd, obj.dh, obj.dm, obj.ds, obj.dms);
    };

    Sao.PYSON.eval.TimeDelta = function(days, seconds, microseconds) {
        return new Sao.PYSON.TimeDelta(days, seconds, microseconds);
    };
    Sao.PYSON.TimeDelta = class TimeDelta extends Sao.PYSON.PYSON {
        constructor(days=0, seconds=0, microseconds=0) {
            super();
            function test(value, name) {
                if (value instanceof Sao.PYSON.TimeDelta) {
                    if (jQuery(value.types()).not(['number']).length)
                    {
                        throw name + ' must be an integer';
                    }
                } else {
                    if (typeof value != 'number') {
                        throw name + ' must be an integer';
                    }
                }
                return value;
            }
            this._days = test(days, 'days');
            this._seconds = test(seconds, 'seconds');
            this._microseconds = test(microseconds, 'microseconds');
        }
        pyson() {
            return {
                '__class__': 'TimeDelta',
                'd': this._days,
                's': this._seconds,
                'm': this._microseconds,
            };
        }
        types() {
            return ['object'];
        }
        __string_params__() {
            return [this._days, this._seconds, this._microseconds];
        }
    };
    Sao.PYSON.TimeDelta.eval_ = function(value, context) {
        return Sao.TimeDelta(value.d, value.s, value.m / 1000);
    };
    Sao.PYSON.TimeDelta.init_from_object = function(obj) {
        return new Sao.PYSON.TimeDelta(obj.d, obj.s, obj.microseconds);
    };

    Sao.PYSON.eval.Len = function(value) {
        return new Sao.PYSON.Len(value);
    };
    Sao.PYSON.Len = class Len extends Sao.PYSON.PYSON {
        constructor(value) {
            super();
            if (value instanceof Sao.PYSON.PYSON) {
                if (jQuery(value.types()).not(['object', 'string']).length ||
                    jQuery(['object', 'string']).not(value.types()).length) {
                    throw 'value must be an object or a string';
                }
            } else {
                if ((typeof value != 'object') && (typeof value != 'string')) {
                    throw 'value must be an object or a string';
                }
            }
            this._value = value;
        }
        pyson() {
            return {
                '__class__': 'Len',
                'v': this._value
            };
        }
        types() {
            return ['integer'];
        }
        __string_params__() {
            return [this._value];
        }
    };

    Sao.PYSON.Len.eval_ = function(value, context) {
        if (typeof value.v == 'object') {
            return Object.keys(value.v).length;
        } else {
            return value.v.length;
        }
    };
    Sao.PYSON.Len.init_from_object = function(obj) {
        return new Sao.PYSON.Len(obj.v);
    };
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    // https://developer.mozilla.org/en-US/docs/Web/API/WindowOrWorkerGlobalScope/btoa#Unicode_strings
    function utoa(str) {
        return window.btoa(unescape(encodeURIComponent(str)));
    }

    Sao.Session = Sao.class_(Object, {
        init: function(database, login) {
            this.login_service = null;
            this.user_id = null;
            this.session = null;
            this.cache = new Cache();
            this.prm = jQuery.when();  // renew promise
            this.database = database;
            this.login = login;
            this.restore();
            this.context = {};
            this.restore_context();
            if (!Sao.Session.current_session) {
                Sao.Session.current_session = this;
            }
        },
        get_auth: function() {
            return utoa(this.login + ':' + this.user_id + ':' + this.session);
        },
        do_login: function(parameters) {
            var dfd = jQuery.Deferred();
            var login = this.login;
            var device_cookies = JSON.parse(
                localStorage.getItem('sao_device_cookies'));
            var device_cookie = null;
            if (device_cookies && device_cookies[this.database]) {
                device_cookie = device_cookies[this.database][this.login];
            }
            var func = function(parameters) {
                parameters.device_cookie = device_cookie;
                return {
                    'method': 'common.db.login',
                    'params': [login, parameters, Sao.i18n.getlang()]
                };
            };
            new Sao.Login(func, this).run().then(result => {
                this.login = login;
                this.user_id = result[0];
                this.session = result[1];
                this.store();
                this.renew_device_cookie();
                dfd.resolve();
            }, () => {
                this.user_id = null;
                this.session = null;
                this.store();
                dfd.reject();
            });
            return dfd.promise();
        },
        do_logout: function() {
            if (!(this.user_id && this.session)) {
                return jQuery.when();
            }
            var args = {
                'id': 0,
                'method': 'common.db.logout',
                'params': []
            };
            var prm = jQuery.ajax({
                'headers': {
                    'Authorization': 'Session ' + this.get_auth()
                },
                'contentType': 'application/json',
                'data': JSON.stringify(args),
                'dataType': 'json',
                'url': '/' + this.database + '/',
                'type': 'post',
            });
            this.unstore();
            this.database = null;
            this.login = null;
            this.user_id = null;
            this.session = null;
            if (Sao.Session.current_session === this) {
                Sao.Session.current_session = null;
            }
            return prm;
        },
        do_reset_password: function() {
            if (!this.login) {
                return jQuery.when();
            }
            const args = {
                'id': 0,
                'method': 'common.db.reset_password',
                'params': [this.login, Sao.i18n.getlang()],
            };
            return jQuery.ajax({
                'contentType': 'application/json',
                'data': JSON.stringify(args),
                'dataType': 'json',
                'url': '/' + this.database + '/',
                'type': 'post',
            });
        },
        reload_context: function() {
            var args = {
                'method': 'model.res.user.get_preferences',
                'params': [true, this.context]
            };
            this.reset_context();
            var prm = Sao.rpc(args, this);
            return prm.then(context => {
                context = Object.fromEntries(Object.entries(context).filter(
                    ([k, v]) => (k != 'locale') && !k.endsWith('.rec_name')));
                jQuery.extend(this.context, context);
                this.store_context();
            });
        },
        reset_context: function() {
            this.context = {
                client: Sao.Bus.id,
            };
        },
        restore_context: function() {
            this.reset_context();
            var context = sessionStorage.getItem('sao_context_' + this.database);
            if (context !== null) {
                jQuery.extend(
                    this.context, Sao.rpc.convertJSONObject(JSON.parse(context)));
            }
        },
        store_context: function() {
            var context = jQuery.extend({}, this.context);
            delete context.client;
            context = JSON.stringify(Sao.rpc.prepareObject(context));
            sessionStorage.setItem('sao_context_' + this.database, context);
        },
        restore: function() {
            if (this.database && !this.session) {
                var session_data = localStorage.getItem(
                    'sao_session_' + this.database);
                if (session_data !== null) {
                    session_data = JSON.parse(session_data);
                    if (!this.login || this.login == session_data.login) {
                        this.login_service = session_data.login_service;
                        this.login = session_data.login;
                        this.user_id = session_data.user_id;
                        this.session = session_data.session;
                    }
                }
            }
        },
        store: function() {
            var session = {
                'login': this.login,
                'user_id': this.user_id,
                'session': this.session,
            };
            session = JSON.stringify(session);
            localStorage.setItem('sao_session_' + this.database, session);
        },
        unstore: function() {
            localStorage.removeItem('sao_session_' + this.database);
        },
        renew_device_cookie: function() {
            var device_cookie;
            var device_cookies = JSON.parse(
                localStorage.getItem('sao_device_cookies'));
            if (!device_cookies || !(this.database in device_cookies)) {
                device_cookie = null;
            } else {
                device_cookie = device_cookies[this.database][this.login];
            }
            var renew_prm = Sao.rpc({
                method: 'model.res.user.device.renew',
                params: [device_cookie, {}],
            }, this);
            renew_prm.done(result => {
                device_cookies = JSON.parse(
                    localStorage.getItem('sao_device_cookies'));
                if (!device_cookies) {
                    device_cookies = {};
                }
                if (!(this.database in device_cookies)) {
                    device_cookies[this.database] = {};
                }
                device_cookies[this.database][this.login] = result;
                localStorage.setItem(
                    'sao_device_cookies', JSON.stringify(device_cookies));
            });
            renew_prm.fail(() => {
                Sao.Logger.error("Cannot renew device cookie");
            });
        }
    });

    Sao.Session.server_version = function() {
        var timeoutID = Sao.common.processing.show();
        return jQuery.ajax({
            'contentType': 'application/json',
            'data': JSON.stringify({
                'id': 0,
                'method': 'common.server.version',
                'params': []
            }),
            'dataType': 'json',
            'url': '/',
            'type': 'post',
            'complete': [function() {
                Sao.common.processing.hide(timeoutID);
            }]
        }).then(function(data) {
            return data.result;
        });
    };

    Sao.Session.login_dialog = function() {
        var dialog = new Sao.Dialog(
            Sao.i18n.gettext("Login"), 'login-dialog', 'md', true,
            Sao.__version__);
        dialog.database_select = jQuery('<select/>', {
            'class': 'form-control',
            'id': 'database',
            'name': 'database',
        }).hide();
        dialog.database_input = jQuery('<input/>', {
            'class': 'form-control',
            'id': 'database',
            'name': 'database',
        }).hide();
        dialog.login_input = jQuery('<input/>', {
            'class': 'form-control',
            'id': 'login',
            'name': 'login',
        });
        dialog.button = jQuery('<button/>', {
            'class': 'btn btn-primary btn-block',
            'type': 'submit',
            'title': Sao.i18n.gettext("Login"),
        }).text(' ' + Sao.i18n.gettext("Login"));
        dialog.body.append(jQuery('<div/>', {
            'class': 'form-group'
        }).append(jQuery('<label/>', {
            'class': 'control-label',
            'for': 'database'
        }).text(Sao.i18n.gettext('Database')))
        .append(dialog.database_select)
        .append(dialog.database_input))
        .append(jQuery('<div/>', {
            'class': 'panel panel-default',
        })
        .append(jQuery('<div/>', {
            'class': 'panel-body',
        })
        .append(jQuery('<div/>', {
            'class': 'form-group'
        }).append(jQuery('<label/>', {
            'class': 'control-label',
            'for': 'login'
        }).text(Sao.i18n.gettext('User name')))
        .append(dialog.login_input)
        )
        .append(dialog.button)));
        return dialog;
    };

    Sao.Session.get_credentials = function() {
        var database_url = function() {
            return window.location.hash.replace(
                /^(#(!|))/, '').split('/', 1)[0] || null;
        };
        var dfd = jQuery.Deferred();
        var database = database_url();

        var session = new Sao.Session(database, null);
        if (session.session) {
            dfd.resolve(session);
            return dfd;
        }
        var dialog = Sao.Session.login_dialog();

        var empty_field = function() {
            return dialog.modal.find('input,select').filter(':visible:not([readonly])')
                .filter(function() {
                    return !jQuery(this).val();
                });
        };

        var disable_form = function(disabled=true) {
            dialog.body.find('input,select,button').prop('disabled', disabled);
        };

        var login = function() {
            var login = dialog.login_input.val();
            var database = database || dialog.database_select.val() ||
                dialog.database_input.val();
            dialog.modal.find('.has-error').removeClass('has-error');
            if (!(login && database)) {
                empty_field().closest('.form-group').addClass('has-error');
                return;
            }
            dialog.button.focus();
            disable_form();
            session.database = database;
            session.login = login;
            session.restore();
            (session.session ? jQuery.when() : session.do_login())
                .then(function() {
                    dialog.modal.modal('hide');
                    dfd.resolve(session);
                    dialog.modal.remove();
                    if (database_url() != database) {
                        window.location = '#' + database;
                    }
                }, function() {
                    disable_form(false);
                    empty_field().closest('.form-group').addClass('has-error');
                    empty_field().first().focus();
                });
        };

        var login_service = function(evt) {
            var database = database || dialog.database_select.val() ||
                dialog.database_input.val();
            dialog.modal.find('.has-error').removeClass('has-error');
            if (!database) {
                dialog.database_select
                    .closest('.form-group').addClass('has-error');
                return;
            }
            disable_form();
            var host = window.location.protocol + '//' + window.location.host;
            var next = new URL(host + '/');
            next.searchParams.append('login_service', evt.data);
            var url = new URL(host + '/' + database + evt.data);
            url.searchParams.append('next', next.href);
            var service_window = window.open(url.href, '_blank', 'popup=1');
            const timer = window.setInterval(() => {
                if (service_window.closed) {
                    window.clearInterval(timer);
                    session.database = database;
                    session.restore();
                    if (session.session) {
                        dfd.resolve(session);
                        dialog.modal.remove();
                        if (database_url() != database) {
                            window.location = '#' + database;
                        }
                    } else {
                        disable_form(false);
                        empty_field().first().focus();
                    }
                }
            }, 500);
        };

        dialog.modal.modal({
            backdrop: false,
            keyboard: false
        });
        dialog.modal.find('form').unbind().submit(function(e) {
            e.preventDefault();
            login();
        });
        dialog.modal.on('shown.bs.modal', function() {
            empty_field().first().focus();
        });

        jQuery.when(Sao.DB.list()).then(function(databases) {
            var el;
            databases = databases || [];
            if (databases.length == 1 ) {
                database = databases[0];
                el = dialog.database_input;
            } else {
                el = dialog.database_select;
                for (const database of databases) {
                    el.append(jQuery('<option/>', {
                        'value': database,
                        'text': database
                    }));
                }
            }
            el.prop('readonly', databases.length == 1);
            el.show();
            el.val(database || '');
        }, function() {
            dialog.database_input.show();
        });

        jQuery.when(Sao.Authentication.services()).then(function(services) {
            if (services.length) {
                var panel_body = jQuery('<div/>', {
                    'class': 'panel-body',
                }).append(jQuery('<p/>')
                    .text(Sao.i18n.gettext("Login with")));
                dialog.body.append(jQuery('<div/>', {
                    'class': 'panel panel-default',
                }).append(panel_body));
                for (const [name, url] of services) {
                    panel_body.append(jQuery('<button/>', {
                        'class': 'btn btn-block btn-default',
                        'type': 'button',
                    }).text(name).click(url, login_service));
                }
            }
        });
        return dfd.promise();
    };

    Sao.Session.renew = function(session) {
        if (session.prm.state() == 'pending') {
            return session.prm;
        }
        var dfd = jQuery.Deferred();
        session.session = null;
        session.prm = dfd.promise();
        if (!session.login_service) {
            session.do_login().then(dfd.resolve, function() {
                Sao.logout();
                dfd.reject();
            });
        } else {
            session.unstore();
            var host = window.location.protocol + '//' + window.location.host;
            var next = new URL(host + '/');
            next.searchParams.append('login_service', session.login_service);
            next.searchParams.append('renew', session.user_id);
            var url = new URL(host + '/' + session.database + session.login_service);
            url.searchParams.append('next', next.href);
            var service_window = window.open(url.href, '_blank', 'popup=1');
            const timer = window.setInterval(() => {
                if (service_window.closed) {
                    window.clearInterval(timer);
                    session.restore();
                    if (session.session) {
                        dfd.resolve();
                    } else {
                        Sao.logout();
                        dfd.reject();
                    }
                }
            }, 500);
        }
        dfd.done(function() {
            Sao.Bus.listen();
        });
        return session.prm;
    };

    Sao.Session.current_session = null;

    Sao.Login = Sao.class_(Object, {
        init: function(func, session) {
            this.func = func;
            this.session = session || Sao.Session.current_session;
        },
        run: function(parameters={}) {
            var dfd = jQuery.Deferred();
            var timeoutID = Sao.common.processing.show();
            var data = this.func(parameters);
            data.id = 0;
            var args = {
                'contentType': 'application/json',
                'data': JSON.stringify(data),
                'dataType': 'json',
                'url': '/' + this.session.database + '/',
                'type': 'post',
                'complete': [function() {
                    Sao.common.processing.hide(timeoutID);
                }]
            };
            if (this.session.user_id && this.session.session) {
                args.headers = {
                    'Authorization': 'Session ' + this.session.get_auth()
                };
            }
            var ajax_prm = jQuery.ajax(args);

            var ajax_success = function(data) {
                if (data === null) {
                    Sao.common.warning.run('',
                           Sao.i18n.gettext('Unable to reach the server.'));
                    dfd.reject();
                } else if (data.error) {
                    if (data.error[0].startsWith('401')) {
                        return this.run({}).then(dfd.resolve, dfd.reject);
                    } else if (data.error[0].startsWith('429')) {
                        Sao.common.message.run(
                            Sao.i18n.gettext('Too many requests. Try again later.'),
                            'tryton-error').always(dfd.resolve);
                    } else if (data.error[0].startsWith('404')) {
                        Sao.common.message.run(
                            Sao.i18n.gettext("Not found."),
                            'tryton-error').always(dfd.reject);
                    } else if (data.error[0] != 'LoginException') {
                        Sao.common.error.run(data.error[0], data.error[1])
                            .always(dfd.reject);
                    } else {
                        var args = data.error[1];
                        var name = args[0];
                        var message = args[1];
                        var type = args[2];
                        this['get_' + type](message, name).then(value => {
                            parameters[name] = value;
                            return this.run(parameters).then(
                                    dfd.resolve, dfd.reject);
                        }, dfd.reject);
                    }
                } else {
                    dfd.resolve(data.result);
                }
            };
            var ajax_error = function(query, status_, error) {
                if (query.status == 401) {
                    // Retry
                    this.run({}).then(dfd.resolve, dfd.reject);
                } else if (query.status == 429) {
                    Sao.common.message.run(
                        Sao.i18n.gettext('Too many requests. Try again later.'),
                        'tryton-error').always(dfd.resolve);
                } else if (query.status == 404) {
                    Sao.common.message.run(
                        Sao.i18n.gettext("Not found."),
                        'tryton-error').always(dfd.reject);
                } else {
                    Sao.common.error.run(status_, error).always(dfd.reject);
                }
            };
            ajax_prm.done(ajax_success.bind(this));
            ajax_prm.fail(ajax_error.bind(this));
            return dfd.promise();
        },
        get_char: function(message, name) {
            return Sao.common.ask.run(message, name);
        },
        get_password: function(message, name) {
            const session = this.session;
            const AskPasswordDialog = Sao.class_(Sao.common.AskDialog, {
                build_dialog: function(question, name, visibility, prm) {
                    const dialog = AskPasswordDialog._super.build_dialog.call(
                        this, question, name, visibility, prm);
                    jQuery('<button/>', {
                        'class': 'btn btn-link btn-sm pull-left',
                        'type': 'button',
                        'title': Sao.i18n.gettext(
                            "Send you an email to reset your password."),
                    }).text("Reset forgotten password").click(() => {
                        session.do_reset_password().then(() => {
                            return Sao.common.message.run(Sao.i18n.gettext(
                                "A request to reset your password has been sent.\n" +
                                "Please check your mailbox."));
                        }).then(() => {
                            dialog.modal.find('input,select')
                                .filter(':visible').first().focus();
                        });
                    }).prependTo(dialog.footer);
                    dialog.modal.find('.modal-dialog').removeClass('modal-sm');
                    return dialog;
                },
            });
            var ask;
            if (name == 'password') {
                ask = new AskPasswordDialog();
            } else {
                ask = Sao.common.ask;
            }
            return ask.run(message, name, false);
        },
    });

    var Cache = Sao.class_(Object, {
        init: function() {
            this.store = {};
        },
        cached: function(prefix) {
            return prefix in this.store;
        },
        set: function(prefix, key, expire, value) {
            expire = new Date(new Date().getTime() + expire * 1000);
            Sao.setdefault(this.store, prefix, {})[key] = {
                'expire': expire,
                'value': JSON.stringify(Sao.rpc.prepareObject(value)),
            };
        },
        get: function(prefix, key) {
            var now = new Date();
            var data = Sao.setdefault(this.store, prefix, {})[key];
            if (!data) {
                return undefined;
            }
            if (data.expire < now) {
                delete this.store[prefix][key];
                return undefined;
            }
            Sao.Logger.info("(cached)", prefix, key);
            return Sao.rpc.convertJSONObject(jQuery.parseJSON(data.value));
        },
        clear: function(prefix) {
            if (prefix) {
                this.store[prefix] = {};
            } else {
                this.store = {};
            }
        },
    });

    Sao.DB = {};

    Sao.DB.list = function() {
        var timeoutID = Sao.common.processing.show();
        return jQuery.ajax({
            'contentType': 'application/json',
            'data': JSON.stringify({
                'id': 0,
                'method': 'common.db.list',
                'params': []
            }),
            'dataType': 'json',
            'url': '/',
            'type': 'post',
            'complete': [function() {
                Sao.common.processing.hide(timeoutID);
            }]
        }).then(function(data) {
            return data.result;
        });
    };

    Sao.Authentication = {};

    Sao.Authentication.services = function() {
        var timeoutID = Sao.common.processing.show();
        return jQuery.ajax({
            'contentType': 'application/json',
            'data': JSON.stringify({
                'id': 0,
                'method': 'common.authentication.services',
                'params': []
            }),
            'dataType': 'json',
            'url': '/',
            'type': 'post',
            'complete': [function() {
                Sao.common.processing.hide(timeoutID);
            }]
        }).then(function(data) {
            Sao.Authentication.services = function() {
                return data.result;
            };
            return data.result;
        });
    };
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.common = {};

    Sao.common.BACKSPACE_KEYCODE = 8;
    Sao.common.TAB_KEYCODE = 9;
    Sao.common.RETURN_KEYCODE = 13;
    Sao.common.ESC_KEYCODE = 27;
    Sao.common.UP_KEYCODE = 38;
    Sao.common.DOWN_KEYCODE = 40;
    Sao.common.DELETE_KEYCODE = 46;
    Sao.common.F2_KEYCODE = 113;
    Sao.common.F3_KEYCODE = 114;

    Sao.common.SELECTION_NONE = 1;
    Sao.common.SELECTION_SINGLE = 2;
    Sao.common.SELECTION_MULTIPLE = 3;

    Sao.common.compare = function(arr1, arr2) {
        if (arr1.length != arr2.length) {
            return false;
        }
        for (var i = 0; i < arr1.length; i++) {
            var a = arr1[i], b = arr2[i];
            if ((a instanceof Array) && (b instanceof Array)) {
                if (!Sao.common.compare(a, b)) {
                    return false;
                }
            } else if (moment.isMoment(a) && moment.isMoment(b)) {
                if ((a.isDate != b.isDate) &&
                    (a.isDateTime != b.isDateTime) &&
                    (a.valueOf() != b.valueOf())) {
                    return false;
                }
            } else if (moment.isDuration(a) && moment.isDuration(b)) {
                if (a.valueOf() != b.valueOf()) {
                    return false;
                }
            } else if ((a instanceof Number) || (b instanceof Number)) {
                if (Number(a) !== Number(b)) {
                    return false;
                }
            } else if (a != b) {
                return false;
            }
        }
        return true;
    };

    Sao.common.contains = function(array1, array2) {
        for (var i = 0; i < array1.length; i++) {
            if (Sao.common.compare(array1[i], array2)) {
                return true;
            }
        }
        return false;
    };

    // Find the intersection of two arrays.
    // The arrays must be sorted.
    Sao.common.intersect = function(a, b) {
        var ai = 0, bi = 0;
        var result = [];
        while (ai < a.length && bi < b.length) {
            if (a[ai] < b[bi]) {
                ai++;
            } else if (a[ai] > b[bi]) {
                bi++;
            } else {
                result.push(a[ai]);
                ai++;
                bi++;
            }
        }
        return result;
    };

    Sao.common.scrollIntoViewIfNeeded = function(element) {
        element = element[0];
        if (element) {
            var rect = element.getBoundingClientRect();
            if (rect.bottom > window.innerHeight) {
                element.scrollIntoView(false);
            }
            if (rect.top < 0) {
                element.scrollIntoView();
            }
        }
    };

    // Handle click and Return press event
    // If one, the handler is executed at most once for both events
    Sao.common.click_press = function(func, one) {
        return function handler(evt) {
            if (evt.type != 'keypress' ||
                    evt.which == Sao.common.RETURN_KEYCODE) {
                if (one) {
                    jQuery(this).off('click keypress', null, handler);
                }
                return func(evt);
            }
        };
    };

    // Cartesian product
    Sao.common.product = function(array, repeat) {
        repeat = repeat || 1;
        var pools = [];
        var i = 0;
        while (i < repeat) {
            pools = pools.concat(array);
            i++;
        }
        var result = [[]];
        for (const pool of pools) {
            var tmp = [];
            for (const x of result) {
                for (const y of pool) {
                    tmp.push(x.concat([y]));
                }
            }
            result = tmp;
        }
        return result;
    };

    Sao.common.selection = function(title, values, alwaysask=false) {
        var prm = jQuery.Deferred();
        if (jQuery.isEmptyObject(values)) {
            prm.reject();
            return prm;
        }
        var keys = Object.keys(values).sort();
        if ((keys.length == 1) && (!alwaysask)) {
            var key = keys[0];
            prm.resolve(values[key]);
            return prm;
        }
        var dialog = new Sao.Dialog(
                title || Sao.i18n.gettext('Your selection:'),
                'selection-dialog');

        keys.forEach(function(k, i) {
            jQuery('<div/>', {
                'class': 'radio'
            }).append(jQuery('<label/>')
                .text(' ' + k)
                .prepend(jQuery('<input/>', {
                    'type': 'radio',
                    'name': 'selection',
                    'value': i
                })))
            .appendTo(dialog.body);
        });
        dialog.body.find('input').first().prop('checked', true);

        jQuery('<button/>', {
            'class': 'btn btn-link',
            'type': 'button',
            'title': Sao.i18n.gettext("Cancel"),
        }).text(Sao.i18n.gettext('Cancel')).click(function() {
            dialog.modal.modal('hide');
            prm.reject();
        }).appendTo(dialog.footer);
        jQuery('<button/>', {
            'class': 'btn btn-primary',
            'type': 'button',
            'title': Sao.i18n.gettext("OK"),
        }).text(Sao.i18n.gettext('OK')).click(function() {
            var i = dialog.body.find('input:checked').attr('value');
            dialog.modal.modal('hide');
            prm.resolve(values[keys[i]]);
        }).appendTo(dialog.footer);
        dialog.modal.on('hidden.bs.modal', function(e) {
            jQuery(this).remove();
        });
        dialog.modal.modal('show');
        return prm;
    };

    Sao.common.moment_format = function(format) {
        return format
            .replace('%a', 'ddd')
            .replace('%A', 'dddd')
            .replace('%w', 'd')
            .replace('%d', 'DD')
            .replace('%b', 'MMM')
            .replace('%B', 'MMMM')
            .replace('%m', 'MM')
            .replace('%y', 'YY')
            .replace('%Y', 'YYYY')
            .replace('%H', 'HH')
            .replace('%I', 'hh')
            .replace('%p', 'A')
            .replace('%M', 'mm')
            .replace('%S', 'ss')
            .replace('%f', 'SSS')
            .replace('%z', 'ZZ')
            .replace('%Z', 'zz')
            .replace('%j', 'DDDD')
            .replace('%U', 'ww')
            .replace('%W', 'WW')
            .replace('%c', 'llll')
            .replace('%x', 'L')
            .replace('%X', 'LTS')
            .replace('%', '%%')
            ;
    };

    Sao.common.DATE_OPERATORS = [
        ['S', moment.duration(-1, 'seconds')],
        ['s', moment.duration(1, 'seconds')],
        ['I', moment.duration(-1, 'minutes')],
        ['i', moment.duration(1, 'minutes')],
        ['H', moment.duration(-1, 'hours')],
        ['h', moment.duration(1, 'hours')],
        ['D', moment.duration(-1, 'days')],
        ['d', moment.duration(1, 'days')],
        ['W', moment.duration(-1, 'weeks')],
        ['w', moment.duration(1, 'weeks')],
        ['M', moment.duration(-1, 'months')],
        ['m', moment.duration(1, 'months')],
        ['Y', moment.duration(-1, 'years')],
        ['y', moment.duration(1, 'years')],
    ];

    Sao.common.date_format = function(format) {
        return Sao.common.moment_format(
            format || Sao.i18n.locale.date || '%x');
    };

    Sao.common.format_time = function(format, date) {
        if (!date) {
            return '';
        }
        return date.format(Sao.common.moment_format(format));
    };

    Sao.common.parse_time = function(format, value) {
        var date = moment(value, Sao.common.moment_format(format));
        if (date.isValid()) {
            date = Sao.Time(
                date.hour(), date.minute(), date.second(), date.millisecond());
        } else {
            date = null;
        }
        return date;
    };

    Sao.common.format_date = function(date_format, date) {
        if (!date) {
            return '';
        }
        return date.format(Sao.common.moment_format(date_format));
    };

    Sao.common.parse_date = function(date_format, value) {
        var date = moment(value,
               Sao.common.moment_format(date_format));
        if (date.isValid()) {
            date = Sao.Date(date.year(), date.month(), date.date());
        } else {
            date = null;
        }
        return date;
    };

    Sao.common.format_datetime = function(datetime_format, date) {
        if (!date) {
            return '';
        }
        return date.format(Sao.common.moment_format(datetime_format));
    };

    Sao.common.parse_datetime = function(datetime_format, value) {
        var formats = [
            Sao.common.moment_format(datetime_format),
            'YYYY-MM-DDTHH:mm',
            'YYYY-MM-DDTHH:mm:ss',
            'YYYY-MM-DD HH:mm',
            'YYYY-MM-DD HH:mm:ss',
        ];
        var date = moment(value, formats, true);
        if (date.isValid()) {
            date = Sao.DateTime(date.year(), date.month(), date.date(),
                    date.hour(), date.minute(), date.second(),
                    date.millisecond());
        } else {
            date = null;
        }
        return date;
    };

    Sao.common.timedelta = {};
    Sao.common.timedelta.DEFAULT_CONVERTER = {
        's': 1
    };
    Sao.common.timedelta.DEFAULT_CONVERTER.m =
        Sao.common.timedelta.DEFAULT_CONVERTER.s * 60;
    Sao.common.timedelta.DEFAULT_CONVERTER.h =
        Sao.common.timedelta.DEFAULT_CONVERTER.m * 60;
    Sao.common.timedelta.DEFAULT_CONVERTER.d =
        Sao.common.timedelta.DEFAULT_CONVERTER.h * 24;
    Sao.common.timedelta.DEFAULT_CONVERTER.w =
        Sao.common.timedelta.DEFAULT_CONVERTER.d * 7;
    Sao.common.timedelta.DEFAULT_CONVERTER.M =
        Sao.common.timedelta.DEFAULT_CONVERTER.d * 30;
    Sao.common.timedelta.DEFAULT_CONVERTER.Y =
        Sao.common.timedelta.DEFAULT_CONVERTER.d * 365;
    Sao.common.timedelta._get_separator = function() {
        return {
            Y: Sao.i18n.gettext('Y'),
            M: Sao.i18n.gettext('M'),
            w: Sao.i18n.gettext('w'),
            d: Sao.i18n.gettext('d'),
            h: Sao.i18n.gettext('h'),
            m: Sao.i18n.gettext('m'),
            s: Sao.i18n.gettext('s')
        };
    };
    Sao.common.timedelta.format = function(value, converter) {
        if (!value) {
            return '';
        }
        if (!converter) {
            converter = Sao.common.timedelta.DEFAULT_CONVERTER;
        }
        var text = [];
        value = value.asSeconds();
        var sign = '';
        if (value < 0) {
            sign = '-';
        }
        value = Math.abs(value);
        converter = Object.keys(
            Sao.common.timedelta._get_separator()).map(function(key) {
                return [key, converter[key]];
            });
        var values = [];
        var k, v;
        for (var i = 0; i < converter.length; i++) {
            k = converter[i][0];
            v = converter[i][1];
            if (v) {
                var part = Math.floor(value / v);
                value -= part * v;
                values.push(part);
            } else {
                values.push(0);
            }
        }
        for (i = 0; i < converter.length - 3; i++) {
            k = converter[i][0];
            v = values[i];
            if (v) {
                text.push(v + Sao.common.timedelta._get_separator()[k]);
            }
        }
        if (jQuery(values.slice(-3)).is(function(i, v) { return v; }) ||
                jQuery.isEmptyObject(text)) {
            var time_values = values.slice(-3);
            var time = time_values[0].toString().padStart(2, "0");
            time += ":" + time_values[1].toString().padStart(2, "0");
            if (time_values[2] || value) {
                time += ':' + time_values[2].toString().padStart(2, "0");
            }
            text.push(time);
        }
        text = sign + text.reduce(function(p, c) {
            if (p) {
                return p + ' ' + c;
            } else {
                return c;
            }
        });
        if (value) {
            if (!jQuery(values.slice(-3)).is(function(i, v) { return v; })) {
                // Add space if no time
                text += ' ';
            }
            text += ('' + value.toFixed(6)).slice(1);
        }
        return text;
    };
    Sao.common.timedelta.parse = function(text, converter) {
        if (!text) {
            return null;
        }
        if (!converter) {
            converter = Sao.common.timedelta.DEFAULT_CONVERTER;
        }
        var separators = Sao.common.timedelta._get_separator();
        var separator;
        for (var k in separators) {
            separator = separators[k];
            text = text.replace(separator, separator + ' ');
        }

        var seconds = 0;
        var sec;
        var parts = text.split(' ');
        for (var i = 0; i < parts.length; i++) {
            var part = parts[i];
            if (part.contains(':')) {
                var subparts = part.split(':');
                var subconverter = [
                    converter.h, converter.m, converter.s];
                for (var j = 0;
                        j < Math.min(subparts.length, subconverter.length);
                        j ++) {
                    var t = subparts[j];
                    var v = subconverter[j];
                    sec = Math.abs(parseFloat(t)) * v;
                    if (!isNaN(sec)) {
                        seconds += sec;
                    }
                }
            } else {
                var found = false;
                for (var key in separators) {
                    separator =separators[key];
                    if (part.endsWith(separator)) {
                        part = part.slice(0, -separator.length);
                        sec = Math.abs(parseFloat(part)) * converter[key];
                        if (!isNaN(sec)) {
                            seconds += sec;
                        }
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    sec = Math.abs(parseFloat(part));
                    if (!isNaN(sec)) {
                        seconds += sec;
                    }
                }
            }
        }
        if (text.contains('-')) {
            seconds *= -1;
        }
        return Sao.TimeDelta(null, seconds);
    };

    Sao.common.btoa = function(value) {
        var strings = [], chunksize = 0xffff;
        // JavaScript Core has hard-coded argument limit of 65536
        // String.fromCharCode can not be called with too many
        // arguments
        for (var j = 0; j * chunksize < value.length; j++) {
            strings.push(String.fromCharCode.apply(
                null, value.subarray(
                    j * chunksize, (j + 1) * chunksize)));
        }
        return btoa(strings.join(''));
    };

    Sao.common.atob = function(value) {
       // javascript's atob does not understand linefeed
       // characters
       var byte_string = atob(value.base64.replace(/\s/g, ''));
       // javascript decodes base64 string as a "DOMString", we
       // need to convert it to an array of bytes
       var array_buffer = new ArrayBuffer(byte_string.length);
       var uint_array = new Uint8Array(array_buffer);
       for (var j=0; j < byte_string.length; j++) {
           uint_array[j] = byte_string.charCodeAt(j);
       }
       return uint_array;
    };

    Sao.common.ModelAccess = Sao.class_(Object, {
        init: function() {
            this.batchnum = 100;
            this._models = [];
            this._access = {};
        },
        load_models: function(refresh) {
            if (!refresh) {
                this._access = {};
            }
            try {
                this._models = Sao.rpc({
                    'method': 'model.ir.model.list_models',
                    'params': [{}]
                }, Sao.Session.current_session, false);
            } catch(e) {
                Sao.Logger.error("Unable to get model list.");
            }
        },
        get: function(model) {
            if (this._access[model] !== undefined) {
                return this._access[model];
            }
            var idx = this._models.indexOf(model);
            if (idx < 0) {
                this.load_models(false);
                idx = this._models.indexOf(model);
            }
            var to_load = this._models.slice(
                Math.max(0, idx - Math.floor(this.batchnum / 2)),
                idx + Math.floor(this.batchnum / 2));
            var access;
            try {
                access = Sao.rpc({
                    'method': 'model.ir.model.access.get_access',
                    'params': [to_load, {}]
                }, Sao.Session.current_session, false);
            } catch(e) {
                Sao.Logger.error(`Unable to get access for ${model}.`);
                access = {
                    model: {
                        'read': true,
                        'write': false,
                        'create': false,
                        'delete': false,
                    },
                };
            }
            this._access = jQuery.extend(this._access, access);
            return this._access[model];
        }
    });
    Sao.common.MODELACCESS = new Sao.common.ModelAccess();

    Sao.common.ModelHistory = Sao.class_(Object, {
        init: function() {
            this._models = [];
        },
        load_history: function() {
            this._models = [];
            return Sao.rpc({
                'method': 'model.ir.model.list_history',
                'params': [{}]
            }, Sao.Session.current_session).then(models => {
                this._models = models;
            });
        },
        contains: function(model) {
            return ~this._models.indexOf(model);
        }
    });
    Sao.common.MODELHISTORY = new Sao.common.ModelHistory();

    Sao.common.ModelName = Sao.class_(Object, {
        init: function() {
            this._names = {};
        },
        load_names: function() {
            this._names = Sao.rpc({
                'method':'model.ir.model.get_names',
                'params': [{}],
            }, Sao.Session.current_session, false);
        },
        get: function(model) {
            if (jQuery.isEmptyObject(this._names)) {
                this.load_names();
            }
            return this._names[model] || '';
        },
        clear: function() {
            this._names = {};
        },
    });
    Sao.common.MODELNAME = new Sao.common.ModelName();

    Sao.common.ModelNotification = Sao.class_(Object, {
        init: function() {
            this._depends = null;
        },
        load_names: function() {
            this._depends = Sao.rpc({
                'method': 'model.ir.model.get_notification',
                'params': [{}],
            }, Sao.Session.current_session, false);
        },
        get: function(model) {
            if (!this._depends) {
                this.load_names();
            }
            return this._depends[model] || [];
        },
    });
    Sao.common.MODELNOTIFICATION = new Sao.common.ModelNotification();

    Sao.common.ViewSearch = Sao.class_(Object, {
        init: function() {
            this.encoder = new Sao.PYSON.Encoder();
        },
        load_searches: function() {
            this.searches = {};
            return Sao.rpc({
                'method': 'model.ir.ui.view_search.get',
                'params': [{}]
            }, Sao.Session.current_session).then(searches => {
                this.searches = searches;
            });
        },
        get: function(model) {
            return this.searches[model] || [];
        },
        add: function(model, name, domain) {
            return Sao.rpc({
                'method': 'model.ir.ui.view_search.set',
                'params': [name, model, this.encoder.encode(domain), {}],
            }, Sao.Session.current_session).then(id => {
                if (this.searches[model] === undefined) {
                    this.searches[model] = [];
                }
                this.searches[model].push([id, name, domain, true]);
            });
        },
        remove: function(model, id) {
            return Sao.rpc({
                'method': 'model.ir.ui.view_search.unset',
                'params': [id, {}]
            }, Sao.Session.current_session).then(() => {
                for (var i = 0; i < this.searches[model].length; i++) {
                    var domain = this.searches[model][i];
                    if (domain[0] === id) {
                        this.searches[model].splice(i, 1);
                        break;
                    }
                }
            });
        }
    });
    Sao.common.VIEW_SEARCH = new Sao.common.ViewSearch();

    Sao.common.humanize = function(size, suffix) {
        suffix = suffix || '';
        var sizes, u;
        if ((0 < Math.abs(size)) && (Math.abs(size) < 1)) {
            sizes = ['', 'm', 'µ', 'n', 'p', 'f', 'a', 'z', 'y', 'r', 'q'];
            for (let i=0, len=sizes.length; i < len; i++) {
                u = sizes[i];
                if (Math.abs(size) >= 0.01) {
                    break;
                }
                if (i + 1 < len ) {
                    size *= 1000;
                }
            }
        } else {
            sizes = ['', 'k', 'M', 'G', 'T', 'P', 'E', 'Z', 'Y', 'R', 'Q'];
            for (let i=0, len= sizes.length; i < len; i++) {
                u = sizes[i];
                if (Math.abs(size) <= 1000) {
                    break;
                }
                if (i + 1 < len) {
                    size /= 1000;
                }
            }
        }
        size = size.toLocaleString(
            Sao.i18n.BC47(Sao.i18n.getlang()), {
                'minimumFractionDigits': 0,
                'maximumFractionDigits': Math.abs(size) < 0.01? 15 : 2,
            });
        return size + u + suffix;
    };

    Sao.common.EvalEnvironment = function(parent_, eval_type='eval') {
        var environment;
        if (eval_type == 'eval') {
            environment = parent_.get_eval();
        } else {
            environment = {};
            for (var key in parent_.model.fields) {
                var field = parent_.model.fields[key];
                environment[key] = field.get_on_change_value(parent_);
            }
        }
        environment.id = parent_.id;
        if (parent_.group.parent)
            Object.defineProperty(environment, '_parent_' +
                    parent_.group.parent_name, {
                'enumerable': true,
                'get': function() {
                    return Sao.common.EvalEnvironment(parent_.group.parent,
                        eval_type);
                }
            });
        environment.get = function(item, default_) {
            if (Object.prototype.hasOwnProperty.call(this, item))
                return this[item];
            return default_;
        };

        return environment;
    };

    Sao.common.selection_mixin = {};
    Sao.common.selection_mixin.init = function() {
        this.selection = null;
        this.help = null;
        this.inactive_selection = [];
        this._last_domain = null;
        this._values2selection = {};
        this._domain_cache = {};
        if (this.nullable_widget === undefined) {
            this.nullable_widget = true;
        }
    };
    Sao.common.selection_mixin.init_selection = function(value, callback) {
        if (!value) {
            value = {};
            for (const e of (this.attributes.selection_change_with || [])) {
                value[e] = null;
            }
        }
        var key = JSON.stringify(value);
        var selection = this.attributes.selection || [];
        var prm;
        let prepare_selection = selection => {
            selection = jQuery.extend([], selection);
            if (this.attributes.sort === undefined || this.attributes.sort) {
                selection.sort(function(a, b) {
                    return a[1].localeCompare(b[1]);
                });
            }
            this.selection = jQuery.extend([], selection);
            this.help = this.attributes.help_selection || {};
            if (callback) callback(this.selection, this.help);
        };
        if (!(selection instanceof Array) &&
                !(key in this._values2selection)) {
            if (!jQuery.isEmptyObject(this.attributes.selection_change_with)) {
                prm = this.model.execute(
                    selection, [value], {}, true, false);
            } else {
                prm = this.model.execute(
                    selection, [], {}, true, false);
            }
            prm = prm.then(selection => {
                this._values2selection[key] = selection;
                return selection;
            });
            prm = prm.then(prepare_selection);
        } else {
            if (key in this._values2selection) {
                selection = this._values2selection[key];
            }
            prepare_selection(selection);
            prm = jQuery.when();
        }
        this.inactive_selection = [];
        this._selection_prm = prm;
    };
    Sao.common.selection_mixin.update_selection = function(record, field,
            callback) {
        const _update_selection = () => {
            if (!field) {
                if (callback) {
                    callback(this.selection, this.help);
                }
                return;
            }
            var domain = field.get_domain(record);
            if (!('relation' in this.attributes)) {
                var change_with = this.attributes.selection_change_with || [];
                var value = record._get_on_change_args(change_with);
                delete value.id;
                Sao.common.selection_mixin.init_selection.call(
                    this, value, () => {
                        Sao.common.selection_mixin.filter_selection.call(
                            this, domain, record, field);
                        if (callback) {
                            callback(this.selection, this.help);
                        }
                    });
            } else {
                var context = field.get_context(record);
                var jdomain = JSON.stringify([domain, context]);
                if (jdomain in this._domain_cache) {
                    this.selection = this._domain_cache[jdomain];
                    this._last_domain = [domain, context];
                }
                if ((this._last_domain !== null) &&
                        Sao.common.compare(domain, this._last_domain[0]) &&
                        (JSON.stringify(context) ==
                         JSON.stringify(this._last_domain[1]))) {
                    if (callback) {
                        callback(this.selection, this.help);
                    }
                    return;
                }
                var fields = ['rec_name'];
                var help_field = this.attributes.help_field;
                if (help_field) {
                    fields.push(help_field);
                }
                var prm = Sao.rpc({
                    'method': 'model.' + this.attributes.relation +
                        '.search_read',
                    'params': [domain, 0, null, null, fields, context]
                }, record.model.session, true, false);
                prm.done(result => {
                    var selection = [];
                    for (const x of result) {
                        selection.push([x.id, x.rec_name]);
                    }
                    if (this.nullable_widget) {
                        selection.push([null, '']);
                    }
                    var help = {};
                    if (help_field){
                        for (const x of result) {
                            help[x.id] = x[help_field];
                        }
                    }
                    this._last_domain = [domain, context];
                    this._domain_cache[jdomain] = selection;
                    this.selection = jQuery.extend([], selection);
                    this.help = help;
                    if (callback) {
                        callback(this.selection, this.help);
                    }
                });
                prm.fail(() => {
                    var selection = [];
                    if (this.nullable_widget) {
                        selection.push([null, '']);
                    }
                    this._last_domain = null;
                    this.selection = selection;
                    if (callback) {
                        callback(this.selection, this.help);
                    }
                });
            }
        };
        this._selection_prm.done(_update_selection);
    };
    Sao.common.selection_mixin.filter_selection = function(
            domain, record, field) {
        if (jQuery.isEmptyObject(domain)) {
            return;
        }

        var inversion = new Sao.common.DomainInversion();
        const _value_evaluator = value => {
            var context = {};
            context[this.field_name] = value[0];
            return inversion.eval_domain(domain, context);
        };

        var _model_evaluator = function(allowed_models) {
            return function(value) {
                return ~allowed_models.indexOf(value[0]) ||
                    jQuery.isEmptyObject(allowed_models);
            };
        };

        var evaluator;
        var type_ = field.description.type;
        if (type_ == 'reference') {
            var allowed_models = field.get_models(record);
            evaluator = _model_evaluator(allowed_models);
        } else if (type_ == 'multiselection') {
            return;
        } else {
            evaluator = _value_evaluator;
        }

        this.selection = this.selection.filter(evaluator);
    };
    Sao.common.selection_mixin.get_inactive_selection = function(value) {
        if (!this.attributes.relation) {
            return jQuery.when([]);
        }
        if (value === null) {
            return jQuery.when([null, '']);
        }
        for (var i = 0, len = this.inactive_selection.length; i < len; i++) {
            if (value == this.inactive_selection[i][0]) {
                return jQuery.when(this.inactive_selection[i]);
            }
        }
        var prm = Sao.rpc({
            'method': 'model.' + this.attributes.relation + '.read',
            'params': [[value], ['rec_name'], {}]
        }, Sao.Session.current_session);
        return prm.then(result => {
            this.inactive_selection.push([result[0].id, result[0].rec_name]);
            return [result[0].id, result[0].rec_name];
        });
    };

    Sao.common.Button = Sao.class_(Object, {
        init: function(attributes, el, size, style) {
            this.attributes = attributes;
            if (el) {
                this.el = el;
            } else {
                this.el = jQuery('<button/>', {
                    title: attributes.string || '',
                    name: attributes.name || '',
                });
                this.el.text(attributes.string || '');
                if (this.attributes.rule) {
                    this.el.append(' ').append(jQuery('<span/>', {
                        'class': 'badge'
                    }));
                }
                this.el.attr(
                    'accesskey',
                    Sao.common.accesskey(attributes.string || ''));
            }
            this.icon = this.el.children('img');
            if (!this.icon.length) {
                this.icon = jQuery('<img/>', {
                    'class': 'icon',
                }).prependTo(this.el);
                this.icon.hide();
            }
            this.el.addClass([
                'btn', 'btn-horizontal','btn-modern',
                //(style || 'btn-default'), 
                (size || '')].join(' '));
            this.el.attr('type', 'button');
            this.icon.attr('aria-hidden', true);
            this.set_icon(attributes.icon);
        },
        set_icon: function(icon_name) {
            if (!icon_name) {
                this.icon.attr('src', '');
                this.icon.hide();
                return;
            }
            Sao.common.ICONFACTORY.get_icon_url(icon_name).done(url => {
                this.icon.attr('src', url);
                this.icon.show();
            });
        },
        set_state: function(record) {
            var states;
            if (record) {
                states = record.expr_eval(this.attributes.states || {});
            } else {
                states = {};
            }
            if (states.invisible) {
                this.el.hide();
            } else {
                this.el.show();
            }
            this.el.prop('disabled', Boolean(states.readonly));
            this.set_icon(states.icon || this.attributes.icon);

            if (this.attributes.rule) {
                var prm;
                if (record) {
                    prm = record.get_button_clicks(this.attributes.name);
                } else {
                    prm = jQuery.when();
                }
                prm.then(clicks => {
                    var counter = this.el.children('.badge');
                    var users = [];
                    var tip = '';
                    if (!jQuery.isEmptyObject(clicks)) {
                        for (var u in clicks) {
                            users.push(clicks[u]);
                        }
                        tip = Sao.i18n.gettext('By: ') +
                            users.join(Sao.i18n.gettext(', '));
                    }
                    counter.text(users.length || '');
                    counter.attr('title', tip);
                });
            }

            if (((this.attributes.type === undefined) ||
                        (this.attributes.type === 'class')) && (record)) {
                var parent = record.group.parent;
                while (parent) {
                    if (parent.modified) {
                        this.el.prop('disabled', true);
                        break;
                    }
                    parent = parent.group.parent;
                }
            }
        }
    });

    Sao.common.udlex = Sao.class_(Object, {
        init: function(instream) {

            var Stream = Sao.class_(Object, {
                init: function(stream) {
                    this.stream = stream.split('');
                    this.i = 0;
                },
                read: function(length=1) {
                    if (this.i >= this.stream.length) {
                        return null;
                    }
                    var value = this.stream
                        .slice(this.i, this.i + length).join();
                    this.i += length;
                    return value;
                }
            });
            this.instream = new Stream(instream);
            this.eof = null;
            this.commenters = '';
            this.nowordchars = [':', '>', '<', '=', '!', '"', ';', '(', ')'];
            this.whitespace = ' \t\r\n';
            this.whitespace_split = false;
            this.quotes = '"';
            this.escape = '\\';
            this.escapedquotes = '"';
            this.state = ' ';
            this.pushback = [];
            this.token = '';
        },
        get_token: function() {
            if (this.pushback.length > 0) {
                return this.pushback.shift();
            }
            var raw = this.read_token();
            return raw;
        },
        read_token: function() {
            var quoted = false;
            var escapedstate = ' ';
            const always = true;
            while (always) {
                var nextchar = this.instream.read(1);
                if (this.state === null) {
                    this.token = '';  // past en of file
                    break;
                } else if (this.state == ' ') {
                    if (!nextchar) {
                        this.state = null;  // end of file
                        break;
                    } else if (this.whitespace.contains(nextchar)) {
                        if (this.token || quoted) {
                            break;  // emit current token
                        } else {
                            continue;
                        }
                    } else if (this.commenters.contains(nextchar)) {
                        // TODO readline
                    } else if (this.escape.contains(nextchar)) {
                        escapedstate = 'a';
                        this.state = nextchar;
                    } else if (!~this.nowordchars.indexOf(nextchar)) {
                        this.token = nextchar;
                        this.state = 'a';
                    } else if (this.quotes.contains(nextchar)) {
                        this.state = nextchar;
                    } else if (this.whitespace_split) {
                        this.token = nextchar;
                        this.state = 'a';
                    } else {
                        this.token = nextchar;
                        if (this.token || quoted) {
                            break;  // emit current token
                        } else {
                            continue;
                        }
                    }
                } else if (this.quotes.contains(this.state)) {
                    quoted = true;
                    if (!nextchar) {  // end of file
                        throw 'no closing quotation';
                    }
                    if (nextchar == this.state) {
                        this.state = 'a';
                    } else if (this.escape.contains(nextchar) &&
                        this.escapedquotes.contains(this.state)) {
                        escapedstate = this.state;
                        this.state = nextchar;
                    } else {
                        this.token = this.token + nextchar;
                    }
                } else if (this.escape.contains(this.state)) {
                    if (!nextchar) {  // end of file
                        throw 'no escaped character';
                    }
                    if (this.quotes.contains(escapedstate) &&
                        (nextchar != this.state) &&
                        (nextchar != escapedstate)) {
                        this.token = this.token + this.state;
                    }
                    this.token = this.token + nextchar;
                    this.state = escapedstate;
                } else if (this.state == 'a') {
                    if (!nextchar) {
                        this.state = null;  // end of file
                        break;
                    } else if (this.whitespace.contains(nextchar)) {
                        this.state = ' ';
                        if (this.token || quoted) {
                            break;  // emit current token
                        } else {
                            continue;
                        }
                    } else if (this.commenters.contains(nextchar)) {
                        // TODO
                    } else if (this.quotes.contains(nextchar)) {
                        this.state = nextchar;
                    } else if (this.escape.contains(nextchar)) {
                        escapedstate = 'a';
                        this.state = nextchar;
                    } else if ((!~this.nowordchars.indexOf(nextchar)) ||
                            this.quotes.contains(nextchar) ||
                            this.whitespace_split) {
                        this.token = this.token + nextchar;
                    } else {
                        this.pushback.unshift(nextchar);
                        this.state = ' ';
                        if (this.token) {
                            break;  // emit current token
                        } else {
                            continue;
                        }
                    }
                }
            }
            var result = this.token;
            this.token = '';
            if (!quoted && result === '') {
                result = null;
            }
            return result;
        },
        next: function() {
            var token = this.get_token();
            if (token == this.eof) {
                return null;
            }
            return token;
        }
    });

    Sao.common.DomainParser = Sao.class_(Object, {
        OPERATORS: ['!=', '<=', '>=', '=', '!', '<', '>'],
        init: function(fields, context) {
            this.fields = {};
            this.strings = {};
            this.context = context;
            this.update_fields(fields);
        },
        update_fields: function(fields, prefix, string_prefix) {
            prefix = prefix || '';
            string_prefix = string_prefix || '';
            for (var name in fields) {
                var field = fields[name];
                if ((field.searchable || (field.searchable === undefined)) &&
                    (name !== 'rec_name')) {
                    field = jQuery.extend({}, field);
                    var fullname = prefix ? prefix + '.' + name : name;
                    var string = string_prefix ?
                        string_prefix + '.' + field.string : field.string;
                    field.string = string;
                    field.name = fullname;
                    this.fields[fullname] = field;
                    this.strings[field.string.toLowerCase()] = field;
                    var rfields = field.relation_fields;
                    if (rfields) {
                        this.update_fields(rfields, fullname, string);
                    }
                }
            }
        },
        parse: function(input) {
            try {
                var lex = new Sao.common.udlex(input);
                var tokens = [];
                do {
                    var token = lex.next();
                    if (token !== null) {
                        tokens.push(token);
                    }
                } while (token !== null);
                tokens = this.group_operator(tokens);
                tokens = this.parenthesize(tokens);
                tokens = this.group(tokens);
                tokens = this.operatorize(tokens, 'or');
                tokens = this.operatorize(tokens, 'and');
                tokens = this.parse_clause(tokens);
                return this.simplify(tokens);
            } catch (e) {
                if (e == 'no closing quotation') {
                    return this.parse(input + '"');
                }
                throw e;
            }
        },
        stringable: function(domain) {
            const stringable_ = clause => {
                if (!clause || jQuery.isEmptyObject(clause)) {
                    return true;
                }
                var is_array = function(e) {
                    return e instanceof Array;
                };
                if ((~['AND', 'OR'].indexOf(clause[0]) ||
                            (is_array(clause[0]))) &&
                        clause.slice(1).every(is_array)) {
                    return this.stringable(clause);
                }
                var name = clause[0];
                var value = clause[2];
                if (name.endsWith('.rec_name')) {
                    name = name.slice(0, -9);
                }
                if (name in this.fields) {
                    var field = this.fields[name];
                    if (~['many2one', 'one2one', 'one2many', 'many2many']
                        .indexOf(field.type)) {
                        var test = function(value) {
                            return ((typeof value == 'string') ||
                                (value === null));
                        };
                        if (value instanceof Array) {
                            return value.every(test);
                        } else {
                            return test(value);
                        }
                    } else if (field.type == 'multiselection') {
                        return (!value ||
                            jQuery.isEmptyObject(value) ||
                            (value instanceof Array));
                    } else {
                        return true;
                    }
                } else if (name == 'rec_name') {
                    return true;
                }
                return false;
            };
            if (!domain) {
                return true;
            }
            if (~['AND', 'OR'].indexOf(domain[0])) {
                domain = domain.slice(1);
            }
            return domain.every(stringable_);
        },
        string: function(domain) {

            const string = clause => {
                if (jQuery.isEmptyObject(clause)) {
                    return '';
                }
                if ((typeof clause[0] != 'string') ||
                        ~['AND', 'OR'].indexOf(clause[0])) {
                    return '(' + this.string(clause) + ')';
                }
                var name = clause[0];
                var operator = clause[1];
                var value = clause[2];
                if (name.endsWith('.rec_name')) {
                    name = name.slice(0, -9);
                }
                if (!(name in this.fields)) {
                    if (this.is_full_text(value)) {
                        value = value.slice(1, -1);
                    }
                    return this.quote(value);
                }
                var field = this.fields[name];
                var target = null;
                if (clause.length > 3) {
                    target = clause[3];
                }
                if (operator.contains('ilike')) {
                    if (this.is_full_text(value)) {
                        value = value.slice(1, -1);
                    } else if (!this.is_like(value)) {
                        if (operator == 'ilike') {
                            operator = '=';
                        } else {
                            operator = '!';
                        }
                        value = this.unescape(value);
                    }
                }
                var def_operator = this.default_operator(field);
                if (def_operator == operator.trim()) {
                    operator = '';
                    if (~this.OPERATORS.indexOf(value)) {
                        // As the value could be interpreted as an operator
                        // the default operator must be forced
                        operator = '"" ';
                    }
                } else if ((operator.contains(def_operator) &&
                            (operator.contains('not') ||
                             operator.contains('!')))) {
                    operator = operator.replace(def_operator, '')
                        .replace('not', '!').trim();
                }
                if (operator.endsWith('in')) {
                    if (operator == 'not in') {
                        operator = '!';
                    } else {
                        operator = '';
                    }
                }
                var formatted_value = this.format_value(field, value, target);
                if (~this.OPERATORS.indexOf(operator) &&
                        ~['char', 'text', 'selection']
                        .indexOf(field.type) &&
                        (value === '')) {
                    formatted_value = '""';
                }
                return (this.quote(field.string) + ': ' +
                        operator + formatted_value);
            };

            if (jQuery.isEmptyObject(domain)) {
                return '';
            }
            var nary = ' ';
            if ((domain[0] == 'AND') || (domain[0] == 'OR')) {
                if (domain[0] == 'OR') {
                    nary = ' | ';
                }
                domain = domain.slice(1);
            }
            return domain.map(string).join(nary);
        },
        completion: function(input) {
            var results = [];
            var domain = this.parse(input);
            var closing = 0;
            var i, len;
            for (i=input.length; i>0; i--) {
                if (input[i] == ')' || input[i] == ' ') {
                    break;
                }
                if (input[i] == ')') {
                    closing += 1;
                }
            }
            var endings = this.ending_clause(domain);
            var ending = endings[0];
            var deep_ending = endings[1];
            var deep = deep_ending - closing;
            var string_domain = this.string(domain);

            if (deep > 0) {
                string_domain = string_domain.substring(0,
                        string_domain.length - deep);
            }
            if (string_domain != input) {
                results.push(string_domain);
            }

            var pslice = function(string, depth) {
                if (depth > 0) {
                    return string.substring(0, string.length - depth);
                }
                return string;
            };
            var complete, complete_string;
            if (ending !== null && closing === 0) {
                var completes = this.complete(ending);
                for (i=0, len=completes.length; i < len; i++) {
                    complete = completes[i];
                    complete_string = this.string(
                            this.replace_ending_clause(domain, complete));
                    results.push(pslice(complete_string, deep));
                }
            }
            if (input.length > 0) {
                if (input.substr(input.length - 1, 1) != ' ') {
                    return results;
                }
                if (input.length >= 2 ||
                        input.substr(input.length - 2, 1) == ':') {
                    return results;
                }
            }
            var field, operator, value;
            for (var key in this.strings) {
                field = this.strings[key];
                operator = this.default_operator(field);
                value = '';
                if ((operator == 'ilike') || (operator == 'not ilike')) {
                    value = this.likify(value);
                }
                var new_domain = this.append_ending_clause(domain,
                        [field.name, operator, value], deep);
                var new_domain_string = this.string(new_domain);
                results.push(pslice(new_domain_string, deep));
            }
            return results;
        },
        complete: function(clause) {
            var results = [];
            var name, operator, value;
            if (clause.length == 1) {
                name = clause[0];
            } else if (clause.length == 3) {
                name = clause[0];
                operator = clause[1];
                value = clause[2];
            } else {
                name = clause[0];
                operator = clause[1];
                value = clause[2];
                if (name.endsWith('.rec_name')) {
                    name = name.substring(0, name.length - 9);
                }
            }
            var escaped;
            if (name == "rec_name") {
                if (operator == "ilike") {
                    escaped = value.replace(/%%/g, '__');
                    if (escaped.startsWith('%') || escaped.endsWith('%')) {
                        value = escaped.substring(1, escaped.length - 1);
                    } else if (~escaped.indexOf('%')) {
                        value = value.replace(/%%/g, '%');
                    }
                    operator = null;
                }
                name = value;
                value = '';
            }
            if (name === undefined || name === null) {
                name = '';
            }
            var field;
            if (!(name.toLowerCase() in this.strings) &&
                    !(name in this.fields)) {
                for (var key in this.strings) {
                    field = this.strings[key];
                    if (field.string.toLowerCase()
                            .startsWith(name.toLowerCase())) {
                        operator = this.default_operator(field);
                        value = '';
                        if (operator == 'ilike') {
                            value = this.likify(value);
                        }
                        results.push([field.name, operator, value]);
                    }
                }
                return results;
            }
            if (name in this.fields) {
                field = this.fields[name];
            } else {
                field = this.strings[name.toLowerCase()];
            }
            if (!operator) {
                operator = this.default_operator(field);
                value = '';
                if ((operator == 'ilike') || (operator == 'not ilike')) {
                    value = this.likify(value);
                }
                results.push([field.name, operator, value]);
            } else {
                var completes = this.complete_value(field, value);
                for (var i=0, len=completes.length; i < len; i++) {
                    results.push([field.name, operator, completes[i]]);
                }
            }
            return results;
        },
        is_subdomain: function(element) {
            return (element instanceof Array) && !element.clause;
        },
        ending_clause: function(domain, depth=0) {
            if (domain.length === 0) {
                return [null, depth];
            }
            var last_element = domain[domain.length - 1];
            if (this.is_subdomain(last_element)) {
                return this.ending_clause(last_element, depth + 1);
            }
            return [last_element, depth];
        },
        replace_ending_clause: function(domain, clause) {
            var results = [];
            var i, len;
            for (i = 0, len=domain.length - 1; i < len; i++) {
                results.push(domain[i]);
            }
            if (this.is_subdomain(domain[i])) {
                results.push(
                    this.replace_ending_clause(domain[i], clause));
            } else {
                results.push(clause);
            }
            return results;
        },
        append_ending_clause: function(domain, clause, depth) {
            if (domain.length === 0) {
                return [clause];
            }
            var results = domain.slice(0, -1);
            var last_element = domain[domain.length - 1];
            if (this.is_subdomain(last_element)) {
                results.push(this.append_ending_clause(last_element, clause,
                            depth - 1));
            } else {
                results.push(last_element);
                if (depth === 0) {
                    results.push(clause);
                }
            }
            return results;
        },
        complete_value: function(field, value) {
            var complete_boolean = function() {
                if ((value === null) || (value === undefined)) {
                    return [true, false];
                } else if (value) {
                    return [false];
                } else {
                    return [true];
                }
            };

            var complete_selection = function() {
                var results = [];
                var test_value = value !== null ? value : '';
                if (value instanceof Array) {
                    test_value = value[value.length - 1] || '';
                }
                test_value = test_value.replace(/^%*|%*$/g, '');
                var i, len, svalue, test;
                for (i=0, len=field.selection.length; i<len; i++) {
                    svalue = field.selection[i][0];
                    test = field.selection[i][1].toLowerCase();
                    if (test.startsWith(test_value.toLowerCase())) {
                        if (value instanceof Array) {
                            results.push(value.slice(0, -1).concat([svalue]));
                        } else {
                            results.push(svalue);
                        }
                    }
                }
                return results;
            };

            const complete_reference = () => {
                var results = [];
                var test_value = value !== null ? value : '';
                if (value instanceof Array) {
                    test_value = value[value.length - 1];
                }
                test_value = test_value.replace(/^%*|%*$/g, '');
                var i, len, svalue, test;
                for (i=0, len=field.selection.length; i<len; i++) {
                    svalue = field.selection[i][0];
                    test = field.selection[i][1].toLowerCase();
                    if (test.startsWith(test_value.toLowerCase())) {
                        if (value instanceof Array) {
                            results.push(value.slice(0, -1).concat([svalue]));
                        } else {
                            results.push(this.likify(svalue));
                        }
                    }
                }
                return results;
            };

            var complete_datetime = function() {
                return [Sao.Date(), Sao.DateTime().utc()];
            };

            var complete_date = function() {
                return [Sao.Date()];
            };

            var complete_time = function() {
                return [Sao.Time()];
            };

            var completes = {
                'boolean': complete_boolean,
                'selection': complete_selection,
                'multiselection': complete_selection,
                'reference': complete_reference,
                'datetime': complete_datetime,
                'timestamp': complete_datetime,
                'date': complete_date,
                'time': complete_time
            };

            if (field.type in completes) {
                return completes[field.type]();
            }
            return [];
        },
        group_operator: function(tokens) {
            var cur = tokens[0];
            var result = [];
            for (const nex of tokens.slice(1)) {
                if ((nex == '=') && cur &&
                    ~this.OPERATORS.indexOf(cur + nex)) {
                    result.push(cur + nex);
                    cur = null;
                } else {
                    if (cur !== null) {
                        result.push(cur);
                    }
                    cur = nex;
                }
            }
            if (cur !== null) {
                result.push(cur);
            }
            return result;
        },
        parenthesize: function(tokens) {
            var result = [];
            var current = result;
            var parent = [];
            for (const token of tokens) {
                if (current === undefined) {
                    continue;
                }
                if (token == '(') {
                    parent.push(current);
                    current = current[current.push([]) - 1];
                } else if (token == ')') {
                    current = parent.pop();
                } else {
                    current.push(token);
                }
            }
            return result;
        },
        group: function(tokens) {
            var result = [];

            const _group = parts => {
                var result = [];
                var push_result = function(part) {
                    var clause = [part];
                    clause.clause = true;
                    result.push(clause);
                };
                var i = parts.indexOf(':');
                if (!~i) {
                    parts.forEach(push_result);
                    return result;
                }
                var sub_group = function(name, lvalue) {
                    return function(part) {
                        if (!jQuery.isEmptyObject(name)) {
                            var clause;
                            if (!jQuery.isEmptyObject(lvalue)) {
                                if (part[0] !== null) {
                                    lvalue.push(part[0]);
                                }
                                clause = name.concat([lvalue]);
                                clause.clause = true;
                                result.push(clause);
                            } else {
                                clause = name.concat(part);
                                clause.clause = true;
                                result.push(clause);
                            }
                            name.splice(0, name.length);
                        } else {
                            result.push(part);
                        }
                    };
                };
                for (var j = 0; j < i; j++) {
                    var name = parts.slice(j, i).join(' ');
                    if (name.toLowerCase() in this.strings) {
                        if (!jQuery.isEmptyObject(parts.slice(0, j))) {
                            parts.slice(0, j).forEach(push_result);
                        } else {
                            push_result(null);
                        }
                        name = [name];
                        // empty string is also the default operator
                        var operators = [''].concat(this.OPERATORS);
                        if (((i + 1) < parts.length) &&
                                (~operators.indexOf(parts[i + 1]))) {
                            name = name.concat([parts[i + 1]]);
                            i += 1;
                        } else {
                            name = name.concat([null]);
                        }
                        var lvalue = [];
                        while ((i + 2) < parts.length) {
                            if (parts[i + 2] == ';') {
                                lvalue.push(parts[i + 1]);
                                i += 2;
                            } else {
                                break;
                            }
                        }
                        _group(parts.slice(i + 1)).forEach(
                                sub_group(name, lvalue));
                        if (!jQuery.isEmptyObject(name)) {
                            var clause;
                            if (!jQuery.isEmptyObject(lvalue)) {
                                clause = name.concat([lvalue]);
                                clause.clause = true;
                                result.push(clause);
                            } else {
                                clause = name.concat([null]);
                                clause.clause = true;
                                result.push(clause);
                            }
                        }
                        break;
                    }
                }
                return result;
            };

            var parts = [];
            for (const token of tokens) {
                if (this.is_generator(token)) {
                    for (const group of _group(parts)) {
                        if (!Sao.common.compare(group, [null])) {
                            result.push(group);
                        }
                    }
                    parts = [];
                    result.push(this.group(token));
                } else {
                    parts.push(token);
                }
            }
            for (const group of _group(parts)) {
                if (!Sao.common.compare(group, [null])) {
                    result.push(group);
                }
            }
            return result;
        },
        is_generator: function(value) {
            return (value instanceof Array) && (value.clause === undefined);
        },
        operatorize: function(tokens, operator) {
            var result = [];
            operator = operator || 'or';
            tokens = jQuery.extend([], tokens);
            var notation = {'or': '|', 'and': '&'}[operator];
            var test = function(value) {
                if (value instanceof Array) {
                    return Sao.common.compare(value, [notation]);
                } else {
                    return value == notation;
                }
            };
            var cur = tokens.shift();
            while (test(cur)) {
                cur = tokens.shift();
            }
            if (cur === undefined) {
                return result;
            }
            if (this.is_generator(cur)) {
                cur = this.operatorize(cur, operator);
            }
            var nex = null;
            while (!jQuery.isEmptyObject(tokens)) {
                nex = tokens.shift();
                if ((this.is_generator(nex)) && !test(nex)) {
                    nex = this.operatorize(nex, operator);
                }
                if (test(nex)) {
                    nex = tokens.shift();
                    while (test(nex)) {
                        nex = tokens.shift();
                    }
                    if (this.is_generator(nex)) {
                        nex = this.operatorize(nex, operator);
                    }
                    if (nex !== undefined) {
                        cur = [operator.toUpperCase(), cur, nex];
                    } else {
                        if (!test(cur)) {
                            result.push([operator.toUpperCase(), cur]);
                            cur = null;
                        }
                    }
                    nex = null;
                } else {
                    if (!test(cur)) {
                        result.push(cur);
                    }
                    cur = nex;
                }
            }
            if (jQuery.isEmptyObject(tokens)) {
                if ((nex !== null) && !test(nex)) {
                    result.push(nex);
                } else if ((cur !== null) && !test(cur)) {
                    result.push(cur);
                }
            }
            return result;
        },
        _clausify: function(e) {
            e.clause = true;
            return e;
        },
        parse_clause: function(tokens) {
            var result = [];
            tokens.forEach(clause => {
                if (this.is_generator(clause)) {
                    result.push(this.parse_clause(clause));
                } else if ((clause === 'OR') || (clause === 'AND')) {
                    result.push(clause);
                } else if ((clause.length == 1) &&
                    !(clause[0] instanceof Array)) {
                    result.push(this._clausify(['rec_name', 'ilike',
                                this.likify(clause[0])]));
                } else if ((clause.length == 3) &&
                    (clause[0].toLowerCase() in this.strings)) {
                    var operator = clause[1];
                    var value = clause[2];
                    var field = this.strings[clause[0].toLowerCase()];
                    var field_name = field.name;

                    var target = null;
                    if (field.type == 'reference') {
                        var split = this.split_target_value(field, value);
                        target = split[0];
                        value = split[1];
                        if (target) {
                            field_name += '.rec_name';
                        }
                    } else if (field.type == 'multiselection') {
                        if ((value !== null) && !(value instanceof Array)) {
                            value = [value];
                        }
                    }

                    if (!operator) {
                        operator = this.default_operator(field);
                    }
                    if ((value instanceof Array) &&
                        (field.type != 'multiselection')) {
                        if (operator == '!') {
                            operator = 'not in';
                        } else {
                            operator = 'in';
                        }
                    }
                    if (operator == '!') {
                        operator = this.negate_operator(
                                this.default_operator(field));
                    }
                    if ((value === null) && operator.endsWith('in')) {
                        if (operator.startsWith('not')) {
                            operator = '!=';
                        } else {
                            operator = '=';
                        }
                    }
                    if (~[
                        'integer', 'float', 'numeric',
                        'datetime', 'timestamp', 'date',
                        'time'].indexOf(field.type)) {
                        if ((typeof value == 'string') && value.contains('..')) {
                            var values = value.split('..', 2);
                            var lvalue = this.convert_value(field, values[0], this.context);
                            var rvalue = this.convert_value(field, values[1], this.context);
                            result.push([
                                    this._clausify([field_name, '>=', lvalue]),
                                    this._clausify([field_name, '<=', rvalue])
                                    ]);
                            return;
                        }
                    }
                    if (value instanceof Array) {
                        value = value.map(
                            v => this.convert_value(field, v, this.context));
                        if (~['many2one', 'one2many', 'many2many', 'one2one',
                            'many2many', 'one2one'].indexOf(field.type)) {
                            field_name += '.rec_name';
                        }
                    } else {
                        value = this.convert_value(field, value, this.context);
                    }
                    if (operator.contains('like')) {
                        value = this.likify(value);
                    }
                    if (target) {
                        result.push(this._clausify(
                            [field_name, operator, value, target]));
                    } else {
                        result.push(this._clausify(
                            [field_name, operator, value]));
                    }
                }
            });
            return result;
        },
        likify: function(value, escape) {
            escape = escape || '\\';
            if (!value) {
                return '%';
            }
            var escaped = value
                .replace(escape + '%', '')
                .replace(escape + '_', '');
            if (escaped.contains('%') || escaped.contains('_')) {
                return value;
            } else {
                return '%' + value + '%';
            }
        },
        is_full_text: function(value, escape) {
            escape = escape || '\\';
            var escaped = value;
            if ((escaped.charAt(0) == '%') &&
                (escaped.charAt(escaped.length - 1) == '%')) {
                escaped = escaped.slice(1, -1);
            }
            escaped = escaped
                .replace(escape + '%', '')
                .replace(escape + '_', '');
            if (escaped.contains('%') || escaped.contains('_')) {
                return false;
            }
            return value.startsWith('%') && value.endsWith('%');
        },
        is_like: function(value, escape) {
            escape = escape || '\\';
            var escaped = value
                .replace(escape + '%', '')
                .replace(escape + '_', '');
            return escaped.contains('%') || escaped.contains('_');
        },
        unescape: function(value, escape) {
            escape = escape || '\\';
            return value
                .replace(escape + '%', '%')
                .replace(escape + '_', '_');
        },
        quote: function(value) {
            if (typeof value != 'string') {
                return value;
            }
            if (value.contains('\\')) {
                value = value.replace(new RegExp('\\\\', 'g'), '\\\\');
            }
            if (value.contains('"')) {
                value = value.replace(new RegExp('"', 'g'), '\\"');
            }
            var tests = [':', ' ', '(', ')'].concat(this.OPERATORS);
            for (var i = 0; i < tests.length; i++) {
                var test = tests[i];
                if (value.contains(test)) {
                    return '"' + value + '"';
                }
            }
            return value;
        },
        default_operator: function(field) {
            if (~['char', 'text', 'many2one', 'many2many', 'one2many',
                    'reference', 'one2one'].indexOf(field.type)) {
                return 'ilike';
            } else if (field.type == 'multiselection') {
                return 'in';
            } else {
                return '=';
            }
        },
        negate_operator: function(operator) {
            switch (operator) {
                case 'ilike':
                    return 'not ilike';
                case '=':
                    return '!=';
                case 'in':
                    return 'not in';
            }
        },
        time_format: function(field) {
            return new Sao.PYSON.Decoder({}).decode(field.format);
        },
        split_target_value: function(field, value) {
            var target = null;
            if (typeof value == 'string') {
                for (var i = 0; i < field.selection.length; i++) {
                    var selection = field.selection[i];
                    var key = selection[0];
                    var text = selection[1];
                    if (value.toLowerCase().startsWith(
                                text.toLowerCase() + ',')) {
                        target = key;
                        value = value.slice(text.length + 1);
                        break;
                    }
                }
            }
            return [target, value];
        },
        convert_value: function(field, value, context) {
            if (!context) {
                context = {};
            }
            var convert_selection = function() {
                if (typeof value == 'string') {
                    for (var i = 0; i < field.selection.length; i++) {
                        var selection = field.selection[i];
                        var key = selection[0];
                        var text = selection[1];
                        if (value.toLowerCase() == text.toLowerCase()) {
                            return key;
                        }
                    }
                }
                return value;
            };

            var converts = {
                'boolean': function() {
                    if (typeof value == 'string') {
                        return [Sao.i18n.gettext('y'),
                            Sao.i18n.gettext('Yes'),
                            Sao.i18n.gettext('True'),
                            Sao.i18n.gettext('t'),
                            '1'].some(
                                function(test) {
                                    return test.toLowerCase().startsWith(
                                        value.toLowerCase());
                                });
                    }
                    return null;
                },
                'float': function() {
                    var factor = Number(field.factor || 1);
                    var result = Number(value);
                    if (isNaN(result) || value === '' || value === null) {
                        return null;
                    } else {
                        return result / factor;
                    }
                },
                'integer': function() {
                    var factor = Number(field.factor || 1, 10);
                    var result = parseInt(value, 10);
                    if (isNaN(result)) {
                        return null;
                    } else {
                        return result / factor;
                    }
                },
                'numeric': function() {
                    var factor = Number(field.factor || 1);
                    var result = Number(value);
                    if (isNaN(result.valueOf()) ||
                            value === '' || value === null) {
                        return null;
                    } else {
                        return new Sao.Decimal(result / factor);
                    }
                },
                'selection': convert_selection,
                'multiselection': convert_selection,
                'reference': convert_selection,
                'datetime': () => Sao.common.parse_datetime(
                    Sao.common.date_format(context.date_format) + ' ' +
                    this.time_format(field), value),
                'date': function() {
                    return Sao.common.parse_date(
                            Sao.common.date_format(context.date_format),
                            value);
                },
                'time': () => {
                    try {
                        return Sao.common.parse_time(this.time_format(field),
                                value);
                    } catch (e) {
                        return null;
                    }
                },
                'timedelta': () => {
                    var converter = null;
                    if (field.converter) {
                        converter = this.context[field.converter];
                    }
                    return Sao.common.timedelta.parse(value, converter);
                },
                'many2one': function() {
                    if (value === '') {
                        return null;
                    } else {
                        return value;
                    }
                }
            };
            converts.timestamp = converts.datetime;
            var func = converts[field.type];
            if (func) {
                return func();
            } else {
                return value;
            }
        },
        format_value: function(field, value, target=null, context={}) {
            var format_float = function() {
                if (!value && value !== 0 && value !== new Sao.Decimal(0)) {
                    return '';
                }
                var digit = 0;
                var factor = Number(field.factor || 1);
                var string = String(value * factor);
                if (string.contains('e')) {
                    var exp = string.split('e')[1];
                    string = string.split('e')[0];
                    digit -= parseInt(exp);
                }
                if (string.contains('.')) {
                    digit += string.replace(/0+$/, '').split('.')[1].length;
                }
                return (value * factor).toFixed(digit);
            };
            var format_selection = function() {
                if (field.selection instanceof Array) {
                    for (var i = 0; i < field.selection.length; i++) {
                        if (field.selection[i][0] == value) {
                            return field.selection[i][1];
                        }
                    }
                }
                return value || '';
            };

            var format_reference = function() {
                if (!target) {
                    return format_selection();
                }
                for (var i = 0; i < field.selection.length; i++) {
                    if (field.selection[i][0] == target) {
                        target = field.selection[i][1];
                        break;
                    }
                }
                return target + ',' + value;
            };

            var converts = {
                'boolean': function() {
                    if (value === false) {
                        return Sao.i18n.gettext('False');
                    } else if (value) {
                        return Sao.i18n.gettext('True');
                    } else {
                        return '';
                    }
                },
                'integer': function() {
                    var factor = Number(field.factor || 1);
                    if (value || value === 0) {
                        return '' + parseInt(parseInt(value, 10) * factor, 10);
                    } else {
                        return '';
                    }
                },
                'float': format_float,
                'numeric': format_float,
                'selection': format_selection,
                'multiselection': format_selection,
                'reference': format_reference,
                'datetime': () => {
                    if (!value) {
                        return '';
                    }
                    if (value.isDate ||
                            !(value.hour() ||
                                value.minute() ||
                                value.second())) {
                        return Sao.common.format_date(
                                Sao.common.date_format(context.date_format),
                                value);
                    }
                    return Sao.common.format_datetime(
                        Sao.common.date_format(context.date_format) + ' ' +
                        this.time_format(field), value);
                },
                'date': () => Sao.common.format_date(
                    Sao.common.date_format(context.date_format), value),
                'time': () => {
                    if (!value) {
                        return '';
                    }
                    return Sao.common.format_time(
                            this.time_format(field),
                            value);
                },
                'timedelta': () => {
                    if (!value || !value.valueOf()) {
                        return '';
                    }
                    var converter = null;
                    if (field.converter) {
                        converter = this.context[field.converter];
                    }
                    return Sao.common.timedelta.format(value, converter);
                },
                'many2one': function() {
                    if (value === null) {
                        return '';
                    } else {
                        return value;
                    }
                }
            };
            converts.timestamp = converts.datetime;
            if (value instanceof Array) {
                return value.map(v => this.format_value(field, v)).join(';');
            } else {
                var func = converts[field.type];
                if (func) {
                    return this.quote(func(value));
                } else if (value === null) {
                    return '';
                } else {
                    return this.quote(value);
                }
            }
        },
        simplify: function(value) {
            if (this.is_subdomain(value)) {
                if ((value.length == 1) && this.is_subdomain(value[0])) {
                    return this.simplify(value[0]);
                } else if ((value.length == 2) &&
                    ((value[0] == 'AND') || (value[0] == 'OR')) &&
                    this.is_subdomain(value[1])) {
                    return this.simplify(value[1]);
                } else if ((value.length == 3) &&
                    ((value[0] == 'AND') || (value[0] == 'OR')) &&
                    this.is_subdomain(value[1]) &&
                    (value[0] == value[1][0])) {
                    value = this.simplify(value[1]).concat([value[2]]);
                }
                return value.map(v => this.simplify(v));
            }
            return value;
        }
    });

    Sao.common.DomainInversion = Sao.class_(Object, {
        and: function(a, b) {return a && b;},
        or: function(a, b) {return a || b;},
        OPERATORS: {
            '=': function(a, b) {
                return Sao.common.DomainInversion.equals(a, b);
            },
            '>': function(a, b) {return (a > b);},
            '<': function(a, b) {return (a < b);},
            '<=': function(a, b) {return (a <= b);},
            '>=': function(a, b) {return (a >= b);},
            '!=': function(a, b) {
                return !Sao.common.DomainInversion.equals(a, b);
            },
            'in': function(a, b) {
                return Sao.common.DomainInversion.in_(a, b);
            },
            'not in': function(a, b) {
                return !Sao.common.DomainInversion.in_(a, b);
            },
            'like': function(a, b) {
                return Sao.common.DomainInversion.sql_like(a, b, false);
            },
            'ilike': function(a, b) {
                return Sao.common.DomainInversion.sql_like(a, b, true);
            },
            'not like': function(a, b) {
                return !Sao.common.DomainInversion.sql_like(a, b, false);
            },
            'not ilike': function(a, b) {
                return !Sao.common.DomainInversion.sql_like(a, b, true);
            },
            // Those operators are not supported (yet ?)
            'child_of': function() {return true;},
            'not child_of': function() {return true;}
        },
        locale_part: function(expression, field_name, locale_name='id') {
            if (expression === field_name) {
                return locale_name;
            }
            if (expression.contains('.')) {
                return expression.split('.').slice(1).join('.');
            }
            return expression;
        },
        is_leaf: function(expression) {
            return ((expression instanceof Array) &&
                (expression.length > 2) &&
                (typeof expression[1] == 'string'));
        },
        constrained_leaf: function(part, boolop) {
            if (boolop === undefined) {
                boolop = this.and;
            }
            var operand = part[1];
            if ((operand === '=') & (boolop === this.and)) {
                // We should consider that other domain inversion will set a
                // correct value to this field
                return true;
            }
            return false;
        },
        eval_leaf: function(part, context, boolop) {
            if (boolop === undefined) {
                boolop = this.and;
            }
            var field = part[0];
            var operand = part[1];
            var value = part[2];
            if (field.contains('.')) {
                // In the case where the leaf concerns a m2o then having a
                // value in the evaluation context is deemed suffisant
                return Boolean(context[field.split('.')[0]]);
            }
            var context_field = context[field];
            if (!~['=', '!='].indexOf(operand) &&
                ((context_field === null) ||
                    (context_field === undefined) ||
                    (value === null) ||
                    (value === undefined)) &&
                !(~['in', 'not in'].indexOf(operand) &&
                    ((context_field === null) ||
                        (context_field === undefined)) &&
                    ((value instanceof Array) && ~value.indexOf(null)))) {
                return;
            }
            if (moment.isMoment(context_field) && !value) {
                if (context_field.isDateTime) {
                    value = Sao.DateTime.min;
                } else {
                    value = Sao.Date.min;
                }
            }
            if (moment.isMoment(value) && !context_field) {
                if (value.isDateTime) {
                    context_field = Sao.DateTime.min;
                } else {
                    context_field = Sao.Date.min;
                }
            }
            if ((context_field instanceof Array) & (value === null)) {
                value = [];
            }
            if ((typeof context_field == 'string') &&
                    (value instanceof Array) && value.length == 2) {
                value = value.join(',');
            } else if ((context_field instanceof Array) &&
                    (typeof value == 'string') && context_field.length == 2) {
                context_field = context_field.join(',');
            }
            if (~['=', '!='].indexOf(operand) &&
                    context_field instanceof Array &&
                    typeof value == 'number') {
                operand = {
                    '=': 'in',
                    '!=': 'not in'
                }[operand];
            }
            if (operand in this.OPERATORS) {
                return this.OPERATORS[operand](context_field, value);
            } else {
                return true;
            }
        },
        inverse_leaf: function(domain) {
            if (~['AND', 'OR'].indexOf(domain)) {
                return domain;
            } else if (this.is_leaf(domain)) {
                if (domain[1].contains('child_of') && !domain[0].contains('.')) {
                    if (domain.length == 3) {
                        return domain;
                    } else {
                        return [domain[3]].concat(domain.slice(1));
                    }
                }
                return domain;
            } else {
                return domain.map(d => this.inverse_leaf(d));
            }
        },
        filter_leaf: function(domain, field, model) {
            if (~['AND', 'OR'].indexOf(domain)) {
                return domain;
            } else if (this.is_leaf(domain)) {
                if (domain[0].startsWith(field) && (domain.length > 3)) {
                    if (domain[3] !== model) {
                        return ['id', '=', null];
                    }
                }
                return domain;
            } else {
                return domain.map(d => this.filter_leaf(d, field, model));
            }
        },
        eval_domain: function(domain, context, boolop) {
            if (boolop === undefined) {
                boolop = this.and;
            }
            if (this.is_leaf(domain)) {
                return this.eval_leaf(domain, context, boolop);
            } else if (jQuery.isEmptyObject(domain) && boolop == this.and) {
                return true;
            } else if (jQuery.isEmptyObject(domain) && boolop == this.or) {
                return false;
            } else if (domain[0] == 'AND') {
                return this.eval_domain(domain.slice(1), context);
            } else if (domain[0] == 'OR') {
                return this.eval_domain(domain.slice(1), context, this.or);
            } else {
                return boolop(Boolean(this.eval_domain(domain[0], context)),
                    Boolean(this.eval_domain(domain.slice(1), context, boolop))
                );
            }
        },
        localize_domain: function(domain, field_name, strip_target) {
            if (~['AND', 'OR', true, false].indexOf(domain)) {
                return domain;
            } else if (this.is_leaf(domain)) {
                if (domain[1].contains('child_of')) {
                    if (domain[0].split('.').length > 1) {
                        var target = domain[0].split('.').slice(1).join('.');
                        return [target].concat(domain.slice(1));
                    }
                    if (domain.length == 3) {
                        return domain;
                    } else {
                        return [domain[3]].concat(domain.slice(1, -1));
                    }
                }
                var local_name = 'id';
                if (typeof domain[2] == 'string') {
                    local_name = 'rec_name';
                }
                var n = strip_target ? 3 : 4;
                return [this.locale_part(domain[0], field_name, local_name)]
                    .concat(domain.slice(1, n)).concat(domain.slice(4));
            } else {
                return domain.map(
                    e => this.localize_domain(e, field_name, strip_target));
            }
        },

        _sort_key: function(domain) {
            if (!domain.length) {
                return [0, domain];
            } else if (this.is_leaf(domain)) {
                return [1, domain];
            } else if (~['AND', 'OR'].indexOf(domain)) {
                return [0, domain];
            } else {
                var content = domain.map(this._sort_key.bind(this));
                var nestedness = Math.max(...content.map(e => e[0]));
                return [nestedness + 1, content];
            }
        },

        _domain_compare: function(d1, d2) {
            if ((d1 instanceof Array) && (d2 instanceof Array)) {
                var elem_comparison;
                var min_len = Math.min(d1.length, d2.length);
                for (var i = 0; i < min_len; i++) {
                    elem_comparison = this._domain_compare(d1[i], d2[i]);
                    if (elem_comparison != 0) {
                        return elem_comparison;
                    }
                }
                if (d1.length == d2.length) {
                    return 0;
                } else {
                    return d1.length < d2.length ? -1 : 1;
                }
            } else if (d1 == d2) {
                return 0;
            } else {
                return d1 < d2 ? -1 : 1;
            }
        },

        sort: function(domain) {
            if (!domain.length) {
                return [];
            } else if (this.is_leaf(domain)) {
                return domain;
            } else if (~['AND', 'OR'].indexOf(domain)) {
                return domain;
            } else {
                var sorted_elements = domain.map(this.sort.bind(this));
                sorted_elements.sort(
                    (d1, d2) => this._domain_compare(this._sort_key(d1), this._sort_key(d2))
                );
                return sorted_elements;
            }
        },

        prepare_reference_domain: function(domain, reference) {

            var value2reference = function(value) {
                var model = null;
                var ref_id = null;
                if ((typeof(value) == 'string') && value.contains(',')) {
                    var split = value.split(',');
                    var result = split.splice(0, 1);
                    result.push(split.join(','));
                    model = result[0];
                    ref_id = result[1];
                    if (ref_id != '%') {
                        ref_id = parseInt(ref_id, 10);
                        if (isNaN(ref_id)) {
                            model = null;
                            ref_id = value;
                        }
                    }
                } else if ((value instanceof Array) &&
                        (value.length == 2) &&
                        (typeof(value[0]) == 'string') &&
                        ((typeof(value[1]) == 'number') ||
                            (value[1] == '%'))) {
                    model = value[0];
                    ref_id = value[1];
                } else {
                    ref_id = value;
                }
                return [model, ref_id];
            };

            if (~['AND', 'OR'].indexOf(domain)) {
                return domain;
            } else if (this.is_leaf(domain)) {
                if (domain[0] == reference) {
                    var model, ref_id, splitted;
                    if ((domain[1] == '=') || (domain[1] ==  '!=')) {
                        splitted = value2reference(domain[2]);
                        model = splitted[0];
                        ref_id = splitted[1];
                        if (model) {
                            if (ref_id == '%') {
                                if (domain[1] == '=') {
                                    return [
                                        reference + '.id', '!=', null, model];
                                } else {
                                    return [reference, 'not like', domain[2]];
                                }
                            }
                            return [
                                reference + '.id', domain[1], ref_id, model];
                        }
                    } else if ((domain[1] == 'in') || (domain[1] == 'not in')) {
                        var model_values = {};
                        var break_p = false;
                        for (var i=0; i < domain[2].length; i++) {
                            splitted = value2reference(domain[2][i]);
                            model = splitted[0];
                            ref_id = splitted[1];
                            if (!model) {
                                break_p = true;
                                break;
                            }
                            if (!(model in model_values)) {
                                model_values[model] = [];
                            }
                            model_values[model].push(ref_id);
                        }

                        if (!break_p) {
                            var ref_ids;
                            var new_domain;
                            if (domain[1] == 'in') {
                                new_domain = ['OR'];
                            } else {
                                new_domain = ['AND'];
                            }
                            for (model in model_values) {
                                ref_ids = model_values[model];
                                if (~ref_ids.indexOf('%')) {
                                    if (domain[1] == 'in') {
                                        new_domain.push(
                                            [reference + '.id', '!=', null,
                                                model]);
                                    } else {
                                        new_domain.push(
                                            [reference, 'not like',
                                                model + ',%']);
                                    }
                                } else {
                                    new_domain.push(
                                        [reference + '.id', domain[1],
                                            ref_ids.map(Number), model]);
                                }
                            }
                            return new_domain;
                        }
                    }
                    return [];
                }
                return domain;
            } else {
                return domain.map(
                    d => this.prepare_reference_domain(d, reference));
            }
        },
        extract_reference_models: function(domain, field_name) {
            if (~['AND', 'OR'].indexOf(domain)) {
                return [];
            } else if (this.is_leaf(domain)) {
                var local_part = domain[0].split('.', 1)[0];
                if ((local_part == field_name) &&
                        (domain.length > 3)) {
                    return [domain[3]];
                }
                return [];
            } else {
                var models = [];
                domain.map(d => {
                    var new_models = this.extract_reference_models(
                        d, field_name);
                    for (var i=0, len=new_models.length; i < len; i++) {
                        var model = new_models[i];
                        if (!~models.indexOf(model)) {
                            models.push(model);
                        }
                    }
                });
                return models;
            }
        },
        _bool_operator: function(domain) {
            var bool_op = 'AND';
            if ((domain.length > 0) &&
                ((domain[0] == 'AND') || (domain[0] == 'OR'))) {
                bool_op = domain[0];
            }
            return bool_op;
        },
        simplify_nested: function(domain) {
            if (!domain.length) {
                return [];
            } else if (this.is_leaf(domain)) {
                return [domain];
            } else if ((domain == 'AND') || (domain == 'OR')) {
                return [domain];
            } else if ((domain instanceof Array) && (domain.length == 1)) {
                return this.simplify_nested(domain[0]);
            } else {
                var simplified = [];
                for (var branch of domain) {
                    var simplified_branch = this.simplify_nested(branch);
                    if ((this._bool_operator(simplified_branch) ==
                                this._bool_operator(simplified)) ||
                            (simplified_branch.length == 1)) {
                        if ((simplified.length > 0) &&
                            (simplified_branch.length > 0) &&
                            ((simplified_branch[0] == 'AND') ||
                                (simplified_branch[0] == 'OR'))) {
                            simplified.push(...simplified_branch.slice(1));
                        } else {
                            simplified.push(...simplified_branch);
                        }
                    } else {
                        simplified.push(simplified_branch);
                    }
                }
                return simplified;
            }
        },
        simplify_duplicate: function(domain) {
            var dedup_branches = [];
            var bool_op = null;
            if (~['AND', 'OR'].indexOf(domain[0])) {
                bool_op = domain[0];
                domain = domain.slice(1);
            }
            for (var branch of domain) {
                var simplified_branch = this.simplify(branch);
                if (simplified_branch.length == 0) {
                    if (bool_op === 'OR') {
                        return [];
                    } else {
                        continue;
                    }
                }
                var found_branch = false;
                for (var duped_branch of dedup_branches) {
                    if (Sao.common.compare(
                        simplified_branch, duped_branch)) {
                        found_branch = true;
                        break;
                    }
                }
                if (!found_branch) {
                    dedup_branches.push(simplified_branch);
                }
            }

            if (bool_op && (dedup_branches.length > 1)) {
                dedup_branches.unshift(bool_op);
            }
            return dedup_branches;
        },
        simplify: function(domain) {
            if (this.is_leaf(domain)) {
                return [domain];
            } else if (!domain.length) {
                return [];
            } else {
                return this.simplify_nested(this.simplify_duplicate(domain));
            }
        },
        simplify_AND: function(domain) {
            if (this.is_leaf(domain)) {
                return domain;
            } else if (domain == 'OR') {
                return domain;
            } else {
                var simplified = [];
                for (const e of domain) {
                    if (e == 'AND') {
                        continue;
                    }
                    simplified.push(this.simplify_AND(e));
                }
                return simplified;
            }
        },
        canonicalize: function(domain) {
            return this.simplify_AND(this.sort(this.simplify(domain)));
        },
        merge: function(domain, domoperator) {
            if (jQuery.isEmptyObject(domain) ||
                    ~['AND', 'OR'].indexOf(domain)) {
                return [];
            }
            var domain_type = domain[0] == 'OR' ? 'OR' : 'AND';
            if (this.is_leaf(domain)) {
                return [domain];
            } else if (domoperator === undefined) {
                return [domain_type].concat([].concat.apply(
                    [], domain.map(e => this.merge(e, domain_type))));
            } else if (domain_type == domoperator) {
                return [].concat.apply(
                    [], domain.map(e => this.merge(e, domain_type)));
            } else {
                // without setting the domoperator
                return [this.merge(domain)];
            }
        },
        concat: function(domains, domoperator) {
            var result = [];
            if (domoperator) {
                result.push(domoperator);
            }
            for (const domain of domains) {
                if (!jQuery.isEmptyObject(domain)) {
                    result.push(domain);
                }
            }
            return this.simplify(this.merge(result));
        },
        unique_value: function(domain, single_value=true) {
            if ((domain instanceof Array) &&
                    (domain.length == 1)) {
                let [name, operator, value, ...model] = domain[0];
                const count = name.split('.').length - 1;
                if (
                    (operator == '=' ||
                        (single_value && operator == 'in' && value.length == 1)) &&
                    (!count ||
                        ((count === 1) && model.length && name.endsWith('.id')))) {
                    value = operator == '=' ? value : value[0];
                    if (model.length && name.endsWith('.id')) {
                        model = model[0];
                        value = [model, value];
                    }
                    return [true, name, value];
                }
            }
            return [false, null, null];
        },
        parse: function(domain) {
            var And = Sao.common.DomainInversion.And;
            var Or = Sao.common.DomainInversion.Or;
            if (this.is_leaf(domain)) {
                return domain;
            } else if (jQuery.isEmptyObject(domain)) {
                return new And([]);
            } else if (domain[0] === 'OR') {
                return new Or(domain.slice(1));
            } else {
                var begin = 0;
                if (domain[0] === 'AND') {
                    begin = 1;
                }
                return new And(domain.slice(begin));
            }
        },
        domain_inversion: function(domain, symbol, context={}) {
            var expression = this.parse(domain);
            if (!~expression.variables.indexOf(symbol)) {
                return true;
            }
            return expression.inverse(symbol, context);
        }
    });
    Sao.common.DomainInversion.equals = function(a, b) {
        if ((a instanceof Array) && (b instanceof Array)) {
            return Sao.common.compare(a, b);
        } else if (moment.isMoment(a) && moment.isMoment(b)) {
            return ((a.isDate == b.isDate) &&
                (a.isDateTime == b.isDateTime) &&
                (a.valueOf() == b.valueOf()));
        } else if ((a instanceof Number) || (b instanceof Number)) {
            return (Number(a) === Number(b));
        } else {
            return (a === b);
        }
    };
    Sao.common.DomainInversion.in_ = function(a, b) {
        if (a instanceof Array) {
            if (b instanceof Array) {
                for (var i = 0, len = a.length; i < len; i++) {
                    if (~b.indexOf(a[i])) {
                        return true;
                    }
                }
                return false;
            } else {
                return Boolean(~a.indexOf(b));
            }
        } else {
            return Boolean(~b.indexOf(a));
        }
    };
    Sao.common.DomainInversion.sql_like = function(value, pattern, ignore_case)
    {
        var escape = false;
        var chars = [];
        var splitted = pattern.split(/(.|\\)/);
        var char;
        for (var i=1, len=splitted.length; i < len; i = i+2) {
            char = splitted[i];
            if (escape) {
                if ((char == '%') || (char == '_')) {
                    chars.push(char);
                } else {
                    chars.push('\\', char);
                }
                escape = false;
            } else if (char == '\\') {
                escape = true;
            } else if (char == '_') {
                chars.push('.');
            } else if (char == '%') {
                chars.push('.*');
            } else {
                chars.push(char);
            }
        }

        if (!pattern.startsWith('%')) {
            chars.splice(0, 0, '^');
        }
        if (!pattern.endsWith('%')) {
            chars.push('$');
        }

        var flags = ignore_case ? 'i' : '';
        var regexp = new RegExp(chars.join(''), flags);
        return regexp.test(value);
    };
    Sao.common.DomainInversion.And = Sao.class_(Object, {
        init: function(expressions) {
            this.domain_inversion = new Sao.common.DomainInversion();
            this.branches = expressions.map(this.domain_inversion.parse.bind(
                    this.domain_inversion));
            this.variables = [];
            for (var i = 0, len = this.branches.length; i < len; i++) {
                var expression = this.branches[i];
                if (this.domain_inversion.is_leaf(expression)) {
                    this.variables.push(this.base(expression[0]));
                } else if (expression instanceof
                    Sao.common.DomainInversion.And) {
                    this.variables = this.variables.concat(
                        expression.variables);
                }
            }
        },
        base: function(expression) {
            if (!expression.contains('.')) {
                return expression;
            } else {
                return expression.split('.')[0];
            }
        },
        inverse: function(symbol, context) {
            var DomainInversion = Sao.common.DomainInversion;
            var result = [];
            for (var i = 0, len = this.branches.length; i < len; i++) {
                var part = this.branches[i];
                if (part instanceof DomainInversion.And) {
                    var part_inversion = part.inverse(symbol, context);
                    var evaluated = typeof part_inversion == 'boolean';
                    if (!~part.variables.indexOf(symbol)) {
                        continue;
                    }
                    if (!evaluated) {
                        result.push(part_inversion);
                    } else if (part_inversion) {
                        continue;
                    } else {
                        return false;
                    }
                } else if (this.domain_inversion.is_leaf(part) &&
                        (this.base(part[0]) === symbol)) {
                    result.push(part);
                } else {
                    var field = part[0];
                    if ((!(field in context)) ||
                        ((field in context) &&
                            (this.domain_inversion.eval_leaf(
                                part, context, this.domain_inversion.and) ||
                                this.domain_inversion.constrained_leaf(
                                    part, this.domain_inversion.and)))) {
                        result.push(true);
                    } else {
                        return false;
                    }
                }
            }
            result = result.filter(function(e) {
                return e !== true;
            });
            if (jQuery.isEmptyObject(result)) {
                return true;
            } else {
                return this.domain_inversion.simplify(result);
            }
        }
    });
    Sao.common.DomainInversion.Or = Sao.class_(Sao.common.DomainInversion.And, {
        inverse: function(symbol, context) {
            var DomainInversion = Sao.common.DomainInversion;
            var result = [];
            if (!jQuery.isEmptyObject(this.variables.filter(function(e) {
                    return (!(e in context)) && (e != symbol);
                }))) {
                // In this case we don't know enough about this OR part, we
                // consider it to be True (because people will have the
                // constraint on this part later).
                return true;
            }
            for (var i = 0, len = this.branches.length; i < len; i++) {
                var part = this.branches[i];
                if (part instanceof DomainInversion.And) {
                    var part_inversion = part.inverse(symbol, context);
                    var evaluated = typeof part_inversion == 'boolean';
                    if (!~part.variables.indexOf(symbol)) {
                        if (evaluated && part_inversion) {
                            return true;
                        }
                        continue;
                    }
                    if (!evaluated) {
                        result.push(part_inversion);
                    } else if (part_inversion) {
                        return true;
                    } else {
                        continue;
                    }
                } else if (this.domain_inversion.is_leaf(part) &&
                        (this.base(part[0]) == symbol)) {
                    result.push(part);
                } else {
                    var field = part[0];
                    field = this.base(field);
                    if ((field in context) &&
                        (this.domain_inversion.eval_leaf(
                            part, context, this.domain_inversion.or)) ||
                        this.domain_inversion.constrained_leaf(
                            part, this.domain_inversion.or)) {
                        return true;
                    } else if ((field in context) &&
                            !this.domain_inversion.eval_leaf(part, context,
                                this.domain_inversion.or)) {
                        result.push(false);
                    }
                }
            }
            result = result.filter(function(e) {
                return e !== false;
            });
            if (jQuery.isEmptyObject(result)) {
                return false;
            } else {
                return this.domain_inversion.simplify(['OR'].concat(result));
            }
        }
    });

    Sao.common.mimetypes = {
        'csv': 'text/csv',
        'doc': 'application/msword',
        'docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
        'gif': 'image/gif',
        'html': 'text/html',
        'jpeg': 'image/jpeg',
        'jpg': 'image/jpeg',
        'mpeg': 'video/mpeg',
        'mpg': 'video/mpeg',
        'ods': 'application/vnd.oasis.opendocument.spreadsheet',
        'odt': 'application/vnd.oasis.opendocument.text',
        'ogg': 'audio/ogg',
        'pdf': 'application/pdf',
        'png': 'image/png',
        'svg': 'image/svg+xml',
        'text': 'text/plain',
        'tif': 'image/tif',
        'tiff': 'image/tif',
        'txt': 'text/plain',
        'webp': 'image/webp',
        'xls': 'application/vnd.ms-excel',
        'xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
        'xml': 'application/xml',
        'xpm': 'image/x-xpixmap',
    };

    Sao.common.guess_mimetype = function(filename) {
        for (var ext in Sao.common.mimetypes) {
            var re = new RegExp('.*.' + ext + '$', 'i');
            if (re.test(filename)) {
                return Sao.common.mimetypes[ext];
            }
        }
        return 'application/octet-binary';
    };

    Sao.common.LOCAL_ICONS = [
        'tryton-add',
        'tryton-archive',
        'tryton-arrow-down',
        'tryton-arrow-left',
        'tryton-arrow-right',
        'tryton-arrow-up',
        'tryton-attach',
        'tryton-back',
        'tryton-barcode-scanner',
        'tryton-bookmark-border',
        'tryton-bookmarks',
        'tryton-bookmark',
        'tryton-cancel',
        'tryton-clear',
        'tryton-close',
        'tryton-copy',
        'tryton-create',
        'tryton-date',
        'tryton-delete',
        'tryton-download',
        'tryton-drag',
        'tryton-email',
        'tryton-error',
        'tryton-exit',
        'tryton-export',
        'tryton-filter',
        'tryton-format-align-center',
        'tryton-format-align-justify',
        'tryton-format-align-left',
        'tryton-format-align-right',
        'tryton-format-bold',
        'tryton-format-color-text',
        'tryton-format-italic',
        'tryton-format-underline',
        'tryton-forward',
        'tryton-history',
        'tryton-import',
        'tryton-info',
        'tryton-launch',
        'tryton-link',
        'tryton-log',
        'tryton-menu',
        'tryton-note',
        'tryton-ok',
        'tryton-open',
        'tryton-print',
        'tryton-public',
        'tryton-question',
        'tryton-refresh',
        'tryton-remove',
        'tryton-save',
        'tryton-search',
        'tryton-send',
        'tryton-sound-off',
        'tryton-sound-on',
        'tryton-star-border',
        'tryton-star',
        'tryton-switch',
        'tryton-translate',
        'tryton-unarchive',
        'tryton-undo',
        'tryton-unfold-less',
        'tryton-unfold-more',
        'tryton-warning',
    ];

    Sao.common.IconFactory = Sao.class_(Object, {
        batchnum: 10,
        _name2id: {},
        _icons: {},
        load_icons: function(refresh=false) {
            const icon_model = new Sao.Model('ir.ui.icon');
            var icons;
            try {
                icons = icon_model.execute('list_icons', [], {}, false);
            } catch (e) {
                icons = [];
            }
            const name2id = {};
            for (const icon of icons) {
                name2id[icon[1]] = icon[0];
            }
            this._name2id = name2id;
            if (!refresh) {
                for (const icon_name in this._icons) {
                    window.URL.revokeObjectURL(this._icons[icon_name]);
                }
                this._icons = {};
            }
            return name2id;
        },
        _get_icon: function(icon_name) {
            var url = this._icons[icon_name];
            if (url !== undefined) {
                return jQuery.when(url);
            }
            if (~Sao.common.LOCAL_ICONS.indexOf(icon_name)) {
                return jQuery.get('images/' + icon_name + '.svg', null, null, 'text')
                    .then(icon => {
                        var img_url = this._convert(icon);
                        this._icons[icon_name] = img_url;
                        return img_url;
                    })
                    .fail(() => {
                        Sao.Logger.error("Unknown icon %s", icon_name);
                        this._icons[icon_name] = null;
                    });
            }
            var name2id = this._name2id;
            if (!(icon_name in name2id)) {
                name2id = this.load_icons(true);
                if (!(icon_name in name2id)) {
                    Sao.Logger.error("Unknown icon %s", icon_name);
                    this._icons[icon_name] = null;
                    return jQuery.when();
                }
            }
            var ids = [];
            for (const name in name2id) {
                if ((!(name in this._icons)) || (name == icon_name)) {
                    ids.push(name2id[name]);
                }
            }
            const idx = ids.indexOf(name2id[icon_name]);
            const from = Math.max(Math.round(idx - this.batchnum / 2), 0);
            const to = Math.round(idx + this.batchnum / 2);
            ids = ids.slice(from, to);

            var icon_model = new Sao.Model('ir.ui.icon');
            var icons;
            try {
                icons = icon_model.execute(
                    'read', [ids, ['name', 'icon']], {}, false);
            } catch(e) {
                icons = [];
            }
            for (const icon of icons) {
                const icon_url = this._convert(icon.icon);
                this._icons[icon.name] = icon_url;
                if (icon.name == icon_name) {
                    url = icon_url;
                }
            }
            return jQuery.when(url);
        },
        _convert: function(data) {
            var xml = jQuery.parseXML(data);
            jQuery(xml).find('svg').attr('fill', Sao.config.icon_colors[0]);
            data = new XMLSerializer().serializeToString(xml);
            var blob = new Blob([data],
                {type: 'image/svg+xml'});
            return window.URL.createObjectURL(blob);
        },
        get_icon_url: function(icon_name) {
            if (!icon_name) {
                return jQuery.when('');
            }
            return this._get_icon(icon_name);
        },
        get_icon_img: function(icon_name, attrs) {
            attrs = attrs || {};
            if (!attrs['class']) {
                attrs['class'] = 'icon';
            }
            var img = jQuery('<img/>', attrs);
            if (icon_name) {
                this.get_icon_url(icon_name).then(function(url) {
                    img.attr('src', url);
                });
            }
            return img;
        },
    });

    Sao.common.ICONFACTORY = new Sao.common.IconFactory();

    Sao.common.UniqueDialog = Sao.class_(Object, {
        size: undefined,
        init: function() {
            this.running = false;
        },
        build_dialog: function() {
            var dialog = new Sao.Dialog('', this.class_, this.size, false);
            return dialog;
        },
        run: function() {
            if (this.running) {
                return jQuery.when();
            }
            var args = Array.prototype.slice.call(arguments);
            var prm = jQuery.Deferred();
            args.push(prm);
            var dialog = this.build_dialog.apply(this, args);
            dialog.content.submit(evt => {
                evt.preventDefault();
                dialog.footer.find('button.btn-primary').first().click();
            });
            this.running = true;
            dialog.modal.modal('show');
            dialog.modal.on('shown.bs.modal', function() {
                dialog.modal.find('input,select')
                    .filter(':visible').first().focus();
            });
            dialog.modal.on('keydown', e => {
                if (e.which == Sao.common.ESC_KEYCODE) {
                    this.close(dialog);
                    prm.reject();
                }
            });
            return prm;
        },
        close: function(dialog) {
            dialog.modal.on('hidden.bs.modal', function(event) {
                jQuery(this).remove();
            });
            dialog.modal.modal('hide');
            this.running = false;
        }
    });

    Sao.common.MessageDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'message-dialog',
        build_dialog: function(message, icon, prm) {
            var dialog = Sao.common.MessageDialog._super.build_dialog.call(
                this);
            dialog.header.remove();
            dialog.body.append(jQuery('<div/>', {
                'class': 'alert alert-info',
                role: 'alert'
            }).append(jQuery('<span/>')
                .text(message)
                .css('white-space', 'pre-wrap')));
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(() => {
                this.close(dialog);
                prm.resolve('ok');
            }).appendTo(dialog.footer);
            return dialog;
        },
        run: function(message, icon) {
            return Sao.common.MessageDialog._super.run.call(
                    this, message, icon || 'tryton-info');
        }
    });
    Sao.common.message = new Sao.common.MessageDialog();

    Sao.common.WarningDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'warning-dialog',
        size: 'md',
        build_dialog: function(message, title, prm) {
            var dialog = Sao.common.WarningDialog._super.build_dialog.call(
                this);
            var content = jQuery('<div/>', {
                'class': 'alert alert-warning',
                role: 'alert'
            }).append(jQuery('<h4/>')
                .text(title)
                .css('white-space', 'pre-wrap'));
            if (message) {
                content.append(jQuery('<span/>')
                    .text(message)
                    .css('white-space', 'pre-wrap'));
            }
            dialog.body.append(content);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(() => {
                this.close(dialog);
                prm.resolve('ok');
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.warning = new Sao.common.WarningDialog();

    Sao.common.UserWarningDialog = Sao.class_(Sao.common.WarningDialog, {
        class_: 'user-warning-dialog',
        size: 'md',
        build_dialog: function(message, title, prm) {
            var dialog = Sao.common.UserWarningDialog._super.build_dialog.call(
                this, message, title, prm);
            var always = jQuery('<input/>', {
                'type': 'checkbox'
            });
            dialog.body.append(jQuery('<div/>', {
                'class': 'checkbox',
            }).append(jQuery('<label/>')
                .text(Sao.i18n.gettext("Always ignore this warning."))
                .prepend(always))
            );
            dialog.body.append(jQuery('<p/>')
                    .text(Sao.i18n.gettext('Do you want to proceed?')));
            dialog.footer.empty();
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("No"),
            }).text(Sao.i18n.gettext('No')).click(() => {
                this.close(dialog);
                prm.reject();
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("Yes"),
            }).text(Sao.i18n.gettext('Yes')).click(() => {
                this.close(dialog);
                if (always.prop('checked')) {
                    prm.resolve('always');
                }
                prm.resolve('ok');
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.userwarning = new Sao.common.UserWarningDialog();

    Sao.common.ConfirmationDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'confirmation-dialog',
        build_dialog: function(message) {
            var dialog = Sao.common.ConfirmationDialog._super.build_dialog.call(
                this);
            dialog.header.remove();
            dialog.body.append(jQuery('<div/>', {
                'class': 'alert alert-info',
                role: 'alert'
            }).append(jQuery('<span/>')
                .text(message)
                .css('white-space', 'pre-wrap')));
            return dialog;
        }
    });

    Sao.common.SurDialog = Sao.class_(Sao.common.ConfirmationDialog, {
        build_dialog: function(message, prm) {
            var dialog = Sao.common.SurDialog._super.build_dialog.call(
                this, message);
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.close(dialog);
                prm.reject();
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(() => {
                this.close(dialog);
                prm.resolve();
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.sur = new Sao.common.SurDialog();

    Sao.common.Sur3BDialog = Sao.class_(Sao.common.ConfirmationDialog, {
        build_dialog: function(message, prm) {
            var dialog = Sao.common.SurDialog._super.build_dialog.call(
                this, message);
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.close(dialog);
                prm.resolve('cancel');
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'title': Sao.i18n.gettext("No"),
            }).text(Sao.i18n.gettext('No')).click(() => {
                this.close(dialog);
                prm.resolve('ko');
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("Yes"),
            }).text(Sao.i18n.gettext('Yes')).click(() => {
                this.close(dialog);
                prm.resolve('ok');
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.sur_3b = new Sao.common.Sur3BDialog();

    Sao.common.AskDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'ask-dialog',
        run: function() {
            var args = Array.prototype.slice.call(arguments);
            if (args.length == 2) {
                args.push(true);
            }
            return Sao.common.AskDialog._super.run.apply(this, args);
        },
        build_dialog: function(question, name, visibility, prm) {
            var dialog = Sao.common.AskDialog._super.build_dialog.call(this);
            dialog.header.remove();
            var entry = jQuery('<input/>', {
                'class': 'form-control',
                'type': visibility ? 'input' : 'password',
                'id': 'ask-dialog-entry',
                'name': name,
            });
            dialog.body.append(jQuery('<div/>', {
                'class': 'form-group'
            }).append(jQuery('<label/>', {
                'for': 'ask-dialog-entry'
            }).text(question)).append(entry));
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.close(dialog);
                prm.reject();
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(() => {
                this.close(dialog);
                prm.resolve(entry.val());
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.ask = new Sao.common.AskDialog();

    Sao.common.ConcurrencyDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'ask-dialog',
        size: 'md',
        build_dialog: function(model, record_id, context, prm) {
            var dialog = Sao.common.ConcurrencyDialog._super.build_dialog.call(
                this);
            dialog.add_title(Sao.i18n.gettext("Concurrency Warning"));
            dialog.body.append(jQuery('<div/>', {
                'class': 'alert alert-info',
                role: 'alert'
            }).append(jQuery('<p/>')
                .text(Sao.i18n.gettext('This record has been modified ' +
                'while you were editing it.')))
                .append(jQuery('<p/>').text(Sao.i18n.gettext('Choose:')))
                .append(jQuery('<ul/>')
                    .append(jQuery('<li/>')
                        .text(Sao.i18n.gettext('"Cancel" to cancel saving;')))
                    .append(jQuery('<li/>')
                        .text(Sao.i18n.gettext(
                                '"Compare" to see the modified version;')))
                    .append(jQuery('<li/>')
                        .text(Sao.i18n.gettext(
                                '"Write Anyway" to save your current version.'))))
                );
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.close(dialog);
                prm.reject();
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'title': Sao.i18n.gettext("Compare"),
            }).text(Sao.i18n.gettext('Compare')).click(() => {
                this.close(dialog);
                Sao.rpc({
                    'method': 'model.' + model + '.read',
                    'params': [[record_id], ['rec_name'], context],
                }, Sao.Session.current_session).then(function(result) {
                    var name = result[0].rec_name;
                    Sao.Tab.create({
                        'model': model,
                        'res_id': record_id,
                        name: Sao.i18n.gettext("Compare: %1", name),
                        'domain': [['id', '=', record_id]],
                        'context': context,
                        'mode': ['form'],
                    });
                    prm.reject();
                });
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'title': Sao.i18n.gettext("Write Anyway"),
            }).text(Sao.i18n.gettext('Write Anyway')).click(() => {
                this.close(dialog);
                prm.resolve();
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.concurrency = new Sao.common.ConcurrencyDialog();

    Sao.common.ErrorDialog = Sao.class_(Sao.common.UniqueDialog, {
        class_: 'error-dialog',
        size: 'md',
        build_dialog: function(title, details, prm) {
            var dialog = Sao.common.ConcurrencyDialog._super.build_dialog.call(
                this);
            dialog.add_title(Sao.i18n.gettext('Application Error'));
            const alert_ = jQuery('<div/>', {
                'class': 'alert alert-danger',
                role: 'alert'
            }).appendTo(dialog.body);
            alert_.append(jQuery('<h4/>')
                .text(title)
                .css('white-space', 'pre-wrap'));
            alert_.append(jQuery('<p/>').append(jQuery('<a/>', {
                'class': 'btn btn-default',
                role: 'button',
                'data-toggle': 'collapse',
                'data-target': '#error-detail',
                'aria-expanded': false,
                'aria-controls': '#error-detail',
            }).text(Sao.i18n.gettext("Details"))));
            alert_.append(jQuery('<p/>', {
                'class': 'collapse',
                id: 'error-detail',
            }).append(jQuery('<pre/>', {
                'class': 'pre-scrollable',
            }).text(details)));
            jQuery('<a/>', {
                'class': 'btn btn-link',
                href: Sao.config.bug_url,
                target: '_blank',
                rel: 'noreferrer noopener',
            }).text(Sao.i18n.gettext('Report Bug')).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("Close"),
            }).text(Sao.i18n.gettext('Close')).click(() => {
                this.close(dialog);
                prm.resolve();
            }).appendTo(dialog.footer);
            return dialog;
        }
    });
    Sao.common.error = new Sao.common.ErrorDialog();

    Sao.common.Processing = Sao.class_(Object, {
        queries: 0,
        timeout: 3000,
        init: function() {
            this.el = jQuery('<div/>', {
                'id': 'processing',
                'class': 'text-center'
            });
            var label = jQuery('<span/>', {
                'class': 'label label-info',
                'text': Sao.i18n.gettext('Processing'),
            }).appendTo(this.el);
            for (var i = 0; i < 3; i ++) {
                label.append(jQuery('<span/>', {
                    'class': 'dot',
                    'text': '.',
                }));
            }
            this.el.hide();
            jQuery(() => {
                this.el.appendTo('body');
            });
        },
        show: function() {
            return window.setTimeout(() => {
                this.queries += 1;
                this.el.show();
            }, this.timeout);
        },
        hide: function(timeoutID) {
            window.clearTimeout(timeoutID);
            if (this.queries > 0) {
                this.queries -= 1;
            }
            if (this.queries <= 0) {
                this.queries = 0;
                this.el.hide();
            }
        }
    });
    Sao.common.processing = new Sao.common.Processing();

    Sao.common.set_overflow = function set_overflow(el, state) {
        // We must set the overflow of the treeview and modal-body
        // containing the input to visible to prevent vertical scrollbar
        // inherited from the auto overflow-x
        // Idem when in navbar collapse for the overflow-y
        // (see http://www.w3.org/TR/css-overflow-3/#overflow-properties)
        var overflow, height;
        if (state == 'hide') {
            overflow = '';
            height = '';
        } else {
            overflow = 'visible';
            height = 'none';
        }
        el.closest('.treeview')
            .css('overflow', overflow)
            .css('max-height', height);
        el.closest('.modal-body').css('overflow', overflow);
        el.closest('.navbar-collapse.in').css('overflow-y', overflow);
        el.closest('.content-box').css('overflow-y', overflow);
        el.parents('fieldset.form-panel').css('overflow', overflow);
        el.parents('fieldset.form-panel').children('.form-container')
            .css('overflow', overflow);
        el.parents('.form-container').css('overflow', overflow);
        el.parents('.form-item, .form-widget').css('overflow', overflow);
        el.parents('fieldset.form-group_').css('overflow', overflow);
        Sao.common.scrollIntoViewIfNeeded(el);
    };

    Sao.common.InputCompletion = Sao.class_(Object, {
        init: function(el, source, match_selected, format) {
            Sao.common.InputCompletion._next_debug_id =
                (Sao.common.InputCompletion._next_debug_id || 0) + 1;
            this._debug_id = Sao.common.InputCompletion._next_debug_id;
            if (!el.is('input')) {
                el.addClass('dropdown');
                this.dropdown = el;
            } else {
                el.wrap('<div class="dropdown"/>');
                this.dropdown = el.parent();
            }
            this.input = el.find('input').add(el.filter('input')).first();
            this.input.attr('autocomplete', 'off');
            // bootstrap requires an element with data-toggle
            jQuery('<span/>', {
                'data-toggle': 'dropdown'
            }).appendTo(this.dropdown);
            this.menu = jQuery('<ul/>', {
                'class': 'dropdown-menu',
                'role': 'listbox'
            }).appendTo(this.dropdown);
            this.separator = jQuery('<li/>', {
                'role': 'separator',
                'class': 'divider'
            }).appendTo(this.menu);
            this.separator.hide();

            this.source = source;
            this.match_selected = match_selected;
            this.format = format;
            this.action_activated = null;

            this._search_text = null;
            this._last_values = [];
            this._trace_completion('init');

            this.input.on('input', () => {
                this._trace_completion('input', {
                    value: this.input.val(),
                });
                window.setTimeout(this._input.bind(this), 300,
                        this.input.val());
            });
            this.input.keydown(evt => {
                if (evt.which == Sao.common.ESC_KEYCODE) {
                    if (this.dropdown.hasClass('open')) {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this.menu.dropdown('toggle');
                    }
                } else if (evt.which == Sao.common.DOWN_KEYCODE) {
                    if (this.dropdown.hasClass('open')) {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this.menu.find('li > a').first().focus();
                    }
                }
            });
            this.menu.keydown(evt => {
                if (evt.which == Sao.common.ESC_KEYCODE) {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this.menu.dropdown('toggle');
                }
            });
            this.dropdown.on('hide.bs.dropdown', () => {
                this.input.focus();
                this.dropdown.removeClass('sao-completion-open');
                this.dropdown.parents('.form-item, .form-widget, .tab-pane')
                    .removeClass('sao-completion-open');
                this.dropdown.parents('fieldset.form-panel')
                    .removeClass('sao-completion-open');
                this.dropdown.parents('fieldset.form-group_')
                    .removeClass('sao-completion-open');
                Sao.common.set_overflow(this.input, 'hide');
                this._trace_completion('hide');
            });
            this.dropdown.on('show.bs.dropdown', () => {
                this.dropdown.addClass('sao-completion-open');
                this.dropdown.parents('.form-item, .form-widget, .tab-pane')
                    .addClass('sao-completion-open');
                this.dropdown.parents('fieldset.form-panel')
                    .addClass('sao-completion-open');
                this.dropdown.parents('fieldset.form-group_')
                    .addClass('sao-completion-open');
                Sao.common.set_overflow(this.input, 'show');
                window.setTimeout(() => this._trace_completion('show'), 0);
            });
        },
        _trace_completion: function(reason, extra) {
            if (!window.console) {
                return;
            }
            var input = this.input && this.input[0];
            var menu = this.menu && this.menu[0];
            var dropdown = this.dropdown && this.dropdown[0];
            var rect = function(element) {
                if (!element || !element.getBoundingClientRect) {
                    return null;
                }
                var box = element.getBoundingClientRect();
                return {
                    left: Math.round(box.left),
                    top: Math.round(box.top),
                    width: Math.round(box.width),
                    height: Math.round(box.height),
                    bottom: Math.round(box.bottom),
                };
            };
            var css_snapshot = function(element) {
                if (!element || !window.getComputedStyle) {
                    return null;
                }
                var style = window.getComputedStyle(element);
                return {
                    tag: element.tagName,
                    classes: element.className || '',
                    display: style.display,
                    position: style.position,
                    zIndex: style.zIndex,
                    overflow: style.overflow,
                    overflowX: style.overflowX,
                    overflowY: style.overflowY,
                };
            };
            var ancestors = [];
            if (input) {
                var current = input.parentElement;
                var count = 0;
                while (current && count < 10) {
                    ancestors.push(css_snapshot(current));
                    current = current.parentElement;
                    count += 1;
                }
            }
            var payload = jQuery.extend({
                id: this._debug_id,
                reason: reason,
                fieldName: this.input && this.input.attr('name') || null,
                value: this.input && this.input.val ? this.input.val() : null,
                open: this.dropdown && this.dropdown.hasClass('open'),
                completionCount: this.menu ?
                    this.menu.find('li.completion').length : null,
                actionCount: this.menu ?
                    this.menu.find('li.action').length : null,
                allowCreate: Boolean(this._allow_create),
                inputRect: rect(input),
                dropdownRect: rect(dropdown),
                menuRect: rect(menu),
                inputCss: css_snapshot(input),
                dropdownCss: css_snapshot(dropdown),
                menuCss: css_snapshot(menu),
                ancestors: ancestors,
            }, extra || {});
        },
        set_actions: function(action_activated, search=true, create=true) {
            if (action_activated !== undefined) {
                this.action_activated = action_activated;
            }
            var actions = [];
            if (search) {
                actions.push(['search', Sao.i18n.gettext('Search...')]);
            }
            if (create) {
                actions.push(['create', Sao.i18n.gettext('Create...')]);
            }
            this.menu.find('li.action').remove();
            if (jQuery.isEmptyObject(actions)) {
                this.separator.hide();
                this._trace_completion('set_actions', {
                    search: search,
                    create: create,
                    actionCount: 0,
                });
                return;
            }
            this.separator.show();
            actions.forEach(function(action) {
                var action_id = action[0];
                var content = action[1];
                jQuery('<li/>', {
                    'class': 'action action-' + action_id
                }).append(jQuery('<a/>', {
                    'href': '#'
                }).text(this._format_action(content)))
                .click(evt => {
                    evt.preventDefault();
                    if (this.action_activated) {
                        this.action_activated(action_id);
                    }
                    this.input.val('');
                })
                .appendTo(this.menu);
            }, this);
            this._trace_completion('set_actions', {
                search: search,
                create: create,
                actionCount: actions.length,
            });
        },
        _format: function(content) {
            if (this.format) {
                return this.format(content);
            }
            return jQuery('<span/>').text(content);
        },
        _format_action: function(content) {
            if (this.format_action) {
                return this.format_action(content);
            }
            return content;
        },
        _input: function(text) {
            if (text != this.input.val()) {
                this._trace_completion('input_stale', {
                    requestedText: text,
                    currentText: this.input.val(),
                });
                return;
            }
            var prm;
            if (this.source instanceof Array) {
                prm = jQuery.when(this.source.filter(function(value) {
                    return value.toLowerCase().startsWith(text.toLowerCase());
                }));
            } else {
                prm = this.source(text);
            }
            prm.then(values => {
                if (text != this.input.val()) {
                    this._trace_completion('result_stale', {
                        requestedText: text,
                        currentText: this.input.val(),
                    });
                    return;
                }
                this._trace_completion('result', {
                    requestedText: text,
                    resultCount: values ? values.length : 0,
                    sample: (values || []).slice(0, 6).map(value =>
                        value && (value.name || value.rec_name || value)),
                });
                this._set_selection(values);
            });
        },
        _set_selection: function(values) {
            if (values === undefined) {
                values = [];
            }
            this._last_values = values.slice ? values.slice() : values;
            this.menu.find('li.completion').remove();
            values.reverse().forEach(function(value) {
                jQuery('<li/>', {
                    'class': 'completion'
                }).append(jQuery('<a/>', {
                    'href': '#'
                }).append(this._format(value)))
                .click(evt => {
                    evt.preventDefault();
                    if (this.match_selected) {
                        this.match_selected(value);
                    }
                    this.input.focus();
                }).prependTo(this.menu);
            }, this);
            if (!this.input.val() || (
                !this.menu.find('li.completion').length &&
                !this.menu.find('li.action').length)) {
                this._trace_completion('close_decision', {
                    reasonDetail: !this.input.val() ?
                        'empty_input' : 'no_completion_or_action',
                    valueCount: values.length,
                });
                if (this.dropdown.hasClass('open')) {
                    this.menu.dropdown('toggle');
                }
            } else {
                this._trace_completion('open_decision', {
                    valueCount: values.length,
                    completionCount: this.menu.find('li.completion').length,
                    actionCount: this.menu.find('li.action').length,
                });
                if (!this.dropdown.hasClass('open')) {
                    this.menu.dropdown('toggle');
                }
                window.setTimeout(() =>
                    this._trace_completion('after_open_decision'), 0);
            }
        }
    });

    Sao.common.get_completion = function(el, source,
            match_selected, action_activated, search=true, create=true) {
        var format = function(content) {
            return content.name;
        };
        var completion = new Sao.common.InputCompletion(
                el, source, match_selected, format);
        if (action_activated) {
            completion.set_actions(action_activated, search, create);
        }
        completion._allow_create = create;
        return completion;
    };

    Sao.common.update_completion = function(
            entry, record, field, model, domain) {
        var search_text = entry.val();
        var trace = function(reason, extra) {
            if (!window.console) {
                return;
            }
        };
        if (!search_text || !model) {
            trace('skip', {
                hasText: Boolean(search_text),
                hasModel: Boolean(model),
            });
            return jQuery.when();
        }
        if (domain === undefined) {
            domain = field.get_domain(record);
        }
        var context = field.get_search_context(record);

        var order = field.get_search_order(record);
        var sao_model = new Sao.Model(model);
        trace('request', {
            domain: domain,
            context: context,
            order: order,
            limit: Sao.config.limit,
        });
        return sao_model.execute(
            'autocomplete',
            [search_text, domain, Sao.config.limit, order], context)
        .then(
            function(result) {
                var filtered = result.filter((value) => {
                    return value.id || entry.completion._allow_create;
                }).map((value) => {
                    if (value.id === null) {
                        value.name = Sao.i18n.gettext(
                            'Create "%1"...', value.name);
                    }
                    return value;
                });
                trace('response', {
                    rawCount: result ? result.length : 0,
                    filteredCount: filtered.length,
                    allowCreate: Boolean(entry.completion._allow_create),
                    sample: filtered.slice(0, 6).map(value =>
                        value && (value.name || value.rec_name || value)),
                });
                return filtered;
            },
            function(error) {
                trace('error', {
                    error: error && (error.message || String(error)) || null,
                });
                Sao.Logger.warn(
                    "Unable to search for completion of %s", model);
            });
    };

    Sao.common.Paned = Sao.class_(Object, {
        init: function(orientation) {
            var row;
            this._orientation = orientation;
            this.el = jQuery('<div/>');
            if (orientation == 'horizontal') {
                row = jQuery('<div/>', {
                    'class': 'row'
                }).appendTo(this.el);
                this.child1 = jQuery('<div/>', {
                    'class': 'col-md-6'
                }).appendTo(row);
                this.child2 = jQuery('<div/>', {
                    'class': 'col-md-6'
                }).appendTo(row);
            } else if (orientation == 'vertical') {
                this.child1 = jQuery('<div/>', {
                    'class': 'row'
                }).appendTo(this.el);
                this.child2 = jQuery('<div/>', {
                    'class': 'row'
                }).appendTo(this.el);
            }
        },
        get_child1: function() {
            return this.child1;
        },
        get_child2: function() {
            return this.child2;
        }
    });

    Sao.common.get_focus_chain = function(element) {
        var elements = element.find('input,select,textarea');
        elements.sort(function(a, b) {
            if (('tabindex' in a.attributes) && ('tabindex' in b.attributes)) {
                var a_tabindex = parseInt(a.attributes.tabindex.value);
                var b_tabindex = parseInt(b.attributes.tabindex.value);
                return a_tabindex - b_tabindex;
            } else if ('tabindex' in a.attributes) {
                return -1;
            } else if ('tabindex' in b.attributes) {
                return 1;
            } else {
                return 0;
            }
        });
        return elements;
    };

    Sao.common.find_focusable_child = function(element) {
        var i, len, children, focusable;

        if (!element.is(':visible')) {
            return null;
        }
        if (~['input', 'select', 'textarea'].indexOf(
            element[0].tagName.toLowerCase()) &&
            !element.prop('readonly')) {
            return element;
        }

        children = Sao.common.get_focus_chain(element);
        for (i = 0, len = children.length; i < len; i++) {
            focusable = Sao.common.find_focusable_child(jQuery(children[i]));
            if (focusable) {
                return focusable;
            }
        }
    };

    Sao.common.find_first_focus_widget = function(ancestor, widgets) {
        var i, j;
        var children, commons, is_common;

        if (widgets.length == 1) {
            return jQuery(widgets[0]);
        }
        children = Sao.common.get_focus_chain(ancestor);
        for (i = 0; i < children.length; i++) {
            commons = [];
            for (j = 0; j < widgets.length; j++) {
                is_common = jQuery(widgets[j]).closest(children[i]).length > 0;
                if (is_common) {
                    commons.push(widgets[j]);
                }
            }
            if (commons.length > 0) {
                return Sao.common.find_first_focus_widget(jQuery(children[i]),
                        commons);
            }
        }
    };

    Sao.common.apply_label_attributes = function(label, readonly, required) {
        if (!readonly) {
            label.addClass('editable');
            if (required) {
                label.addClass('required');
            } else {
                label.removeClass('required');
            }
        } else {
            label.removeClass('editable required');
        }
    };

    Sao.common.download_file = function(data, name, options) {
        if (options === undefined) {
            var type = Sao.common.guess_mimetype(name);
            options = {type: type};
        }
        var blob = new Blob([data], options);

        if (window.navigator && window.navigator.msSaveOrOpenBlob) {
            window.navigator.msSaveOrOpenBlob(blob, name);
            return;
        }

        var blob_url = window.URL.createObjectURL(blob);

        var dialog = new Sao.Dialog(Sao.i18n.gettext('Download'));
        var close = function() {
            dialog.modal.modal('hide');
        };
        var a = jQuery('<a/>', {
                'href': blob_url,
                'download': name,
                'text': name,
                'target': '_blank'
                }).appendTo(dialog.body)
                .click(close);
        jQuery('<button/>', {
            'class': 'btn btn-default',
            'type': 'button',
            'title': Sao.i18n.gettext("Close"),
        }).text(Sao.i18n.gettext('Close')).click(close)
            .appendTo(dialog.footer);
        dialog.modal.on('shown.bs.modal', function() {
            // Force the click trigger
            a[0].click();
        });
        dialog.modal.modal('show');

        dialog.modal.on('hidden.bs.modal', function() {
            jQuery(this).remove();
            window.URL.revokeObjectURL(this.blob_url);
        });

    };

    Sao.common.get_input_data = function(input, callback, char_) {
        for (var i = 0; i < input[0].files.length; i++) {
            Sao.common.get_file_data(input[0].files[i], callback, char_);
        }
    };

    Sao.common.get_file_data = function(file, callback, char_) {
        var reader = new FileReader();
        reader.onload = function() {
            var value = new Uint8Array(reader.result);
            if (char_) {
                value = String.fromCharCode.apply(null, value);
            }
            callback(value, file.name);
        };
        reader.readAsArrayBuffer(file);
    };

    Sao.common.ellipsize = function(string, length) {
        if (string.length <= length) {
            return string;
        }
        var ellipsis = Sao.i18n.gettext('...');
        return string.slice(0, length - ellipsis.length) + ellipsis;
    };

    Sao.common.accesskey = function(string) {
        for (var i=0; i < string.length; i++) {
            var c = string.charAt(i).toLowerCase();
            // Skip sao and browser shortcuts
            if (!~['d', 'e', 'f', 'i', 'n', 't', 'w'].indexOf(c)) {
                return c;
            }
        }
    };

    Sao.common.debounce = function(func, wait) {
        return (...args) => {
            clearTimeout(func._debounceTimeout);
            func._debounceTimeout = setTimeout(() => {
                func.apply(this, args);
            }, wait);
        };
    };

    Sao.common.uuid4 = function() {
        return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g,
            function(c) {
                var r = Math.random() * 16 | 0;
                var v = c == 'x' ? r : (r & 0x3 | 0x8);
                return v.toString(16);
            });
    };

    Sao.common.COLOR_SCHEMES = {
        'red': '#cf1d1d',
        'green': '#3fb41b',
        'blue': '#224565',
        'grey': '#444444',
        'black': '#000000',
        'darkcyan': '#305755',
    };

    Sao.common.hex2rgb = function(hexstring, digits) {
        digits = digits || 2;
        var top = parseInt('f'.repeat(digits), 16);
        var r = parseInt(
            hexstring.substring(1, digits + 1), 16);
        var g = parseInt(
            hexstring.substring(digits + 1, digits * 2 + 1), 16);
        var b = parseInt(
            hexstring.substring(digits * 2 + 1, digits * 3 + 1), 16);
        return [r / top, g / top, b / top];
    };

    Sao.common.rgb2hex = function(rgb, digits) {
        digits = digits || 2;
        var top = parseInt('f'.repeat(digits), 16);
        return '#' + rgb
            .map(function(i) { return Math.round(i * top).toString(16); })
            .join('');
    };

    Sao.common.rgb2hsv = function(rgb) {
        var r = rgb[0],
            g = rgb[1],
            b = rgb[2];
        var maxc = Math.max.apply(null, rgb);
        var minc = Math.min.apply(null, rgb);
        var v = maxc;
        if (minc == maxc) return [0, 0, v];
        var s = (maxc - minc) / maxc;
        var rc = (maxc - r) / (maxc - minc);
        var gc = (maxc - g) / (maxc - minc);
        var bc = (maxc - b) / (maxc - minc);
        var h;
        if (r == maxc) {
            h = bc - gc;
        } else if (g == maxc) {
            h = 2 + rc - bc;
        } else {
            h = 4 + gc - rc;
        }
        h = (h / 6) % 1;
        return [h, s, v];
    };

    Sao.common.hsv2rgb = function(hsv) {
        var h = hsv[0],
            s = hsv[1],
            v = hsv[2];
        if (s == 0) return [v, v, v];
        var i = Math.trunc(h * 6);
        var f = (h * 6) - i;
        var p = v * (1 - s);
        var q = v * (1 - s * f);
        var t = v * (1 - s * (1 - f));
        i = i % 6;

        if (i == 0) return [v, t, p];
        else if (i == 1) return [q, v, p];
        else if (i == 2) return [p, v, t];
        else if (i == 3) return [p, q, v];
        else if (i == 4) return [t, p, v];
        else if (i == 5) return [v, p, q];
    };

    Sao.common.generateColorscheme = function(masterColor, keys, light=0.1) {
        var rgb = Sao.common.hex2rgb(
            Sao.common.COLOR_SCHEMES[masterColor] || masterColor);
        var hsv = Sao.common.rgb2hsv(rgb);
        var h = hsv[0],
            s = hsv[1],
            v = hsv[2];
        if (keys.length) {
            light = Math.min(light, (1 - v) / keys.length);
        }
        var golden_angle = 0.618033988749895;
        var colors = {};
        for (var i = 0; i < keys.length; i++) {
            var key = keys[i];
            colors[key] = Sao.common.rgb2hex(Sao.common.hsv2rgb(
                [(h + golden_angle * i) % 1, s, (v + light * i) % 1]));
        }
        return colors;
    };

    Sao.common.richtext_toolbar = function() {
        var toolbar = jQuery('<div/>', {
            'class': 'btn-toolbar',
            'role': 'toolbar',
        });

        var button_apply_command = function(evt) {
            document.execCommand(evt.data);
        };

        var add_buttons = function(buttons) {
            var group = jQuery('<div/>', {
                'class': 'btn-group',
                'role': 'group'
            }).appendTo(toolbar);
            for (const properties of buttons) {
                const button = jQuery('<button/>', {
                    'class': 'btn btn-default',
                    'type': 'button',
                    'title': properties.label,
                }).append(Sao.common.ICONFACTORY.get_icon_img(
                    'tryton-format-' + properties.icon)
                ).appendTo(group);
                button.click(properties.command, button_apply_command);
            }
        };

        add_buttons([
            {
                'icon': 'bold',
                'command': 'bold',
                'label': Sao.i18n.gettext("Bold"),
            }, {
                'icon': 'italic',
                'command': 'italic',
                'label': Sao.i18n.gettext("Italic"),
            }, {
                'icon': 'underline',
                'command': 'underline',
                'label': Sao.i18n.gettext("Underline"),
            }]);

        var selections = [
            {
                'heading': Sao.i18n.gettext('Font'),
                'options': ['Normal', 'Serif', 'Sans', 'Monospace'],  // XXX
                'command': 'fontname'
            }, {
                'heading': Sao.i18n.gettext('Size'),
                'options': [1, 2, 3, 4, 5, 6, 7],
                'command': 'fontsize'
            }];
        var add_option = function(dropdown, properties) {
            return function(option) {
                dropdown.append(jQuery('<li/>').append(jQuery('<a/>', {
                    'href': '#'
                }).text(option).click(function(evt) {
                    evt.preventDefault();
                    document.execCommand(properties.command, false, option);
                })));
            };
        };
        for (var properties of selections) {
            var group = jQuery('<div/>', {
                'class': 'btn-group',
                'role': 'group'
            }).appendTo(toolbar);
            jQuery('<button/>', {
                'class': 'btn btn-default dropdown-toggle',
                'type': 'button',
                'data-toggle': 'dropdown',
                'aria-expanded': false,
                'aria-haspopup': true
            }).append(properties.heading)
                .append(jQuery('<span/>', {
                    'class': 'caret'
                })).appendTo(group);
            var dropdown = jQuery('<ul/>', {
                'class': 'dropdown-menu'
            }).appendTo(group);
            properties.options.forEach(add_option(dropdown, properties));
        }

        add_buttons([
            {
                'icon': 'align-left',
                'command': Sao.i18n.rtl? 'justifyRight' : 'justifyLeft',
                'label': (Sao.i18n.rtl?
                    Sao.i18n.gettext("Justify Right") :
                    Sao.i18n.gettext("Justify Left")),
            }, {
                'icon': 'align-center',
                'command': 'justifyCenter',
                'label': Sao.i18n.gettext("Justify Center"),
            }, {
                'icon': 'align-right',
                'command': Sao.i18n.rtl? 'justifyLeft': 'justifyRight',
                'label': (Sao.i18n.rtl?
                    Sao.i18n.gettext("Justify Left") :
                    Sao.i18n.gettext("Justify Right")),
            }, {
                'icon': 'align-justify',
                'command': 'justifyFull',
                'label': Sao.i18n.gettext("Justify Full"),
            }]);

        // TODO backColor
        [['foreColor', '#000000']].forEach(
            function(e) {
                var command = e[0];
                var color = e[1];
                jQuery('<input/>', {
                    'class': 'btn btn-default',
                    'type': 'color'
                }).appendTo(toolbar)
                    .change(function() {
                        document.execCommand(command, false, jQuery(this).val());
                    }).focusin(function() {
                        document.execCommand(command, false, jQuery(this).val());
                    }).val(color);
            });
        return toolbar;
    };

    Sao.common.richtext_normalize = function(html) {
        var el = jQuery('<div/>').html(html);
        // TODO order attributes
        el.find('div').each(function(i, el) {
            el = jQuery(el);
            // Not all browsers respect the styleWithCSS
            if (el.css('text-align')) {
                // Remove browser specific prefix
                var align = el.css('text-align').split('-').pop();
                el.attr('align', align);
                el.css('text-align', '');
            }
            // Some browsers set start as default align
            if (el.attr('align') == 'start') {
                if (Sao.i18n.rtl) {
                    el.attr('align', 'right');
                } else {
                    el.attr('align', 'left');
                }
            }
        });
        return el.html();
    };

    Sao.common.image_url = function(data) {
        if (!data) {
            return null;
        }
        var type = '';
        try {
            var xml = data;
            if (xml instanceof Uint8Array) {
                xml = new TextDecoder().decode(data);
            }
            if (jQuery.parseXML(xml)) {
                type = 'image/svg+xml';
            }
        } catch (e) {
            // continue
        }
        var blob = new Blob([data], {type: type});
        return window.URL.createObjectURL(blob);
    };

    Sao.common.play_sound = function(sound='success') {
        var snd = new Audio('sounds/' + sound + '.wav');
        snd.volume = localStorage.getItem('sao_sound_volume') || 0.5;
        snd.play();
    };

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    function get_x2m_sub_fields(f_attrs, prefix) {
        if (f_attrs.loading == 'eager' && f_attrs.views) {
            // There's only one key but we don't know its value
            const [[, view],] = Object.entries(f_attrs.views);

            const sub_fields = view.fields || {};
            const x2m_sub_fields = [];

            for (const [s_field, f_def] of Object.entries(sub_fields)) {
                x2m_sub_fields.push(`${prefix}.${s_field}`);

                var type_ = f_def.type;
                if (['many2one', 'one2one', 'reference'].includes(type_)) {
                    x2m_sub_fields.push(`${prefix}.${s_field}.rec_name`);
                } else if (['selection', 'multiselection'].includes(type_)) {
                    x2m_sub_fields.push(`${prefix}.${s_field}:string`);
                } else if (['one2many', 'many2many'].includes(type_)) {
                    x2m_sub_fields.push(
                        ...get_x2m_sub_fields(f_def, `${prefix}.${s_field}`)
                    );
                }
            }

            x2m_sub_fields.push(
                `${prefix}._timestamp`,
                `${prefix}._write`,
                `${prefix}._delete`);
            return x2m_sub_fields;
        } else {
            return [];
        }
    }

    Sao.Model = Sao.class_(Object, {
        init: function(name, attributes) {
            attributes = attributes || {};
            this.name = name;
            this.session = Sao.Session.current_session;
            this.fields = {};
        },
        add_fields: function(descriptions) {
            var added = [];
            for (var name in descriptions) {
                var desc = descriptions[name];
                if (!(name in this.fields)) {
                    var Field = Sao.field.get(desc.type);
                    this.fields[name] = new Field(desc);
                    added.push(name);
                } else {
                    jQuery.extend(this.fields[name].description, desc);
                }
            }
            return added;
        },
        execute: function(
                method, params, context={}, async=true,
                process_exception=true) {
            var args = {
                'method': 'model.' + this.name + '.' + method,
                'params': params.concat(context)
            };
            return Sao.rpc(args, this.session, async, process_exception);
        },
        copy: function(records, context) {
            if (jQuery.isEmptyObject(records)) {
                return jQuery.when();
            }
            var record_ids = records.map(function(record) {
                return record.id;
            });
            return this.execute('copy', [record_ids, {}], context);
        }
    });

    Sao.Group = function(model, context, array) {
        array.prm = jQuery.when();
        array.model = model;
        array._context = context;
        array.on_write = [];
        array.parent = undefined;
        array.screens = [];
        array.parent_name = '';
        array.children = [];
        array.child_name = '';
        array.parent_datetime_field = undefined;
        array.record_removed = [];
        array.record_deleted = [];
        array.__readonly = false;
        array.exclude_field = null;
        array.skip_model_access = false;
        array.forEach(function(e, i, a) {
            e.group = a;
        });
        Object.defineProperty(array, 'readonly', {
            get: function() {
                // Must skip res.user for Preference windows
                var access = Sao.common.MODELACCESS.get(this.model.name);
                if (this.context._datetime ||
                    (!(access.write || access.create) &&
                        !this.skip_model_access)) {
                    return true;
                }
                return this.__readonly;
            },
            set: function(value) {
                this.__readonly = value;
            }
        });
        array.load = function(ids, modified=false, position=-1, preloaded=null) {
            if (position == -1) {
                position = this.length;
            }
            var new_records = [];
            for (const id of ids) {
                let new_record = this.get(id);
                if (!new_record) {
                    new_record = new Sao.Record(this.model, id);
                    new_record.group = this;
                    this.splice(position, 0, new_record);
                    position += 1;
                }
                if (preloaded && (id in preloaded)) {
                    new_record.set(preloaded[id], false, false);
                }
                new_records.push(new_record);
            }
            // Remove previously removed or deleted records
            var record_removed = [];
            for (const record of this.record_removed) {
                if (!~ids.indexOf(record.id)) {
                    record_removed.push(record);
                }
            }
            this.record_removed = record_removed;
            var record_deleted = [];
            for (const record of this.record_deleted) {
                if (!~ids.indexOf(record.id)) {
                    record_deleted.push(record);
                }
            }
            this.record_deleted = record_deleted;
            if (new_records.length && modified) {
                for (const record of new_records) {
                    record.modified_fields.id = true;
                }
                this.record_modified();
            }
        };
        array.get = function(id) {
            // TODO optimize
            for (const record of this) {
                if (record.id == id) {
                    return record;
                }
            }
        };
        array.new_ = function(default_, id, defaults=null) {
            var record = new Sao.Record(this.model, id);
            record.group = this;
            if (default_) {
                record.default_get(defaults);
            }
            return record;
        };
        array.add = function(record, position=-1, modified=true) {
            if (position == -1) {
                position = this.length;
            }
            position = Math.min(position, this.length);
            if (record.group != this) {
                record.group = this;
            }
            if (this.indexOf(record) < 0) {
                this.splice(position, 0, record);
            }
            for (var record_rm of this.record_removed) {
                if (record_rm.id == record.id) {
                    this.record_removed.splice(
                            this.record_removed.indexOf(record_rm), 1);
                }
            }
            for (var record_del of this.record_deleted) {
                if (record_del.id == record.id) {
                    this.record_deleted.splice(
                            this.record_deleted.indexOf(record_del), 1);
                }
            }
            record.modified_fields.id = true;
            if (modified) {
                // Set parent field to trigger on_change
                if (this.parent && this.model.fields[this.parent_name]) {
                    var field = this.model.fields[this.parent_name];
                    if ((field instanceof Sao.field.Many2One) ||
                            field instanceof Sao.field.Reference) {
                        var value = [this.parent.id, ''];
                        if (field instanceof Sao.field.Reference) {
                            value = [this.parent.model.name, value];
                        }
                        field.set_client(record, value);
                    }
                }
            }
            return record;
        };
        array.remove = function(
            record, remove, force_remove=false, modified=true) {
            if (record.id >= 0) {
                if (remove) {
                    if (~this.record_deleted.indexOf(record)) {
                        this.record_deleted.splice(
                                this.record_deleted.indexOf(record), 1);
                    }
                    if (!~this.record_removed.indexOf(record)) {
                        this.record_removed.push(record);
                    }
                } else {
                    if (~this.record_removed.indexOf(record)) {
                        this.record_removed.splice(
                                this.record_removed.indexOf(record), 1);
                    }
                    if (!~this.record_deleted.indexOf(record)) {
                        this.record_deleted.push(record);
                    }
                }
            }
            record.modified_fields.id = true;
            if ((record.id < 0) || force_remove) {
                this._remove(record);
            }
            if (modified) {
                this.record_modified();
            }
        };
        array._remove = function(record) {
            var idx = this.indexOf(record);
            this.splice(idx, 1);
        };
        array.unremove = function(record) {
            this.record_removed.splice(this.record_removed.indexOf(record), 1);
            this.record_deleted.splice(this.record_deleted.indexOf(record), 1);
            record.group.record_modified();
        };
        array.clear = function() {
            this.splice(0, this.length);
            this.record_removed = [];
            this.record_deleted = [];
        };
        array.record_modified = function() {
            if (!this.parent) {
                for (const screen of this.screens) {
                    screen.record_modified();
                }
            } else {
                this.parent.modified_fields[this.child_name] = true;
                this.parent.model.fields[this.child_name].changed(this.parent);
                this.parent.validate(null, true, false, true);
                this.parent.group.record_modified();
            }
        };
        array.record_notify = function(notifications) {
            for (const screen of this.screens) {
                screen.record_notify(notifications);
            }
        };
        array.delete_ = function(records) {
            if (jQuery.isEmptyObject(records)) {
                return jQuery.when();
            }
            var root_group = this.root_group;
            Sao.Logger.assert(records.every(
                r => r.model.name == this.model.name),
                'records not from the same model');
            Sao.Logger.assert(records.every(
                r =>  r.group.root_group == root_group),
                'records not from the same root group');
            records = records.filter(record => record.id >= 0);
            var context = this.context;
            context._timestamp = {};
            for (const record of records) {
                jQuery.extend(context._timestamp, record.get_timestamp());
            }
            var record_ids = records.map(function(record) {
                return record.id;
            });
            return root_group.on_write_ids(record_ids).then(reload_ids => {
                for (const record of records) {
                    record.destroy();
                }
                reload_ids = reload_ids.filter(e => !~record_ids.indexOf(e));
                return this.model.execute('delete', [record_ids], context)
                .then(() => {
                    root_group.reload(reload_ids);
                });
            });
        };
        Object.defineProperty(array, 'root_group', {
            get: function() {
                var root = this;
                var parent = this.parent;
                while (parent) {
                    root = parent.group;
                    parent = parent.parent;
                }
                return root;
            }
        });
        array.save = function() {
            var deferreds = [];
            this.forEach(record => {
                deferreds.push(record.save());
            });
            if (!jQuery.isEmptyObject(this.record_deleted)) {
                for (const record of this.record_deleted) {
                    this._remove(record);
                }
                deferreds.push(this.delete_(this.record_deleted));
                this.record_deleted.splice(0, this.record_deleted.length);
            }
            return jQuery.when.apply(jQuery, deferreds);
        };
        array.written = function(ids) {
            if (typeof(ids) == 'number') {
                ids = [ids];
            }
            return this.on_write_ids(ids).then(to_reload => {
                to_reload = to_reload.filter(e => !~ids.indexOf(e));
                this.root_group.reload(to_reload);
            });
        };
        array.reload = function(ids) {
            for (const child of this.children) {
                child.reload(ids);
            }
            for (const id of ids) {
                const record = this.get(id);
                if (record && jQuery.isEmptyObject(record.modified_fields)) {
                    record.cancel();
                }
            }
        };
        array.on_write_ids = function(ids) {
            var deferreds = [];
            var result = [];
            this.on_write.forEach(fnct => {
                var prm = this.model.execute(fnct, [ids], this._context)
                .then(res => {
                    jQuery.extend(result, res);
                });
                deferreds.push(prm);
            });
            return jQuery.when.apply(jQuery, deferreds).then(
                () => result.filter((e, i, a) =>  i == a.indexOf(e)));
        };
        array.set_parent = function(parent) {
            this.parent = parent;
            if (parent && parent.model.name == this.model.name) {
                this.parent.group.children.push(this);
            }
        };
        array.add_fields = function(fields) {
            var added = this.model.add_fields(fields);
            if (jQuery.isEmptyObject(this)) {
                return;
            }
            var new_ = [];
            for (const record of this) {
                if (record.id < 0) {
                    new_.push(record);
                }
            }
            if (new_.length && added.length) {
                this.model.execute('default_get', [added, this.context])
                    .then(values => {
                        for (const record of new_) {
                            record.set_default(values, true, false);
                        }
                        this.record_modified();
                    });
            }
        };
        array.destroy = function() {
            if (this.parent) {
                var i = this.parent.group.children.indexOf(this);
                if (~i) {
                    this.parent.group.children.splice(i, 1);
                }
            }
            this.parent = null;
        };
        Object.defineProperty(array, 'domain', {
            get: function() {
                var domain = [];
                for (const screen of this.screens) {
                    if (screen.attributes.domain) {
                        domain.push(screen.attributes.domain);
                    }
                }
                if (this.parent && this.child_name) {
                    var field = this.parent.model.fields[this.child_name];
                    return [domain, field.get_domain(this.parent)];
                } else {
                    return domain;
                }
            }
        });
        Object.defineProperty(array, 'context', {
            get: function() {
                return this._get_context();
            },
            set: function(context) {
                this._context = jQuery.extend({}, context);
            }
        });
        Object.defineProperty(array, 'local_context', {
            get: function() {
                return this._get_context(true);
            }
        });
        array._get_context = function(local) {
            var context;
            if (!local) {
                context = jQuery.extend({}, this.model.session.context);
            } else {
                context = {};
            }
            if (this.parent) {
                var parent_context = this.parent.get_context(local);
                jQuery.extend(context, parent_context);
                if (this.child_name in this.parent.model.fields) {
                    var field = this.parent.model.fields[this.child_name];
                    jQuery.extend(context, field.get_context(
                        this.parent, parent_context, local));
                }
            }
            jQuery.extend(context, this._context);
            if (this.parent_datetime_field) {
                context._datetime = this.parent.get_eval()[
                    this.parent_datetime_field];
            }
            return context;
        };
        array.clean4inversion = function(domain) {
            if (jQuery.isEmptyObject(domain)) {
                return [];
            }
            var inversion = new Sao.common.DomainInversion();
            var head = domain[0];
            var tail = domain.slice(1);
            if (~['AND', 'OR'].indexOf(head)) {
                // pass
            } else if (inversion.is_leaf(head)) {
                var field = head[0];
                if ((field in this.model.fields) &&
                        (this.model.fields[field].description.readonly)) {
                    head = [];
                }
            } else {
                head = this.clean4inversion(head);
            }
            return [head].concat(this.clean4inversion(tail));
        };
        array.domain4inversion = function() {
            var domain = this.domain;
            if (!this.__domain4inversion ||
                    !Sao.common.compare(this.__domain4inversion[0], domain)) {
                this.__domain4inversion = [domain, this.clean4inversion(domain)];
            }
            return this.__domain4inversion[1];
        };
        array.get_by_path = function(path) {
            path = jQuery.extend([], path);
            var record = null;
            var group = this;

            var browse_child = function() {
                if (jQuery.isEmptyObject(path)) {
                    return record;
                }
                var child_name = path[0][0];
                var id = path[0][1];
                path.splice(0, 1);
                record = group.get(id);
                if (!record) {
                    return null;
                }
                if (!child_name) {
                    return browse_child();
                }
                return record.load(child_name).then(function() {
                    group = record._values[child_name];
                    if (!group) {
                        return null;
                    }
                    return browse_child();
                });
            };
            return jQuery.when().then(browse_child);
        };
        array.set_sequence = function(field, position) {
            var changed = false;
            var prev = null;
            var index, update, value, cmp;
            if (position === 0) {
                cmp = function(a, b) { return a > b; };
            } else {
                cmp = function(a, b) { return a < b; };
            }
            for (const record of this) {
                if (record.get_loaded([field]) || changed || record.id < 0) {
                    if (prev) {
                        index = prev.field_get(field);
                    } else {
                        index = null;
                    }
                    update = false;
                    value = record.field_get(field);
                    if (value === null) {
                        if (index) {
                            update = true;
                        } else if (prev) {
                            if (record.id >= 0) {
                                update = cmp(record.id, prev.id);
                            } else if (position === 0) {
                                update = true;
                            }
                        }
                    } else if (value === index) {
                        if (prev) {
                            if (record.id >= 0) {
                                update = cmp(record.id, prev.id);
                            } else if (position === 0) {
                                update = true;
                            }
                        }
                    } else if (value <= (index || 0)) {
                        update = true;
                    }
                    if (update) {
                        if (index === null) {
                            index = 0;
                        }
                        index += 1;
                        record.field_set_client(field, index);
                        changed = record;
                    }
                }
                prev = record;
            }
        };
        return array;
    };

    Sao.Record = Sao.class_(Object, {
        id_counter: -1,
        init: function(model, id=null) {
            this.model = model;
            this.group = Sao.Group(model, {}, []);
            if (id === null) {
                this.id = Sao.Record.prototype.id_counter;
            } else {
                this.id = id;
            }
            if (this.id < 0) {
                Sao.Record.prototype.id_counter--;
            }
            this._values = {};
            this.modified_fields = {};
            this._loaded = {};
            this.fields = {};
            this._timestamp = null;
            this._write = true;
            this._delete = true;
            this.resources = null;
            this.button_clicks = {};
            this.links_counts = {};
            this.state_attrs = {};
            this.autocompletion = {};
            this.exception = false;
            this.destroyed = false;
        },
        get modified() {
            if (!jQuery.isEmptyObject(this.modified_fields)) {
                Sao.Logger.info(
                    "Modified fields of %s@%s", this.id, this.model.name,
                    Object.keys(this.modified_fields));
                return true;
            } else {
                return false;
            }
        },
        save: function(force_reload=false) {
            var context = this.get_context();
            var prm = jQuery.when();
            if ((this.id < 0) || this.modified) {
                var values = this.get();
                if (this.id < 0) {
                    // synchronous call to avoid multiple creation
                    try {
                        this.id = this.model.execute(
                            'create', [[values]], context,  false)[0];
                    } catch (e) {
                        if (e.promise) {
                            return e.then(() => this.save(force_reload));
                        } else {
                            return jQuery.Deferred().reject();
                        }
                    }
                } else {
                    if (!jQuery.isEmptyObject(values)) {
                        context._timestamp = this.get_timestamp();
                        prm = this.model.execute('write', [[this.id], values],
                            context);
                    }
                }
                prm = prm.done(() => {
                    this.cancel();
                    if (force_reload) {
                        return this.reload();
                    }
                });
                if (this.group) {
                    prm = prm.done(() => this.group.written(this.id));
                }
            }
            if (this.group.parent) {
                delete this.group.parent.modified_fields[this.group.child_name];
                prm = prm.done(() => this.group.parent.save(force_reload));
            }
            return prm;
        },
        reload: function(fields) {
            if (this.id < 0) {
                return jQuery.when();
            }
            if (!fields) {
                return this.load('*');
            } else {
                var prms = fields.map(field => this.load(field));
                return jQuery.when.apply(jQuery, prms);
            }
        },
        is_loaded: function(name) {
            return ((this.id < 0) || (name in this._loaded));
        },
        load: function(name, async=true, process_exception=true) {
            var fname;
            if (this.destroyed || this.is_loaded(name)) {
                if (async) {
                    return jQuery.when();
                } else if (name !== '*') {
                    return this.model.fields[name];
                } else {
                    return;
                }
            }
            if (async && this.group.prm.state() == 'pending') {
                return this.group.prm.then(() => this.load(name));
            }
            var id2record = {};
            id2record[this.id] = this;
            var loading, views, field;
            if (name == '*') {
                loading = 'eager';
                views = new Set();
                for (fname in this.model.fields) {
                    field = this.model.fields[fname];
                    if ((field.description.loading || 'eager') == 'lazy') {
                        loading = 'lazy';
                    }
                    for (const view of field.views) {
                        views.add(view);
                    }
                }
            } else {
                loading = this.model.fields[name].description.loading || 'eager';
                views = this.model.fields[name].views;
            }
            var fields = {};
            var views_operator;
            if (loading == 'eager') {
                for (fname in this.model.fields) {
                    field = this.model.fields[fname];
                    if ((field.description.loading || 'eager') == 'eager') {
                        fields[fname] = field;
                    }
                }
                views_operator = views.isSubsetOf.bind(views);
            } else {
                fields = this.model.fields;
                views_operator = function(view) {
                    return Boolean(this.intersection(view).size);
                }.bind(views);
            }
            var fnames = [];
            for (fname in fields) {
                field = fields[fname];
                if (!(fname in this._loaded) &&
                    (!views.size ||
                        views_operator(new Set(field.views)))) {
                    fnames.push(fname);
                }
            }
            var related_read_limit = null;
            var fnames_to_fetch = fnames.slice();
            var rec_named_fields = ['many2one', 'one2one', 'reference'];
            const selection_fields = ['selection', 'multiselection'];
            for (const fname of fnames) {
                var fdescription = this.model.fields[fname].description;
                if (~rec_named_fields.indexOf(fdescription.type))
                    fnames_to_fetch.push(fname + '.rec_name');
                else if (~selection_fields.indexOf(fdescription.type) &&
                    ((fdescription.loading || 'lazy') == 'eager')) {
                    fnames_to_fetch.push(fname + ':string');
                } else if (
                    ['many2many', 'one2many'].includes(fdescription.type)) {
                    var sub_fields = get_x2m_sub_fields(fdescription, fname);
                    fnames_to_fetch = [ ...fnames_to_fetch, ...sub_fields];
                    if (sub_fields.length > 0) {
                        related_read_limit = Sao.config.display_size;
                    }
                }
            }
            if (!~fnames.indexOf('rec_name')) {
                fnames_to_fetch.push('rec_name');
            }
            fnames_to_fetch.push('_timestamp');
            fnames_to_fetch.push('_write');
            fnames_to_fetch.push('_delete');

            var context = jQuery.extend({}, this.get_context());
            if (related_read_limit) {
                context.related_read_limit = related_read_limit;
            }
            if (loading == 'eager') {
                var limit = Math.trunc(Sao.config.limit /
                    Math.min(fnames_to_fetch.length, 10));

                const filter_group = record => {
                    return (!record.destroyed &&
                        (record.id >= 0) &&
                        !(name in record._loaded));
                };
                const filter_parent_group = record => {
                    return (filter_group(record) &&
                            (id2record[record.id] === undefined) &&
                            ((record.group === this.group) ||
                             // Don't compute context for same group
                             (JSON.stringify(record.get_context()) ===
                              JSON.stringify(context))));
                };
                var group, filter;
                if (this.group.parent &&
                        (this.group.parent.model.name == this.model.name)) {
                    group = [];
                    group = group.concat.apply(
                            group, this.group.parent.group.children);
                    filter = filter_parent_group;
                } else {
                    group = this.group;
                    filter = filter_group;
                }
                var idx = group.indexOf(this);
                if (~idx) {
                    var length = group.length;
                    var n = 1;
                    while ((Object.keys(id2record).length < limit) &&
                        ((idx - n >= 0) || (idx + n < length)) &&
                        (n < 2 * limit)) {
                            var record;
                            if (idx - n >= 0) {
                                record = group[idx - n];
                                if (filter(record)) {
                                    id2record[record.id] = record;
                                }
                            }
                            if (idx + n < length) {
                                record = group[idx + n];
                                if (filter(record)) {
                                    id2record[record.id] = record;
                                }
                            }
                            n++;
                        }
                }
            }

            for (fname in this.model.fields) {
                if ((this.model.fields[fname].description.type == 'binary') &&
                        ~fnames_to_fetch.indexOf(fname, fnames_to_fetch)) {
                    context[this.model.name + '.' + fname] = 'size';
                }
            }
            var result = this.model.execute('read', [
                Object.keys(id2record).map( e => parseInt(e, 10)),
                fnames_to_fetch], context, async, process_exception);
            const succeed = (values, exception=false) => {
                var id2value = {};
                for (const e of values) {
                    id2value[e.id] = e;
                }
                for (var id in id2record) {
                    var record = id2record[id];
                    if (!record.exception) {
                        record.exception = exception;
                    }
                    var value = id2value[id];
                    if (record && value) {
                        for (var key in this.modified_fields) {
                            delete value[key];
                        }
                        record.set(value, false);
                    }
                }
            };
            const failed = () => {
                var failed_values = [];
                var default_values;
                for (var id in id2record) {
                    default_values = {
                        id: id
                    };
                    for (const fname of fnames_to_fetch) {
                        if (fname != 'id') {
                            default_values[fname] = null;
                        }
                    }
                    failed_values.push(default_values);
                }
                return succeed(failed_values, true);
            };
            if (async) {
                this.group.prm = result.then(succeed, failed);
                return this.group.prm;
            } else {
                if (result) {
                    succeed(result);
                } else {
                    failed();
                }
                if (name !== '*') {
                    return this.model.fields[name];
                } else {
                    return;
                }
            }
        },
        set: function(values, modified=true, validate=true) {
            var name, value;
            var later = {};
            var fieldnames = [];
            for (name in values) {
                value = values[name];
                if (name == '_timestamp') {
                    // Always keep the older timestamp
                    if (!this._timestamp) {
                        this._timestamp = value;
                    }
                    continue;
                }
                if (name == '_write' || name == '_delete') {
                    this[name] = value;
                    continue;
                }
                if (!(name in this.model.fields)) {
                    if (name == 'rec_name') {
                        this._values[name] = value;
                    }
                    continue;
                }
                if (this.model.fields[name] instanceof Sao.field.One2Many) {
                    later[name] = value;
                    continue;
                }
                const field = this.model.fields[name];
                var related;
                if ((field instanceof Sao.field.Many2One) ||
                        (field instanceof Sao.field.Reference)) {
                    related = name + '.';
                    this._values[related] = values[related] || {};
                } else if ((field instanceof Sao.field.Selection) ||
                    (field instanceof Sao.field.MultiSelection)) {
                    related = name + ':string';
                    if (name + ':string' in values) {
                        this._values[related] = values[related];
                    } else {
                        delete this._values[related];
                    }
                }
                this.model.fields[name].set(this, value);
                this._loaded[name] = true;
                fieldnames.push(name);
            }
            for (name in later) {
                value = later[name];
                this.model.fields[name].set(this, value, false, values[`${name}.`]);
                this._loaded[name] = true;
                fieldnames.push(name);
            }
            if (validate) {
                this.validate(fieldnames, true, false, false);
            }
            if (modified) {
                this.set_modified();
            }
        },
        get: function() {
            var value = {};
            for (var name in this.model.fields) {
                var field = this.model.fields[name];
                if (field.description.readonly &&
                        !((field instanceof Sao.field.One2Many) &&
                            !(field instanceof Sao.field.Many2Many))) {
                    continue;
                }
                if ((this.modified_fields[name] === undefined) && this.id >= 0) {
                    continue;
                }
                value[name] = field.get(this);
                // Sending an empty x2MField breaks ModelFieldAccess.check
                if ((field instanceof Sao.field.One2Many) &&
                        (value[name].length === 0)) {
                    delete value[name];
                }
            }
            return value;
        },
        invalid_fields: function() {
            var fields = {};
            for (var fname in this.model.fields) {
                var field = this.model.fields[fname];
                var invalid = field.get_state_attrs(this).invalid;
                if (invalid) {
                    fields[fname] = invalid;
                }
            }
            return fields;
        },
        get_context: function(local) {
            if (!local) {
                return this.group.context;
            } else {
                return this.group.local_context;
            }
        },
        field_get: function(name) {
            return this.model.fields[name].get(this);
        },
        field_set: function(name, value) {
            this.model.fields[name].set(this, value);
        },
        field_get_client: function(name) {
            return this.model.fields[name].get_client(this);
        },
        field_set_client: function(name, value, force_change) {
            this.model.fields[name].set_client(this, value, force_change);
        },
        default_get: function(defaults=null) {
            if (!jQuery.isEmptyObject(this.model.fields)) {
                var context = this.get_context();
                if (defaults) {
                    for (const name in defaults) {
                        Sao.setdefault(context, `default_${name}` ,defaults[name]);
                    }
                }
                var prm = this.model.execute('default_get',
                        [Object.keys(this.model.fields)], context);
                return prm.then(values => {
                    if (this.group.parent &&
                            this.group.parent_name in this.group.model.fields) {
                        var parent_field =
                            this.group.model.fields[this.group.parent_name];
                        if (parent_field instanceof Sao.field.Reference) {
                            values[this.group.parent_name] = [
                                this.group.parent.model.name,
                                this.group.parent.id];
                        } else if (parent_field.description.relation ==
                                this.group.parent.model.name) {
                            values[this.group.parent_name] =
                                this.group.parent.id;
                        }
                    }
                    return this.set_default(values);
                });
            }
            return jQuery.when();
        },
        set_default: function(values, validate=true, modified=true) {
            var promises = [];
            var fieldnames = [];
            for (var fname in values) {
                if ((fname == '_write') ||
                    (fname == '_delete') ||
                    (fname == '_timestamp')) {
                    this[fname] = values[fname];
                    continue;
                }
                var value = values[fname];
                if (!(fname in this.model.fields)) {
                    continue;
                }
                if (fname == this.group.exclude_field) {
                    continue;
                }
                if ((this.model.fields[fname] instanceof Sao.field.Many2One) ||
                        (this.model.fields[fname] instanceof Sao.field.Reference)) {
                    var related = fname + '.';
                    this._values[related] = values[related] || {};
                }
                promises.push(this.model.fields[fname].set_default(this, value));
                this._loaded[fname] = true;
                fieldnames.push(fname);
            }
            return jQuery.when.apply(jQuery, promises).then(() => {
                this.on_change(fieldnames);
                this.on_change_with(fieldnames);
                const callback = () => {
                    if (modified) {
                        this.set_modified();
                        return jQuery.when.apply(
                            jQuery, this.group.root_group.screens
                            .map(screen => screen.display()));
                    }
                };
                if (validate) {
                    return this.validate(null, true)
                        .then(callback);
                } else {
                    return callback();
                }
            });
        },
        get_timestamp: function() {
            var timestamps = {};
            timestamps[this.model.name + ',' + this.id] = this._timestamp;
            for (var fname in this.model.fields) {
                if (!(fname in this._loaded)) {
                    continue;
                }
                jQuery.extend(timestamps,
                    this.model.fields[fname].get_timestamp(this));
            }
            return timestamps;
        },
        get_eval: function() {
            var value = {};
            for (var key in this.model.fields) {
                if (!(key in this._loaded) && this.id >= 0)
                    continue;
                value[key] = this.model.fields[key].get_eval(this);
            }
            value.id = this.id;
            return value;
        },
        get_on_change_value: function(skip) {
            var value = {};
            for (var key in this.model.fields) {
                if (skip && ~skip.indexOf(key)) {
                    continue;
                }
                if ((this.id >= 0) &&
                        (!this._loaded[key] || !this.modified_fields[key])) {
                    continue;
                }
                value[key] = this.model.fields[key].get_on_change_value(this);
            }
            value.id = this.id;
            return value;
        },
        _get_on_change_args: function(args) {
            var result = {};
            var values = Sao.common.EvalEnvironment(this, 'on_change');
            for (const arg of args) {
                var scope = values;
                for (const e of arg.split('.')) {
                    if (scope !== undefined) {
                        scope = scope[e];
                    }
                }
                result[arg] = scope;
            }
            return result;
        },
        on_change: function(fieldnames) {
            var values = {};
            for (const fieldname of fieldnames) {
                var on_change = this.model.fields[fieldname]
                .description.on_change;
                if (!jQuery.isEmptyObject(on_change)) {
                    values = jQuery.extend(values,
                        this._get_on_change_args(on_change));
                }
            }
            if (!jQuery.isEmptyObject(values)) {
                var changes;
                try {
                    if ((fieldnames.length == 1) ||
                        (values.id === undefined)) {
                        changes = [];
                        for (const fieldname of fieldnames) {
                            changes.push(this.model.execute(
                                'on_change_' + fieldname,
                                [values], this.get_context(), false));
                        }
                    } else {
                        changes = [this.model.execute(
                            'on_change',
                            [values, fieldnames], this.get_context(), false)];
                    }
                } catch (e) {
                    return;
                }
                changes.forEach(this.set_on_change, this);
            }

            var notification_fields = Sao.common.MODELNOTIFICATION.get(
                this.model.name);
            var notification_fields_set = new Set(notification_fields);
            if (fieldnames.some(field => notification_fields_set.has(field))) {
                values = this._get_on_change_args(notification_fields);
                this.model.execute(
                    'on_change_notify', [values], this.get_context())
                    .then(this.group.record_notify.bind(this.group));
            }
        },
        on_change_with: function(field_names) {
            var fieldnames = {};
            var values = {};
            var later = {};
            var fieldname, on_change_with;
            for (fieldname in this.model.fields) {
                on_change_with = this.model.fields[fieldname]
                    .description.on_change_with;
                if (jQuery.isEmptyObject(on_change_with)) {
                    continue;
                }
                for (var i = 0; i < field_names.length; i++) {
                    if (~on_change_with.indexOf(field_names[i])) {
                        break;
                    }
                }
                if (i >= field_names.length) {
                    continue;
                }
                if (!jQuery.isEmptyObject(Sao.common.intersect(
                                Object.keys(fieldnames).sort(),
                                on_change_with.sort()))) {
                    later[fieldname] = true;
                    continue;
                }
                fieldnames[fieldname] = true;
                values = jQuery.extend(values,
                    this._get_on_change_args(
                        on_change_with.concat([fieldname])));
                if ((this.model.fields[fieldname] instanceof
                            Sao.field.Many2One) ||
                        (this.model.fields[fieldname] instanceof
                         Sao.field.Reference)) {
                    delete this._values[fieldname + '.'];
                }
            }
            var changed;
            fieldnames = Object.keys(fieldnames);
            if (fieldnames.length) {
                try {
                    if ((fieldnames.length == 1) ||
                        (values.id === undefined)) {
                        changed = {};
                        for (const fieldname of fieldnames) {
                            changed = jQuery.extend(
                                changed,
                                this.model.execute(
                                    'on_change_with_' + fieldname,
                                    [values], this.get_context(), false));
                        }
                    } else {
                        changed = this.model.execute(
                            'on_change_with',
                            [values, fieldnames], this.get_context(), false);
                    }
                } catch (e) {
                    return;
                }
                this.set_on_change(changed);
            }
            if (!jQuery.isEmptyObject(later)) {
                values = {};
                for (const fieldname in later) {
                    on_change_with = this.model.fields[fieldname]
                        .description.on_change_with;
                    values = jQuery.extend(
                        values,
                        this._get_on_change_args(
                            on_change_with.concat([fieldname])));
                }
                fieldnames = Object.keys(later);
                try {
                    if ((fieldnames.length == 1) ||
                        (values.id === undefined)) {
                        changed = {};
                        for (const fieldname of fieldnames) {
                            changed = jQuery.extend(
                                changed,
                                this.model.execute(
                                    'on_change_with_' + fieldname,
                                    [values], this.get_context(), false));
                        }
                    } else {
                        changed = this.model.execute(
                            'on_change_with',
                            [values, fieldnames], this.get_context(), false);
                    }
                } catch (e) {
                    return;
                }
                this.set_on_change(changed);
            }
        },
        set_on_change: function(values) {
            var fieldname, value;
            for (fieldname in values) {
                value = values[fieldname];
                if (!(fieldname in this.model.fields)) {
                    continue;
                }
                if ((this.model.fields[fieldname] instanceof
                            Sao.field.Many2One) ||
                        (this.model.fields[fieldname] instanceof
                         Sao.field.Reference)) {
                    var related = fieldname + '.';
                    this._values[related] = values[related] || {};
                }
                this.load(fieldname, false).set_on_change(this, value);
            }
        },
        autocomplete_with: function(fieldname) {
            for (var fname in this.model.fields) {
                var field = this.model.fields[fname];
                var autocomplete = field.description.autocomplete || [];
                if (!~autocomplete.indexOf(fieldname)) {
                    continue;
                }
                this.do_autocomplete(fname);
            }
        },
        do_autocomplete: function(fieldname) {
            this.autocompletion[fieldname] = [];
            var field = this.model.fields[fieldname];
            var autocomplete = field.description.autocomplete;
            var values = this._get_on_change_args(autocomplete);
            var result;
            try {
                result = this.model.execute(
                    'autocomplete_' + fieldname, [values], this.get_context(),
                    false, false);
            } catch (e) {
                result = [];
            }
            this.autocompletion[fieldname] = result;
        },
        on_scan_code: function(code, depends) {
            depends = this.expr_eval(depends);
            var values = this._get_on_change_args(depends);
            return this.model.execute(
                'on_scan_code', [values, code], this.get_context(),
                true, false).then((changes) => {
                    this.set_on_change(changes);
                    this.set_modified();
                    return !jQuery.isEmptyObject(changes);
                });
        },
        reset: function(value) {
            this.cancel();
            this.set(value, true);
            if (this.group.parent) {
                this.group.parent.on_change([this.group.child_name]);
                this.group.parent.on_change_with([this.group.child_name]);
            }
        },
        expr_eval: function(expr) {
            if (typeof(expr) != 'string') return expr;
            if (!expr) {
                return;
            } else if (expr == '[]') {
                return [];
            } else if (expr == '{}') {
                return {};
            }
            var ctx = this.get_eval();
            ctx.context = this.get_context();
            ctx.active_model = this.model.name;
            ctx.active_id = this.id;
            if (this.group.parent && this.group.parent_name) {
                var parent_env = Sao.common.EvalEnvironment(this.group.parent);
                ctx['_parent_' + this.group.parent_name] = parent_env;
            }
            return new Sao.PYSON.Decoder(ctx).decode(expr);
        },
        rec_name: function() {
            var prm = this.model.execute('read', [[this.id], ['rec_name']],
                    this.get_context());
            return prm.then(function(values) {
                return values[0].rec_name;
            });
        },
        validate: function(fields, softvalidation, pre_validate, sync) {
            const validate_fields = () => {
                var result = true;
                for (var fname in this.model.fields) {
                    // Skip not loaded fields if sync and record is not new
                    if (sync && this.id >= 0 && !(fname in this._loaded)) {
                        continue;
                    }
                    var field = this.model.fields[fname];
                    if (fields && !~fields.indexOf(fname)) {
                        continue;
                    }
                    if (field.description.readonly) {
                        continue;
                    }
                    if (fname == this.group.exclude_field) {
                        continue;
                    }
                    if (!field.validate(this, softvalidation, pre_validate)) {
                        result = false;
                    }
                }
                return result;
            };
            if (sync) {
                return validate_fields();
            } else {
                return this._check_load(fields).then(validate_fields);
            }
        },
        pre_validate: function() {
            if (jQuery.isEmptyObject(this.modified_fields)) {
                return jQuery.Deferred().resolve(true);
            }
            var values = this._get_on_change_args(
                Object.keys(this.modified_fields).concat(['id']));
            return this.model.execute('pre_validate',
                    [values], this.get_context());
        },
        cancel: function() {
            this._loaded = {};
            this._values = {};
            this.modified_fields = {};
            this._timestamp = null;
            this.button_clicks = {};
            this.links_counts = {};
            this.exception = false;
        },
        _check_load: function(fields) {
            if (!this.get_loaded(fields)) {
                return this.reload(fields);
            }
            return jQuery.when();
        },
        get_loaded: function(fields) {
            if (!jQuery.isEmptyObject(fields)) {
                var result = true;
                for (const field of fields) {
                    if (!(field in this._loaded) && !(field in this.modified_fields)) {
                        result = false;
                    }
                }
                return result;
            }
            return Sao.common.compare(Object.keys(this.model.fields).sort(),
                    Object.keys(this._loaded).sort());
        },
        get root_parent() {
            var parent = this;
            while (parent.group.parent) {
                parent = parent.group.parent;
            }
            return parent;
        },
        get_path: function(group) {
            var path = [];
            var i = this;
            var child_name = '';
            while (i) {
                path.push([child_name, i.id]);
                if (i.group === group) {
                    break;
                }
                child_name = i.group.child_name;
                i = i.group.parent;
            }
            path.reverse();
            return path;
        },
        get_index_path: function(group) {
            var path = [],
                record = this;
            while (record) {
                path.push(record.group.indexOf(record));
                if (record.group === group) {
                    break;
                }
                record = record.group.parent;
            }
            path.reverse();
            return path;
        },
        children_group: function(field_name) {
            var group_prm = jQuery.Deferred();
            if (!field_name) {
                group_prm.resolve([]);
                return group_prm;
            }
            var load_prm = this._check_load([field_name]);
            load_prm.done(() => {
                var group = this._values[field_name];
                if (group === undefined) {
                    group_prm.resolve(null);
                    return;
                }

                if (group.model.fields !== this.group.model.fields) {
                    jQuery.extend(this.group.model.fields, group.model.fields);
                    group.model.fields = this.group.model.fields;
                }
                group.on_write = this.group.on_write;
                group.readonly = this.group.readonly;
                jQuery.extend(group._context, this.group._context);

                group_prm.resolve(group);
                return;
            });
            return group_prm;
        },
        get deleted() {
            return Boolean(~this.group.record_deleted.indexOf(this));
        },
        get removed() {
            return Boolean(~this.group.record_removed.indexOf(this));
        },
        get readonly() {
            return (this.deleted ||
                this.removed ||
                this.exception ||
                this.group.readonly ||
                !this._write);
        },
        get deletable() {
            return this._delete;
        },
        get identity() {
            return JSON.stringify(
                Object.keys(this._values).reduce((values, name) => {
                    var field = this.model.fields[name];
                    if (field) {
                        if (field instanceof Sao.field.Binary) {
                            values[name] = field.get_size(this);
                        } else {
                            values[name] = field.get(this);
                        }
                    }
                    return values;
                }, {}));
        },
        set_field_context: function() {
            for (var name in this.model.fields) {
                var field = this.model.fields[name];
                var value = this._values[name];
                if (!(value instanceof Array)) {
                    continue;
                }
                var context_descriptor = Object.getOwnPropertyDescriptor(
                    value, 'context');
                if (!context_descriptor || !context_descriptor.set) {
                    continue;
                }
                var context = field.description.context;
                if (context) {
                    value.context = this.expr_eval(context);
                }
            }
        },
        get_resources: function(reload) {
            var prm;
            if ((this.id >= 0) && (!this.resources || reload)) {
                prm = this.model.execute(
                    'resources', [this.id], this.get_context())
                    .then(resources => {
                        this.resources = resources;
                        return resources;
                    });
            } else {
                prm = jQuery.when(this.resources);
            }
            return prm;
        },
        get_button_clicks: function(name) {
            if (this.id < 0) {
                return jQuery.when();
            }
            var clicks = this.button_clicks[name];
            if (clicks !== undefined) {
                return jQuery.when(clicks);
            }
            return Sao.rpc({
                'method': 'model.ir.model.button.click.get_click',
                'params': [this.model.name, name, this.id, {}],
            }, this.model.session).then(clicks => {
                this.button_clicks[name] = clicks;
                return clicks;
            });
        },
        set_modified: function(field) {
            if (field) {
                this.modified_fields[field] = true;
            }
            this.group.record_modified();
        },
        destroy: function() {
            var vals = Object.values(this._values);
            for (const val of vals) {
                if (val &&
                    Object.prototype.hasOwnProperty.call(val, 'destroy')) {
                    val.destroy();
                }
            }
            this.destroyed = true;
        }
    });


    Sao.field = {};

    Sao.field.get = function(type) {
        switch (type) {
            case 'char':
                return Sao.field.Char;
            case 'selection':
                return Sao.field.Selection;
            case 'multiselection':
                return Sao.field.MultiSelection;
            case 'datetime':
            case 'timestamp':
                return Sao.field.DateTime;
            case 'date':
                return Sao.field.Date;
            case 'time':
                return Sao.field.Time;
            case 'timedelta':
                return Sao.field.TimeDelta;
            case 'float':
                return Sao.field.Float;
            case 'numeric':
                return Sao.field.Numeric;
            case 'integer':
                return Sao.field.Integer;
            case 'boolean':
                return Sao.field.Boolean;
            case 'many2one':
                return Sao.field.Many2One;
            case 'one2one':
                return Sao.field.One2One;
            case 'one2many':
                return Sao.field.One2Many;
            case 'many2many':
                return Sao.field.Many2Many;
            case 'reference':
                return Sao.field.Reference;
            case 'binary':
                return Sao.field.Binary;
            case 'dict':
                return Sao.field.Dict;
            default:
                return Sao.field.Char;
        }
    };

    Sao.field.Field = Sao.class_(Object, {
        _default: null,
        _single_value: true,
        init: function(description) {
            this.description = description;
            this.name = description.name;
            this.views = new Set();
        },
        set: function(record, value) {
            record._values[this.name] = value;
        },
        get: function(record) {
            var value = record._values[this.name];
            if (value === undefined) {
                value = this._default;
            }
            return value;
        },
        _has_changed: function(previous, value) {
            // Use stringify to compare object instance like Number for Decimal
            return JSON.stringify(previous) != JSON.stringify(value);
        },
        set_client: function(record, value, force_change) {
            var previous_value = this.get(record);
            this.set(record, value);
            if (this._has_changed(previous_value, this.get(record))) {
                this.changed(record);
                record.validate(null, true, false, true);
                record.set_modified(this.name);
            } else if (force_change) {
                this.changed(record);
                record.validate(null, true, false, true);
                record.set_modified();
            }
        },
        get_client: function(record) {
            return this.get(record);
        },
        set_default: function(record, value) {
            this.set(record, value);
            record.modified_fields[this.name] = true;
        },
        set_on_change: function(record, value) {
            this.set(record, value);
            record.modified_fields[this.name] = true;
        },
        changed: function(record) {
            record.on_change([this.name]);
            record.on_change_with([this.name]);
            record.autocomplete_with(this.name);
            record.set_field_context();
        },
        get_timestamp: function(record) {
            return {};
        },
        get_context: function(record, record_context, local) {
            var context;
            if (record_context) {
                context = jQuery.extend({}, record_context);
            } else {
                context = record.get_context(local);
            }
            jQuery.extend(context,
                record.expr_eval(this.description.context || {}));
            return context;
        },
        get_search_context: function(record) {
            var context = this.get_context(record);
            jQuery.extend(context,
                record.expr_eval(this.description.search_context || {}));
            return context;
        },
        get_search_order: function(record) {
            return record.expr_eval(this.description.search_order || null);
        },
        get_domains: function(record, pre_validate) {
            var inversion = new Sao.common.DomainInversion();
            var screen_domain = inversion.domain_inversion(
                    [record.group.domain4inversion(), pre_validate || []],
                    this.name, Sao.common.EvalEnvironment(record));
            if ((typeof screen_domain == 'boolean') && !screen_domain) {
                screen_domain = [['id', '=', null]];
            } else if ((typeof screen_domain == 'boolean') && screen_domain) {
                screen_domain = [];
            }
            var attr_domain = record.expr_eval(this.description.domain || []);
            return [screen_domain, attr_domain];
        },
        get_domain: function(record) {
            var domains = this.get_domains(record);
            var screen_domain = domains[0];
            var attr_domain = domains[1];
            var inversion = new Sao.common.DomainInversion();
            return inversion.concat(
                    [inversion.localize_domain(screen_domain), attr_domain]);
        },
        validation_domains: function(record, pre_validate) {
            var inversion = new Sao.common.DomainInversion();
            return inversion.concat(this.get_domains(record, pre_validate));
        },
        get_eval: function(record) {
            return this.get(record);
        },
        get_on_change_value: function(record) {
            return this.get_eval(record);
        },
        set_state: function(
            record, states=['readonly', 'required', 'invisible']) {
            var state_changes = record.expr_eval(
                    this.description.states || {});
            for (const state of states) {
                if ((state == 'readonly') && this.description.readonly) {
                    continue;
                }
                if (state_changes[state] !== undefined) {
                    this.get_state_attrs(record)[state] = state_changes[state];
                } else if (this.description[state] !== undefined) {
                    this.get_state_attrs(record)[state] =
                        this.description[state];
                }
            }
            if (record.group.readonly ||
                this.get_state_attrs(record).domain_readonly ||
                (record.parent_name == this.name)) {
                this.get_state_attrs(record).readonly = true;
            }
        },
        get_state_attrs: function(record) {
            if (!(this.name in record.state_attrs)) {
                record.state_attrs[this.name] = jQuery.extend(
                        {}, this.description);
            }
            if (record.group.readonly || record.readonly) {
                record.state_attrs[this.name].readonly = true;
            }
            return record.state_attrs[this.name];
        },
        _is_empty: function(record) {
            return !this.get_eval(record);
        },
        check_required: function(record) {
            var state_attrs = this.get_state_attrs(record);
            if (state_attrs.required == 1) {
                if (this._is_empty(record) && (state_attrs.readonly != 1)) {
                    return false;
                }
            }
            return true;
        },
        validate: function(record, softvalidation, pre_validate) {
            if (this.description.readonly) {
                return true;
            }
            var invalid = false;
            var state_attrs = this.get_state_attrs(record);
            var is_required = Boolean(parseInt(state_attrs.required, 10));
            var is_invisible = Boolean(parseInt(state_attrs.invisible, 10));
            state_attrs.domain_readonly = false;
            var inversion = new Sao.common.DomainInversion();
            var domain = inversion.simplify(this.validation_domains(record,
                        pre_validate));
            if (!softvalidation) {
                if (!this.check_required(record)) {
                    invalid = 'required';
                }
            }
            if (typeof domain == 'boolean') {
                if (!domain) {
                    invalid = 'domain';
                }
            } else if (Sao.common.compare(domain, [['id', '=', null]])) {
                invalid = 'domain';
            } else {
                let [screen_domain] = this.get_domains(record, pre_validate);
                var uniques = inversion.unique_value(
                    domain, this._single_value);
                var unique = uniques[0];
                var leftpart = uniques[1];
                var value = uniques[2];
                let unique_from_screen = inversion.unique_value(
                    screen_domain, this._single_value)[0];
                if (this._is_empty(record) &&
                    !is_required &&
                    !is_invisible &&
                    !unique_from_screen) {
                    // Do nothing
                } else if (unique) {
                    // If the inverted domain is so constraint that only one
                    // value is possible we should use it. But we must also pay
                    // attention to the fact that the original domain might be
                    // a 'OR' domain and thus not preventing the modification
                    // of fields.
                    if (value === false) {
                        // XXX to remove once server domains are fixed
                        value = null;
                    }
                    var setdefault = true;
                    var original_domain;
                    if (!jQuery.isEmptyObject(record.group.domain)) {
                        original_domain = inversion.merge(record.group.domain);
                    } else {
                        original_domain = inversion.merge(domain);
                    }
                    var domain_readonly = original_domain[0] == 'AND';
                    if (leftpart.contains('.')) {
                        var recordpart = leftpart.split('.', 1)[0];
                        var localpart = leftpart.split('.', 1)[1];
                        var constraintfields = [];
                        if (domain_readonly) {
                            for (const leaf of inversion.localize_domain(
                                original_domain.slice(1))) {
                                constraintfields.push(leaf);
                            }
                        }
                        if ((localpart != 'id') ||
                                !~constraintfields.indexOf(recordpart)) {
                            setdefault = false;
                        }
                    }
                    if (setdefault && !pre_validate) {
                        this.set_client(record, value);
                        state_attrs.domain_readonly = domain_readonly;
                    }
                }
                if (!inversion.eval_domain(domain,
                            Sao.common.EvalEnvironment(record))) {
                    invalid = domain;
                }
            }
            state_attrs.invalid = invalid;
            return !invalid;
        }
    });

    Sao.field.Char = Sao.class_(Sao.field.Field, {
        _default: '',
        set: function(record, value) {
            if (this.description.strip && value) {
                switch (this.description.strip) {
                    case 'leading':
                        value = value.trimStart();
                        break;
                    case 'trailing':
                        value = value.trimEnd();
                        break;
                    default:
                        value = value.trim();
                }
            }
            Sao.field.Char._super.set.call(this, record, value);
        },
        get: function(record) {
            return Sao.field.Char._super.get.call(this, record) || this._default;
        }
    });

    Sao.field.Selection = Sao.class_(Sao.field.Field, {
        _default: null,
        set_client: function(record, value, force_change) {
            // delete before trigger the display
            delete record._values[this.name + ':string'];
            Sao.field.Selection._super.set_client.call(
                this, record, value, force_change);
        }
    });

    Sao.field.MultiSelection = Sao.class_(Sao.field.Selection, {
        _default: null,
        _single_value: false,
        get: function(record) {
            var value = Sao.field.MultiSelection._super.get.call(this, record);
            if (jQuery.isEmptyObject(value)) {
                value = this._default;
            } else {
                value.sort();
            }
            return value;
        },
        get_eval: function(record) {
            var value = Sao.field.MultiSelection._super.get_eval.call(
                this, record);
            if (value === null) {
                value = [];
            }
            return value;
        },
        set_client: function(record, value, force_change) {
            if (value === null) {
                value = [];
            }
            if (typeof(value) == 'string') {
                value = [value];
            }
            if (value) {
                value = value.slice().sort();
            }
            Sao.field.MultiSelection._super.set_client.call(
                this, record, value, force_change);
        }
    });

    Sao.field.DateTime = Sao.class_(Sao.field.Field, {
        _default: null,
        time_format: function(record) {
            return record.expr_eval(this.description.format);
        },
        set_client: function(record, value, force_change) {
            var current_value;
            if (value) {
                if (value.isTime) {
                    current_value = this.get(record);
                    if (current_value) {
                        value = Sao.DateTime.combine(current_value, value);
                    } else {
                        value = null;
                    }
                } else if (value.isDate) {
                    current_value = this.get(record) || Sao.Time();
                    value = Sao.DateTime.combine(value, current_value);
                }
            }
            Sao.field.DateTime._super.set_client.call(this, record, value,
                force_change);
        },
        date_format: function(record) {
            var context = this.get_context(record);
            return Sao.common.date_format(context.date_format);
        }
    });

    Sao.field.Date = Sao.class_(Sao.field.Field, {
        _default: null,
        set_client: function(record, value, force_change) {
            if (value && !value.isDate) {
                value.isDate = true;
                value.isDateTime = false;
            }
            Sao.field.Date._super.set_client.call(this, record, value,
                force_change);
        },
        date_format: function(record) {
            var context = this.get_context(record);
            return Sao.common.date_format(context.date_format);
        }
    });

    Sao.field.Time = Sao.class_(Sao.field.Field, {
        _default: null,
        time_format: function(record) {
            return record.expr_eval(this.description.format);
        },
        set_client: function(record, value, force_change) {
            if (value && (value.isDate || value.isDateTime)) {
                value = Sao.Time(value.hour(), value.minute(),
                    value.second(), value.millisecond());
            }
            Sao.field.Time._super.set_client.call(this, record, value,
                force_change);
        }
    });

    Sao.field.TimeDelta = Sao.class_(Sao.field.Field, {
        _default: null,
        converter: function(group) {
            return group.context[this.description.converter];
        },
        set_client: function(record, value, force_change) {
            if (typeof(value) == 'string') {
                value = Sao.common.timedelta.parse(
                    value, this.converter(record.group));
            }
            Sao.field.TimeDelta._super.set_client.call(
                this, record, value, force_change);
        },
        get_client: function(record) {
            var value = Sao.field.TimeDelta._super.get_client.call(
                this, record);
            return Sao.common.timedelta.format(
                value, this.converter(record.group));
        }
    });

    Sao.field.Float = Sao.class_(Sao.field.Field, {
        _default: null,
        init: function(description) {
            Sao.field.Float._super.init.call(this, description);
            this._digits = {};
            this._symbol = {};
        },
        digits: function(record, factor=1) {
            var digits = record.expr_eval(this.description.digits);
            if (typeof(digits) == 'string') {
                if (!(digits in record.model.fields)) {
                    return;
                }
                var digits_field = record.model.fields[digits];
                var digits_name = digits_field.description.relation;
                var digits_id = digits_field.get(record);
                if (digits_name && (digits_id !== null) && (digits_id >= 0)) {
                    if (digits_id in this._digits) {
                        digits = this._digits[digits_id];
                    } else {
                        try {
                            digits = Sao.rpc({
                                'method': 'model.' + digits_name + '.get_digits',
                                'params': [digits_id, {}],
                            }, record.model.session, false);
                        } catch(e) {
                            Sao.Logger.warn(
                                "Fail to fetch digits for %s,%s",
                                digits_name, digits_id);
                            return;
                        }
                        this._digits[digits_id] = digits;
                    }
                } else {
                    return;
                }
            }
            if (!digits || !digits.every(function(e) {
                return e !== null;
            })) {
                return;
            }
            var shift = Math.round(Math.log(Math.abs(factor)) / Math.LN10);
            return [digits[0] + shift, digits[1] - shift];
        },
        get_symbol: function(record, symbol) {
            if (record && (symbol in record.model.fields)) {
                var value = this.get(record) || 0;
                var sign = 1;
                if (value < 0) {
                    sign = -1;
                } else if (value === 0) {
                    sign = 0;
                }
                var symbol_field = record.model.fields[symbol];
                var symbol_name = symbol_field.description.relation;
                var symbol_id = symbol_field.get(record);
                if (symbol_name && (symbol_id !== null) && (symbol_id >= 0)) {
                    if (symbol_id in this._symbol) {
                        return this._symbol[symbol_id];
                    }
                    try {
                        var result = Sao.rpc({
                            'method': 'model.' + symbol_name + '.get_symbol',
                            'params': [symbol_id, sign, record.get_context()],
                        }, record.model.session, false) || ['', 1];
                        this._symbol[symbol_id] = result;
                        return result;
                    } catch (e) {
                        Sao.Logger.warn(
                            "Fail to fetch symbol for %s,%s",
                            symbol_name, symbol_id);
                    }
                }
            }
            return ['', 1];
        },
        check_required: function(record) {
            var state_attrs = this.get_state_attrs(record);
            if (state_attrs.required == 1) {
                if ((this.get(record) === null) &&
                    (state_attrs.readonly != 1)) {
                    return false;
                }
            }
            return true;
        },
        convert: function(value) {
            if (!value && (value !== 0)) {
                return null;
            }
            value = Number(value);
            if (isNaN(value)) {
                value = this._default;
            }
            return value;
        },
        apply_factor: function(record, value, factor) {
            if (value !== null) {
                value /= factor;
                var digits = this.digits(record);
                if (digits) {
                    // Round to avoid float precision error
                    // after the division by factor
                    value = value.toFixed(digits[1]);
                }
                value = this.convert(value);
            }
            return value;
        },
        set_client: function(record, value, force_change, factor=1) {
            value = this.apply_factor(record, this.convert(value), factor);
            Sao.field.Float._super.set_client.call(this, record, value,
                force_change);
        },
        get_client: function(record, factor=1, grouping=true) {
            var value = this.get(record);
            if (value !== null) {
                var options = {
                    useGrouping: grouping,
                };
                var digits = this.digits(record, factor);
                if (digits) {
                    options.minimumFractionDigits = digits[1];
                    options.maximumFractionDigits = digits[1];
                }
                return (value * factor).toLocaleString(
                    Sao.i18n.BC47(Sao.i18n.getlang()), options);
            } else {
                return '';
            }
        }
    });

    Sao.field.Numeric = Sao.class_(Sao.field.Float, {
        convert: function(value) {
            if (!value && (value !== 0)) {
                return null;
            }
            value = new Sao.Decimal(value);
            if (isNaN(value.valueOf())) {
                value = this._default;
            }
            return value;
        },
    });

    Sao.field.Integer = Sao.class_(Sao.field.Float, {
        convert: function(value) {
            if (!value && (value !== 0)) {
                return null;
            }
            value = parseInt(value, 10);
            if (isNaN(value)) {
                value = this._default;
            }
            return value;
        }
    });

    Sao.field.Boolean = Sao.class_(Sao.field.Field, {
        _default: false,
        set_client: function(record, value, force_change) {
            value = Boolean(value);
            Sao.field.Boolean._super.set_client.call(this, record, value,
                force_change);
        },
        get: function(record) {
            return Boolean(record._values[this.name]);
        },
        get_client: function(record) {
            return Boolean(record._values[this.name]);
        }
    });

    Sao.field.Many2One = Sao.class_(Sao.field.Field, {
        _default: null,
        check_required: function(record) {
            var state_attrs = this.get_state_attrs(record);
            if (state_attrs.required == 1) {
                if ((this.get(record) === null) &&
                    (state_attrs.readonly != 1)) {
                    return false;
                }
            }
            return true;
        },
        get_client: function(record) {
            var rec_name = (record._values[this.name + '.'] || {}).rec_name;
            if (rec_name === undefined) {
                this.set(record, this.get(record));
                rec_name = (
                    record._values[this.name + '.'] || {}).rec_name || '';
            }
            return rec_name;
        },
        set: function(record, value) {
            var rec_name = (
                record._values[this.name + '.'] || {}).rec_name || '';
            if (!rec_name && (value >= 0) && (value !== null)) {
                var model_name = record.model.fields[this.name].description
                    .relation;
                rec_name = Sao.rpc({
                    'method': 'model.' + model_name + '.read',
                    'params': [[value], ['rec_name'], record.get_context()]
                }, record.model.session, false)[0].rec_name;
            }
            Sao.setdefault(
                record._values, this.name + '.', {}).rec_name = rec_name;
            record._values[this.name] = value;
        },
        set_client: function(record, value, force_change) {
            var rec_name;
            if (value instanceof Array) {
                rec_name = value[1];
                value = value[0];
            } else {
                if (value == this.get(record)) {
                    rec_name = (
                        record._values[this.name + '.'] || {}).rec_name || '';
                } else {
                    rec_name = '';
                }
            }
            if ((value < 0) && (this.name != record.group.parent_name)) {
                value = null;
                rec_name = '';
            }
            Sao.setdefault(
                record._values, this.name + '.', {}).rec_name = rec_name;
            Sao.field.Many2One._super.set_client.call(this, record, value,
                    force_change);
        },
        get_context: function(record, record_context, local) {
            var context = Sao.field.Many2One._super.get_context.call(
                this, record, record_context, local);
            if (this.description.datetime_field) {
                context._datetime = record.get_eval()[
                    this.description.datetime_field];
            }
            return context;
        },
        validation_domains: function(record, pre_validate) {
            return this.get_domains(record, pre_validate)[0];
        },
        get_domain: function(record) {
            var domains = this.get_domains(record);
            var screen_domain = domains[0];
            var attr_domain = domains[1];
            var inversion = new Sao.common.DomainInversion();
            return inversion.concat([
                    inversion.localize_domain(screen_domain, this.name),
                    attr_domain]);
        },
        get_on_change_value: function(record) {
            if ((record.group.parent_name == this.name) &&
                    record.group.parent) {
                return record.group.parent.get_on_change_value(
                        [record.group.child_name]);
            }
            return Sao.field.Many2One._super.get_on_change_value.call(
                    this, record);
        }
    });

    Sao.field.One2One = Sao.class_(Sao.field.Many2One, {
    });

    Sao.field.One2Many = Sao.class_(Sao.field.Field, {
        init: function(description) {
            Sao.field.One2Many._super.init.call(this, description);
        },
        _default: null,
        _single_value: false,
        _set_value: function(record, value, default_, modified, data) {
            this._set_default_value(record);
            var group = record._values[this.name];
            if (jQuery.isEmptyObject(value)) {
                value = [];
            }
            var mode;
            if (jQuery.isEmptyObject(value) ||
                    !isNaN(parseInt(value[0], 10))) {
                mode = 'list ids';
            } else {
                mode = 'list values';
            }
            if ((mode == 'list values') || data) {
                var context = this.get_context(record);
                var value_fields = new Set();
                if (mode == 'list values') {
                    for (const v of value) {
                        for (const f of Object.keys(v)) {
                            value_fields.add(f);
                        }
                    }
                } else {
                    for (const d of data) {
                        for (const f in d) {
                            value_fields.add(f);
                        }
                    }
                }
                let field_names = new Set();
                for (const fieldname of value_fields) {
                    if (!(fieldname in group.model.fields) &&
                            (!~fieldname.indexOf('.')) &&
                            (!~fieldname.indexOf(':')) &&
                            (!fieldname.startsWith('_'))) {
                        field_names.add(fieldname);
                    }
                }
                var attr_fields = Object.values(this.description.views || {})
                    .map(v => v.fields)
                    .reduce((acc, elem) => {
                        for (const field in elem) {
                            acc[field] = elem[field];
                        }
                        return acc;
                    }, {});
                var fields = {};
                for (const n of field_names) {
                    if (n in attr_fields) {
                        fields[n] = attr_fields[n];
                    }
                }

                var to_fetch = Array.from(field_names).filter(k => !(k in attr_fields));
                if (to_fetch.length) {
                    var args = {
                        'method': 'model.' + this.description.relation +
                            '.fields_get',
                        'params': [to_fetch, context]
                    };
                    try {
                        var rpc_fields = Sao.rpc(
                            args, record.model.session, false);
                        for (const [key, value] of Object.entries(rpc_fields)) {
                            fields[key] = value;
                        }
                    } catch (e) {
                        return;
                    }
                }
                if (!jQuery.isEmptyObject(fields)) {
                    group.add_fields(fields);
                }
            }
            if (mode == 'list ids') {
                var records_to_remove = [];
                for (const old_record of group) {
                    if (!~value.indexOf(old_record.id)) {
                        records_to_remove.push(old_record);
                    }
                }
                for (const record_to_remove of records_to_remove) {
                    group.remove(record_to_remove, true, false, false);
                }
                var preloaded = {};
                for (const d of (data || [])) {
                    preloaded[d.id] = d;
                }
                group.load(value, modified || default_, -1, preloaded);
            } else {
                for (const vals of value) {
                    var new_record;
                    if ('id' in vals) {
                        new_record = group.get(vals.id);
                        if (!new_record) {
                            new_record = group.new_(false, vals.id);
                        }
                    } else {
                        new_record = group.new_(false);
                    }
                    if (default_) {
                        // Don't validate as parent will validate
                        new_record.set_default(vals, false, false);
                        group.add(new_record, -1, false);
                    } else {
                        new_record.set(vals, false);
                        group.push(new_record);
                    }
                }
                // Trigger modified only once
                group.record_modified();
            }
        },
        set: function(record, value, _default=false, data=null) {
            var group = record._values[this.name];
            var model;
            if (group !== undefined) {
                model = group.model;
                group.destroy();
            } else if (record.model.name == this.description.relation) {
                model = record.model;
            } else {
                model = new Sao.Model(this.description.relation);
            }
            record._values[this.name] = undefined;
            this._set_default_value(record, model);
            this._set_value(record, value, _default, undefined, data);
        },
        get: function(record) {
            var group = record._values[this.name];
            if (group === undefined) {
                return [];
            }
            var record_removed = group.record_removed;
            var record_deleted = group.record_deleted;
            var result = [];
            var parent_name = this.description.relation_field || '';
            var to_add = [];
            var to_create = [];
            var to_write = [];
            for (const record2 of group) {
                if (~record_removed.indexOf(record2) ||
                        ~record_deleted.indexOf(record2)) {
                    continue;
                }
                var values;
                if (record2.id >= 0) {
                    if (record2.modified) {
                        values = record2.get();
                        delete values[parent_name];
                        if (!jQuery.isEmptyObject(values)) {
                            to_write.push([record2.id]);
                            to_write.push(values);
                        }
                        to_add.push(record2.id);
                    }
                } else {
                    values = record2.get();
                    delete values[parent_name];
                    to_create.push(values);
                }
            }
            if (!jQuery.isEmptyObject(to_add)) {
                result.push(['add', to_add]);
            }
            if (!jQuery.isEmptyObject(to_create)) {
                result.push(['create', to_create]);
            }
            if (!jQuery.isEmptyObject(to_write)) {
                result.push(['write'].concat(to_write));
            }
            if (!jQuery.isEmptyObject(record_removed)) {
                result.push(['remove', record_removed.map(function(r) {
                    return r.id;
                })]);
            }
            if (!jQuery.isEmptyObject(record_deleted)) {
                result.push(['delete', record_deleted.map(function(r) {
                    return r.id;
                })]);
            }
            return result;
        },
        set_client: function(record, value, force_change) {
            // domain inversion try to set None as value
            if (value === null) {
                value = [];
            }
            // domain inversion could try to set id as value
            if (typeof value == 'number') {
                value = [value];
            }

            var previous_ids = this.get_eval(record);
            var modified = !Sao.common.compare(
                previous_ids.sort(), value.sort());
            this._set_value(record, value, false, modified);
            if (modified) {
                this.changed(record);
                record.validate(null, true, false, true);
                record.set_modified(this.name);
            } else if (force_change) {
                this.changed(record);
                record.validate(null, true, false, true);
                record.set_modified();
            }
        },
        get_client: function(record) {
            this._set_default_value(record);
            return record._values[this.name];
        },
        set_default: function(record, value) {
            record.modified_fields[this.name] = true;
            return this.set(record, value, true);
        },
        set_on_change: function(record, value) {
            var fields, new_fields;
            record.modified_fields[this.name] = true;
            this._set_default_value(record);
            if (value instanceof Array) {
                return this._set_value(record, value, false, true);
            }
            var new_field_names = {};
            if (value && (value.add || value.update)) {
                var context = this.get_context(record);
                fields = record._values[this.name].model.fields;
                var adding_values = [];
                if (value.add) {
                    for (const add of value.add) {
                        adding_values.push(add[1]);
                    }
                }
                for (const l of [adding_values, value.update]) {
                    if (!jQuery.isEmptyObject(l)) {
                        for (const v of l) {
                            for (const f of Object.keys(v)) {
                                if (!(f in fields) &&
                                    (f != 'id') &&
                                    (!~f.indexOf('.'))) {
                                        new_field_names[f] = true;
                                    }
                            }
                        }
                    }
                }
                if (!jQuery.isEmptyObject(new_field_names)) {
                    var args = {
                        'method': 'model.' + this.description.relation +
                            '.fields_get',
                        'params': [Object.keys(new_field_names), context]
                    };
                    try {
                        new_fields = Sao.rpc(args, record.model.session, false);
                    } catch (e) {
                        return;
                    }
                } else {
                    new_fields = {};
                }
            }

            var group = record._values[this.name];
            if (value && value.delete) {
                for (const record_id of value.delete) {
                    const record2 = group.get(record_id);
                    if (record2) {
                        group.remove(record2, false, false, false);
                    }
                }
            }
            if (value && value.remove) {
                for (const record_id of value.remove) {
                    const record2 = group.get(record_id);
                    if (record2) {
                        group.remove(record2, true, false, false);
                    }
                }
            }

            if (value && (value.add || value.update)) {
                // First set already added fields to prevent triggering a
                // second on_change call
                if (value.update) {
                    for (const vals of value.update) {
                        if (!vals.id) {
                            continue;
                        }
                        const record2 = group.get(vals.id);
                        if (record2) {
                            var vals_to_set = {};
                            for (var key in vals) {
                                if (!(key in new_field_names)) {
                                    vals_to_set[key] = vals[key];
                                }
                            }
                            record2.set_on_change(vals_to_set);
                        }
                    }
                }

                group.add_fields(new_fields);
                if (value.add) {
                    for (const vals of value.add) {
                        let new_record;
                        const index = vals[0];
                        const data = vals[1];
                        const id_ = data.id;
                        delete data.id;
                        if (id_) {
                            new_record = group.get(id_);
                        }
                        if (!new_record) {
                            new_record = group.new_(false, id_);
                        }
                        group.add(new_record, index, false);
                        new_record.set_on_change(data);
                    }
                }
                if (value.update) {
                    for (const vals of value.update) {
                        if (!vals.id) {
                            continue;
                        }
                        const record2 = group.get(vals.id);
                        if (record2) {
                            record2.set_on_change(vals);
                        }
                    }
                }
            }
        },
        _set_default_value: function(record, model) {
            if (record._values[this.name] !== undefined) {
                return;
            }
            if (!model) {
                model = new Sao.Model(this.description.relation);
            }
            if (record.model.name == this.description.relation) {
                model = record.model;
            }
            var context = record.expr_eval(this.description.context || {});
            var group = Sao.Group(model, context, []);
            group.set_parent(record);
            group.parent_name = this.description.relation_field;
            group.child_name = this.name;
            group.parent_datetime_field = this.description.datetime_field;
            record._values[this.name] = group;
        },
        get_timestamp: function(record) {
            var timestamps = {};
            var group = record._values[this.name] || [];
            var records = group.filter(function(record) {
                return record.modified;
            });
            for (const record of jQuery.extend(
                records, group.record_removed, group.record_deleted)) {
                jQuery.extend(timestamps, record.get_timestamp());
            }
            return timestamps;
        },
        get_eval: function(record) {
            var result = [];
            var group = record._values[this.name];
            if (group === undefined) return result;

            var record_removed = group.record_removed;
            var record_deleted = group.record_deleted;
            for (const record2 of group) {
                if (~record_removed.indexOf(record2) ||
                        ~record_deleted.indexOf(record2))
                    continue;
                result.push(record2.id);
            }
            return result;
        },
        get_on_change_value: function(record) {
            var result = [];
            var group = record._values[this.name];
            if (group === undefined) return result;
            for (const record2 of group) {
                if (!record2.deleted && !record2.removed)
                    result.push(record2.get_on_change_value(
                                [this.description.relation_field || '']));
            }
            return result;
        },
        get_removed_ids: function(record) {
            return record._values[this.name].record_removed.map(function(r) {
                return r.id;
            });
        },
        get_domain: function(record) {
            var domains = this.get_domains(record);
            var attr_domain = domains[1];
            // Forget screen_domain because it only means at least one record
            // and not all records
            return attr_domain;
        },
        validation_domains: function(record, pre_validate) {
            return this.get_domains(record, pre_validate)[0];
        },
        validate: function(record, softvalidation, pre_validate) {
            var invalid = false;
            var inversion = new Sao.common.DomainInversion();
            var ldomain = inversion.localize_domain(inversion.domain_inversion(
                        record.group.clean4inversion(pre_validate || []), this.name,
                        Sao.common.EvalEnvironment(record)), this.name);
            if (typeof ldomain == 'boolean') {
                if (ldomain) {
                    ldomain = [];
                } else {
                    ldomain = [['id', '=', null]];
                }
            }
            for (const record2 of (record._values[this.name] || [])) {
                if (!record2.get_loaded() && (record2.id >= 0) &&
                        !pre_validate) {
                    continue;
                }
                if (!record2.validate(null, softvalidation, ldomain, true)) {
                    invalid = 'children';
                }
            }
            var test = Sao.field.One2Many._super.validate.call(this, record,
                        softvalidation, pre_validate);
            if (test && invalid) {
                this.get_state_attrs(record).invalid = invalid;
                return false;
            }
            return test;
        },
        set_state: function(record, states) {
            this._set_default_value(record);
            Sao.field.One2Many._super.set_state.call(this, record, states);
        },
        _is_empty: function(record) {
            return jQuery.isEmptyObject(this.get_eval(record));
        }
    });

    Sao.field.Many2Many = Sao.class_(Sao.field.One2Many, {
        get_on_change_value: function(record) {
            return this.get_eval(record);
        }
    });

    Sao.field.Reference = Sao.class_(Sao.field.Field, {
        _default: null,
        get_client: function(record) {
            if (record._values[this.name]) {
                var model = record._values[this.name][0];
                var name = (
                    record._values[this.name + '.'] || {}).rec_name || '';
                return [model, name];
            } else {
                return null;
            }
        },
        get: function(record) {
            if (record._values[this.name] &&
                record._values[this.name][0] &&
                record._values[this.name][1] !== null &&
                record._values[this.name][1] >= -1) {
                return record._values[this.name].join(',');
            }
            return null;
        },
        set_client: function(record, value, force_change) {
            if (value) {
                if (typeof(value) == 'string') {
                    value = value.split(',');
                }
                var ref_model = value[0];
                var ref_id = value[1];
                var rec_name;
                if (ref_id instanceof Array) {
                    rec_name = ref_id[1];
                    ref_id = ref_id[0];
                } else {
                    if (ref_id && !isNaN(parseInt(ref_id, 10))) {
                        ref_id = parseInt(ref_id, 10);
                    }
                    if ([ref_model, ref_id].join(',') == this.get(record)) {
                        rec_name = (
                            record._values[this.name + '.'] || {}).rec_name || '';
                    } else {
                        rec_name = '';
                    }
                }
                Sao.setdefault(
                    record._values, this.name + '.', {}).rec_name = rec_name;
                value = [ref_model, ref_id];
            }
            Sao.field.Reference._super.set_client.call(
                    this, record, value, force_change);
        },
        set: function(record, value) {
            if (!value) {
                record._values[this.name] = this._default;
                return;
            }
            var ref_model, ref_id;
            if (typeof(value) == 'string') {
                ref_model = value.split(',')[0];
                ref_id = value.split(',')[1];
                if (!ref_id) {
                    ref_id = null;
                } else if (!isNaN(parseInt(ref_id, 10))) {
                    ref_id = parseInt(ref_id, 10);
                }
            } else {
                ref_model = value[0];
                ref_id = value[1];
            }
            var rec_name = (
                record._values[this.name + '.'] || {}).rec_name || '';
            if (ref_model && ref_id !== null && ref_id >= 0) {
                if (!rec_name && ref_id >= 0) {
                    rec_name = Sao.rpc({
                        'method': 'model.' + ref_model + '.read',
                        'params': [[ref_id], ['rec_name'], record.get_context()]
                    }, record.model.session, false)[0].rec_name;
                }
            } else if (ref_model) {
                rec_name = '';
            } else {
                rec_name = ref_id;
            }
            Sao.setdefault(
                record._values, this.name + '.', {}).rec_name = rec_name;
            record._values[this.name] = [ref_model, ref_id];
        },
        get_on_change_value: function(record) {
            if ((record.group.parent_name == this.name) &&
                    record.group.parent) {
                return [record.group.parent.model.name,
                    record.group.parent.get_on_change_value(
                        [record.group.child_name])];
            }
            return Sao.field.Reference._super.get_on_change_value.call(
                    this, record);
        },
        get_context: function(record, record_context, local) {
            var context = Sao.field.Reference._super.get_context.call(
                this, record, record_context, local);
            if (this.description.datetime_field) {
                context._datetime = record.get_eval()[
                    this.description.datetime_field];
            }
            return context;
        },
        validation_domains: function(record, pre_validate) {
            return this.get_domains(record, pre_validate)[0];
        },
        get_domains: function(record, pre_validate) {
            var model = null;
            if (record._values[this.name]) {
                model = record._values[this.name][0];
            }
            var domains = Sao.field.Reference._super.get_domains.call(
                this, record, pre_validate);
            domains[1] = domains[1][model] || [];
            return domains;
        },
        get_domain: function(record) {
            var model = null;
            if (record._values[this.name]) {
                model = record._values[this.name][0];
            }
            var domains = this.get_domains(record);
            var screen_domain = domains[0];
            var attr_domain = domains[1];
            var inversion = new Sao.common.DomainInversion();
            screen_domain = inversion.filter_leaf(
                screen_domain, this.name, model);
            screen_domain = inversion.prepare_reference_domain(
                screen_domain, this.name);
            return inversion.concat([
                inversion.localize_domain(screen_domain, this.name, true),
                attr_domain]);
        },
        get_search_order: function(record) {
            var order = Sao.field.Reference._super.get_search_order.call(
                this, record);
            if (order !== null) {
                var model = null;
                if (record._values[this.name]) {
                    model = record._values[this.name][0];
                }
                order = order[model] || null;
            }
            return order;
        },
        get_models: function(record) {
            var domains = this.get_domains(record);
            var inversion = new Sao.common.DomainInversion();
            var screen_domain = inversion.prepare_reference_domain(
                domains[0], this.name);
            return inversion.extract_reference_models(
                inversion.concat([screen_domain, domains[1]]),
                this.name);
        },
        _is_empty: function(record) {
            var result = Sao.field.Reference._super._is_empty.call(
                this, record);
            if (!result && record._values[this.name][1] < 0) {
                result = true;
            }
            return result;
        },
    });

    Sao.field.Binary = Sao.class_(Sao.field.Field, {
        _default: null,
        _has_changed: function(previous, value) {
            return previous != value;
        },
        get_size: function(record) {
            var data = record._values[this.name] || 0;
            if ((data instanceof Uint8Array) ||
                (typeof(data) == 'string')) {
                return data.length;
            }
            return data;
        },
        get_data: function(record) {
            var data = record._values[this.name];
            var prm = jQuery.when(data);
            if (!(data instanceof Uint8Array) &&
                (typeof(data) != 'string') &&
                (data !== null)) {
                if (record.id < 0) {
                    return prm;
                }
                var context = record.get_context();
                prm = record.model.execute('read', [[record.id], [this.name]],
                    context).then(data => {
                        data = data[0][this.name];
                        this.set(record, data);
                        return data;
                    });
            }
            return prm;
        }
    });

    Sao.field.Dict = Sao.class_(Sao.field.Field, {
        _default: {},
        _single_value: false,
        init: function(description) {
            Sao.field.Dict._super.init.call(this, description);
            this.schema_model = new Sao.Model(description.schema_model);
            this.keys = {};
        },
        set: function(record, value) {
            if (value) {
                // Order keys to allow comparison with stringify
                var keys = [];
                for (var key in value) {
                    keys.push(key);
                }
                keys.sort();
                var new_value = {};
                for (var index in keys) {
                    key = keys[index];
                    new_value[key] = value[key];
                }
                value = new_value;
            }
            Sao.field.Dict._super.set.call(this, record, value);
        },
        get: function(record) {
            return jQuery.extend(
                {}, Sao.field.Dict._super.get.call(this, record));
        },
        get_client: function(record) {
            return Sao.field.Dict._super.get_client.call(this, record);
        },
        validation_domains: function(record, pre_validate) {
            return this.get_domains(record, pre_validate)[0];
        },
        get_domain: function(record) {
            var inversion = new Sao.common.DomainInversion();
            var domains = this.get_domains(record);
            var screen_domain = domains[0];
            var attr_domain = domains[1];
            return inversion.concat([
                    inversion.localize_domain(screen_domain),
                    attr_domain]);
        },
        date_format: function(record) {
            var context = this.get_context(record);
            return Sao.common.date_format(context.date_format);
        },
        time_format: function(record) {
            return '%X';
        },
        add_keys: function(keys, record) {
            var context = this.get_context(record);
            var domain = this.get_domain(record);
            var batchlen = Math.min(10, Sao.config.limit);

            keys = jQuery.extend([], keys);
            const update_keys = values => {
                for (const k of values) {
                    this.keys[k.name] = k;
                }
            };

            var prms = [];
            while (keys.length > 0) {
                var sub_keys = keys.splice(0, batchlen);
                prms.push(this.schema_model.execute('search_get_keys',
                            [[['name', 'in', sub_keys], domain],
                                Sao.config.limit],
                            context)
                        .then(update_keys));
            }
            return jQuery.when.apply(jQuery, prms);
        },
        add_new_keys: function(ids, record) {
            var context = this.get_context(record);
            return this.schema_model.execute('get_keys', [ids], context)
                .then(new_fields => {
                    var names = [];
                    for (const new_field of new_fields) {
                        this.keys[new_field.name] = new_field;
                        names.push(new_field.name);
                    }
                    return names;
                });
        },
        validate: function(record, softvalidation, pre_validate) {
            var valid = Sao.field.Dict._super.validate.call(
                this, record, softvalidation, pre_validate);

            if (this.description.readonly) {
                return valid;
            }

            var decoder = new Sao.PYSON.Decoder();
            var field_value = this.get_eval(record);
            var domain = [];
            for (var key in field_value) {
                if (!(key in this.keys)) {
                    continue;
                }
                var key_domain = this.keys[key].domain;
                if (key_domain) {
                    domain.push(decoder.decode(key_domain));
                }
            }

            var inversion = new Sao.common.DomainInversion();
            var valid_value = inversion.eval_domain(domain, field_value);
            if (!valid_value) {
                this.get_state_attrs(record).invalid = 'domain';
            }

            return valid && valid_value;
        }
    });
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.Tab = Sao.class_(Object, {
        init: function(attributes) {
            Sao.Tab.tabs.push(this);
            this.attributes = jQuery.extend({}, attributes);
            this.buttons = {};
            this.menu_buttons = {};
            this.id = 'tab-' + Sao.Tab.counter++;
            this.name_el = jQuery('<span/>');
            this.name_short_el = jQuery('<span/>', {
                'class': 'hidden-xs hidden-sm hidden-md',
            }).appendTo(this.name_el);
            this.name_long_el = jQuery('<span/>', {
                'class': 'hidden-lg',
            }).appendTo(this.name_el);
            this.view_prm = jQuery.when();
        },
        menu_def: function() {
            return [
                {
                    id: 'switch_',
                    icon: 'tryton-switch',
                    label: Sao.i18n.gettext('Switch'),
                    tooltip: Sao.i18n.gettext('Switch view'),
                }, {
                    id: 'previous',
                    icon: 'tryton-back',
                    label: Sao.i18n.gettext('Previous'),
                    tooltip: Sao.i18n.gettext('Previous Record')
                }, {
                    id: 'next',
                    icon: 'tryton-forward',
                    label: Sao.i18n.gettext('Next'),
                    tooltip: Sao.i18n.gettext('Next Record'),
                }, {
                    id: 'search',
                    icon: 'tryton-search',
                    label: Sao.i18n.gettext('Search'),
                }, null, {
                    id: 'new_',
                    icon: 'tryton-create',
                    label: Sao.i18n.gettext('New'),
                    tooltip: Sao.i18n.gettext('Create a new record'),
                }, {
                    id: 'save',
                    icon: 'tryton-save',
                    label: Sao.i18n.gettext('Save'),
                    tooltip: Sao.i18n.gettext('Save this record'),
                }, {
                    id: 'reload',
                    icon: 'tryton-refresh',
                    label: Sao.i18n.gettext('Reload/Undo'),
                    tooltip: Sao.i18n.gettext('Reload'),
                }, {
                    id: 'copy',
                    icon: 'tryton-copy',
                    label: Sao.i18n.gettext('Duplicate'),
                }, {
                    id: 'delete_',
                    icon: 'tryton-delete',
                    label: Sao.i18n.gettext('Delete'),
                }, null, {
                    id: 'logs',
                    icon: 'tryton-log',
                    label: Sao.i18n.gettext('View Logs...'),
                }, {
                    id: (this.screen &&
                        Sao.common.MODELHISTORY.contains(this.screen.model_name)) ?
                        'revision': null,
                    icon: 'tryton-history',
                    label: Sao.i18n.gettext('Show revisions...'),
                }, null, {
                    id: 'attach',
                    icon: 'tryton-attach',
                    label: Sao.i18n.gettext('Attachment'),
                    tooltip: Sao.i18n.gettext('Add an attachment to the record'),
                    dropdown: true,
                }, {
                    id: 'note',
                    icon: 'tryton-note',
                    label: Sao.i18n.gettext('Note'),
                    tooltip: Sao.i18n.gettext('Add a note to the record'),
                }, {
                    id: 'action',
                    icon: 'tryton-launch',
                    label: Sao.i18n.gettext('Action'),
                }, null, {
                    id: 'relate',
                    icon: 'tryton-link',
                    label: Sao.i18n.gettext('Relate'),
                }, {
                    id: 'print',
                    icon: 'tryton-print',
                    label: Sao.i18n.gettext('Print'),
                }, {
                    id: 'email',
                    icon: 'tryton-email',
                    label: Sao.i18n.gettext('E-Mail...'),
                    tooltip: Sao.i18n.gettext('Send an e-mail using the record'),
                }, null, {
                    id: 'export',
                    icon: 'tryton-export',
                    label: Sao.i18n.gettext('Export'),
                }, {
                    id: 'import',
                    icon: 'tryton-import',
                    label: Sao.i18n.gettext('Import'),
                }, null, {
                    id: 'close',
                    icon: 'tryton-close',
                    label: Sao.i18n.gettext('Close Tab'),
                },
            ];
        },
        create_tabcontent: function() {
            this.el = jQuery('<div/>', {
                'class': 'panel panel-default ' + this.class_,
            });

            var toolbar = this.create_toolbar().appendTo(this.el);
            this.title = toolbar.find('.title');
            this.title_short = toolbar.find('.title-short');
            this.title_long = toolbar.find('.title-long');

            this.main = jQuery('<div/>', {
                'class': 'panel-body row',
            }).appendTo(this.el);
            this.content = jQuery('<div/>').appendTo(this.main);

            if (this.info_bar) {
                this.el.append(this.info_bar.el);
            }
        },
        set_menu: function(menu) {
            var previous;
            this.menu_def().forEach(item => {
                var menuitem;
                if (item) {
                    if (!this[item.id]) {
                        return;
                    }
                    menuitem = jQuery('<li/>', {
                        'role': 'presentation'
                    });
                    var link = jQuery('<a/>', {
                        'id': item.id,
                        'role': 'menuitem',
                        'href': '#',
                        'tabindex': -1
                    }).text(' ' + item.label).prepend(
                        Sao.common.ICONFACTORY.get_icon_img(item.icon, {
                            'aria-hidden': 'true',
                        })).appendTo(menuitem);
                    this.menu_buttons[item.id] = menuitem;
                    link.click(evt => {
                        evt.preventDefault();
                        if (!menuitem.hasClass('disabled')) {
                            this[item.id]();
                        }
                    });
                } else if (!item && previous) {
                    menuitem = jQuery('<li/>', {
                        'role': 'separator',
                        'class': 'divider hidden-xs',
                    });
                } else {
                    return;
                }
                previous = menuitem;
                menuitem.appendTo(menu);
            });
        },
        create_toolbar: function() {
            var toolbar = jQuery('<nav/>', {
                'class': 'toolbar panel-heading',
                'role': 'toolbar'
            }).append(jQuery('<div/>', {
                'class': 'container-fluid navbar-inverse'
            }).append(jQuery('<div/>', {
                'class': 'dropdown navbar-left flip'
            }).append(jQuery('<a/>', {
                'href': "#",
                'class': "navbar-brand dropdown-toggle",
                'data-toggle': 'dropdown',
                'role': 'button',
                'aria-expanded': false,
                'aria-haspopup': true
            }).append(jQuery('<span/>', {
                'class': 'title'
            }).append(jQuery('<span/>', {
                'class': 'title-long hidden-xs hidden-sm hidden-md',
            })).append(jQuery('<span/>', {
                'class': 'title-short hidden-lg',
            }))).append(jQuery('<span/>', {
                'class': 'caret'
            }))).append(jQuery('<ul/>', {
                'class': 'dropdown-menu',
                'role': 'menu'
            })).append(jQuery('<button/>', {
                'type': 'button',
                'class': 'close visible-xs',
                'aria-label': Sao.i18n.gettext("Close"),
                'title': Sao.i18n.gettext("Close"),
            }).append(jQuery('<span/>', {
                'aria-hidden': true
            }).append('&times;')).click(() => {
                this.close();
            }))).append(jQuery('<div/>', {
                'class': 'btn-toolbar navbar-right pull-right flip',
                'role': 'toolbar'
            })));
            this.set_menu(toolbar.find('ul[role*="menu"]'));

            var group;
            const add_button = item => {
                if (!item || !item.tooltip) {
                    group = null;
                    return;
                }
                if (!item.id || !this[item.id]) {
                    return;
                }
                if (!group) {
                    group = jQuery('<div/>', {
                        'class': 'btn-group',
                        'role': 'group'
                    }).appendTo(toolbar.find('.btn-toolbar'));
                }
                var attributes = {
                    'type': 'button',
                    'class': 'btn btn-default navbar-btn',
                    'title': item.label,
                    'id': item.id
                };
                if (item.dropdown) {
                    attributes['class'] += ' dropdown-toggle';
                    attributes['data-toggle'] = 'dropdown';
                    attributes['aria-expanded'] = false;
                    attributes['aria-haspopup'] = true;
                }
                var button = jQuery('<button/>', attributes)
                    .append(Sao.common.ICONFACTORY.get_icon_img(item.icon, {
                        'aria-hidden': 'true',
                    }));
                this.buttons[item.id] = button;
                if (item.dropdown) {
                    jQuery('<div/>', {
                        'class': 'btn-group dropdown',
                        'role': 'group',
                    }).append(button.append(jQuery('<span/>', {
                        'class': 'caret',
                    }))).append(jQuery('<ul/>', {
                        'class': 'dropdown-menu',
                        'role': 'menu',
                        'aria-labelledby': item.id,
                    })).appendTo(group);
                } else {
                    button.appendTo(group);
                }
                this.buttons[item.id].click(item, event => {
                    var item = event.data;
                    var button = this.buttons[item.id];
                    // Use data instead of disabled prop because the action may
                    // actually disable the button.
                    if (button.data('disabled')) {
                        event.preventDefault();
                        return;
                    }
                    button.data('disabled', true);
                    (this[item.id](this) || jQuery.when())
                        .always(function() {
                            button.data('disabled', false);
                        });
                });
            };
            this.menu_def().forEach(add_button);
            if (this.buttons.previous) {
                this.status_label = jQuery('<span/>', {
                    'class': 'badge',
                }).appendTo(jQuery('<div/>', {
                    'class': 'navbar-text hidden-xs',
                }).insertAfter(this.buttons.previous));
                this.buttons.previous.addClass('hidden-xs');
            }
            if (this.buttons.next) {
                this.buttons.next.addClass('hidden-xs');
            }
            toolbar.find('.btn-toolbar > .btn-group').slice(-2, -1)
                .addClass('hidden-xs')
                .find('.dropdown')
                .on('show.bs.dropdown', function() {
                    jQuery(this).parents('.btn-group')
                        .removeClass('hidden-xs');
                })
                .on('hide.bs.dropdown', function() {
                    jQuery(this).parents('.btn-group')
                        .addClass('hidden-xs');
                });
            toolbar.find('.btn-toolbar > .btn-group').last()
                .addClass('hidden-xs hidden-sm')
                .find('.dropdown')
                .on('show.bs.dropdown', function() {
                    jQuery(this).parents('.btn-group')
                        .removeClass('hidden-xs hidden-sm');
                })
                .on('hide.bs.dropdown', function() {
                    jQuery(this).parents('.btn-group')
                        .addClass('hidden-xs hidden-sm');
                });
            return toolbar;
        },
        show: function() {
            Sao.common.scrollIntoViewIfNeeded(
                jQuery('#tablist').find('a[href="#' + this.id + '"]')
                .tab('show'));
        },
        close: function() {
            var tabs = jQuery('#tabs');
            var tablist = jQuery('#tablist');
            var tab = tablist.find('#nav-' + this.id);
            var content = tabs.find('#' + this.id);
            this.show();
            return this._close_allowed().then(() => {
                var next = tab.nextAll('li').first();
                if (!next.length) {
                    next = tab.prevAll('li').first();
                }
                tab.remove();
                content.remove();
                var i = Sao.Tab.tabs.indexOf(this);
                if (i >= 0) {
                    Sao.Tab.tabs.splice(i, 1);
                }
                if (next.length) {
                    next.find('a').tab('show');
                } else {
                    Sao.set_url();
                }
                tabs.trigger('ready');
            });
        },
        _close_allowed: function() {
            return jQuery.when();
        },
        set_name: function(name) {
            this.name_short_el.text(name.split(' / ').pop());
            this.name_long_el.text(name);
            this.name_el.attr('title', name);
            this.title_short.text(this.name_short);
            this.title_long.text(this.name_long);
        },
        get name_short() {
            return this.name_short_el.text();
        },
        get name_long() {
            return this.name_long_el.text();
        },
        get_url: function() {
        },
        get current_view_type() {
            return 'form';
        },
        compare: function(attributes) {
            return false;
        },
    });

    Sao.Tab.counter = 0;
    Sao.Tab.tabs = [];
    Sao.Tab.tabs.close = function(warning) {
        if (warning && Sao.Tab.tabs.length) {
            return Sao.common.sur.run(
                    Sao.i18n.gettext(
                        'The following action requires to close all tabs.\n' +
                        'Do you want to continue?')).then(function() {
                return Sao.Tab.tabs.close(false);
            });
        }
        if (Sao.Tab.tabs.length) {
            var tab = Sao.Tab.tabs[0];
            return tab.close().then(function() {
                if (!~Sao.Tab.tabs.indexOf(tab)) {
                    return Sao.Tab.tabs.close();
                } else {
                    return jQuery.Deferred().reject();
                }
            });
        }
        if (Sao.main_menu_screen) {
            return Sao.main_menu_screen.save_tree_state().then(function() {
                Sao.main_menu_screen = null;
            });
        }
        return jQuery.when();
    };
    Sao.Tab.tabs.get_current = function() {
        return jQuery('#tablist').find('li.active').data('tab');
    };
    Sao.Tab.tabs.close_current = function() {
        var tab = this.get_current();
        tab.close();
    };

    Sao.Tab.create = function(attributes) {
        var tablist = jQuery('#tablist');
        if (attributes.context === undefined) {
            attributes.context = {};
        }
        for (const other of Sao.Tab.tabs) {
            if (other.compare(attributes)) {
                Sao.common.scrollIntoViewIfNeeded(
                    tablist.find('a[href="#' + other.id + '"]').tab('show'));
                return;
            }
        }
        var tab;
        if (attributes.model) {
            tab = new Sao.Tab.Form(attributes.model, attributes);
        } else {
            tab = new Sao.Tab.Board(attributes);
        }
        return tab.view_prm.then(function() {
            Sao.Tab.add(tab);
        });
    };

    Sao.Tab.add = function(tab) {
        var tabs = jQuery('#tabs');
        var tablist = jQuery('#tablist');
        var tabcontent = jQuery('#tabcontent');
        var tab_link = jQuery('<a/>', {
            'aria-controls': tab.id,
            'role': 'tab',
            'data-toggle': 'tab',
            'href': '#' + tab.id
        }).on('show.bs.tab', function() {
            Sao.set_url(tab.get_url(), tab.name_long.split(' / ').pop());
            Sao.Tab.set_view_type(tab.current_view_type);
        })
        .append(jQuery('<button/>', {
            'class': 'close',
            'aria-label': Sao.i18n.gettext("Close"),
            'title': Sao.i18n.gettext("Close"),
        }).append(jQuery('<span/>', {
            'aria-hidden': true
        }).append('&times;')).click(function(evt) {
            evt.preventDefault();
            tab.close();
        }))
        .append(tab.name_el);
        jQuery('<li/>', {
            'role': 'presentation',
            'data-placement': 'bottom',
            id: 'nav-' + tab.id
        }).append(tab_link)
        .appendTo(tablist)
        .data('tab', tab);
        jQuery('<div/>', {
            role: 'tabpanel',
            'class': 'tab-pane',
            id: tab.id
        }).append(tab.el)
        .appendTo(tabcontent);
        tab_link.on('hide.bs.tab', function(evt) {
            jQuery(evt.target).data('scrollTop', tabs.scrollTop());
        });
        tab_link.on('shown.bs.tab', function(evt) {
            tabs.scrollTop(jQuery(evt.target).data('scrollTop') || 0);
        });
        Sao.common.scrollIntoViewIfNeeded(tab_link.tab('show'));
        tabs.trigger('ready');
    };

    Sao.Tab.previous_tab = function() {
        Sao.Tab.move('prevAll');
    };

    Sao.Tab.next_tab = function() {
        Sao.Tab.move('nextAll');
    };

    Sao.Tab.move = function(direction) {
        var current_tab = this.tabs.get_current();
        var tabs = jQuery('#tabs');
        var tablist = jQuery('#tablist');
        var tab = tablist.find('#nav-' + current_tab.id);
        var next = tab[direction]('li').first();
        if (!next.length) {
            if (direction == 'prevAll') {
                next = tablist.find('li').last();
            } else {
                next = tablist.find('li').first();
            }
        }
        if (next) {
            next.find('a').tab('show');
            tabs.trigger('ready');
        }
    };

    Sao.Tab.set_view_type = function(type) {
        var tabcontent = jQuery('#tabcontent');
        tabcontent.attr('data-view-type', type);
    };

    Sao.Tab.Form = Sao.class_(Sao.Tab, {
        class_: 'tab-form',
        init: function(model_name, attributes) {
            Sao.Tab.Form._super.init.call(this, attributes);
            attributes = jQuery.extend({}, attributes);
            var name = attributes.name;
            if (!name) {
                name = Sao.common.MODELNAME.get(model_name);
            }
            if (attributes.res_id) {
                if (Object.prototype.hasOwnProperty.call(
                    attributes, 'tab_domain')) {
                    delete attributes.tab_domain;
                }
            }
            attributes.breadcrumb = [name];
            var screen = new Sao.Screen(model_name, attributes);
            screen.windows.push(this);
            this.screen = screen;
            this.info_bar = new Sao.Window.InfoBar();
            this.create_tabcontent();
            this.set_name(name);

            this.attachment_screen = null;

            screen.switch_callback = () => {
                if (this === Sao.Tab.tabs.get_current()) {
                    Sao.set_url(
                        this.get_url(), this.name_long.split(' / ').pop());
                }
            };

            this.view_prm = this.screen.switch_view().done(() => {
                this.set_buttons_sensitive();
                this.content.append(screen.screen_container.el);
                if (attributes.res_id) {
                    if (!jQuery.isArray(attributes.res_id)) {
                        attributes.res_id = [attributes.res_id];
                    }
                    screen.load(attributes.res_id);
                    if (attributes.res_id.length) {
                        screen.current_record = screen.group.get(
                            attributes.res_id[0]);
                    }
                    screen.display();
                } else {
                    if (screen.current_view.view_type == 'form') {
                        screen.new_();
                    }
                    if (~['tree', 'graph', 'calendar'].indexOf(
                            screen.current_view.view_type)) {
                        screen.search_filter();
                    }
                }
                this.update_revision();
            });
        },
        create_toolbar: function() {
            var toolbar = Sao.Tab.Form._super.create_toolbar.call(this);
            var screen = this.screen;
            var toolbars = screen.model.execute(
                'view_toolbar_get', [], screen.context, false);
            [
                ['action', 'tryton-launch',
                    Sao.i18n.gettext('Launch action')],
                ['relate', 'tryton-link',
                    Sao.i18n.gettext('Open related records')],
                ['print', 'tryton-print',
                    Sao.i18n.gettext('Print report')]
            ].forEach(menu_action => {
                var dropdown = jQuery('<div/>', {
                    'class': 'btn-group dropdown',
                    'role': 'group'
                })
                    .append(jQuery('<button/>', {
                        'type': 'button',
                        'class': 'btn btn-default navbar-btn dropdown-toggle',
                        'data-toggle': 'dropdown',
                        'aria-expanded': false,
                        'aria-haspopup': true,
                        'title': menu_action[2],
                        'id': menu_action[0],
                    })
                        .append(Sao.common.ICONFACTORY.get_icon_img(
                            menu_action[1], {
                                'aria-hidden': 'true',
                            }))
                        .append(jQuery('<span/>', {
                            'class': 'caret'
                        })))
                    .append(jQuery('<ul/>', {
                        'class': 'dropdown-menu',
                        'role': 'menu',
                        'aria-labelledby': menu_action[0]
                    }))
                    .insertBefore(toolbar.find('button#email'));
                var button = dropdown.find('button');
                this.buttons[menu_action[0]] = button;
                dropdown
                    .on('show.bs.dropdown', function() {
                        jQuery(this).parents('.btn-group')
                            .removeClass('hidden-xs hidden-sm');
                    }).on('hide.bs.dropdown', function() {
                        jQuery(this).parents('.btn-group')
                            .addClass('hidden-xs hidden-sm');
                    });
                var menu = dropdown.find('.dropdown-menu');
                button.click(function() {
                    menu.find([
                        '.' + menu_action[0] + '_button',
                        '.divider-button',
                        '.' + menu_action[0] + '_plugin',
                        '.divider-plugin'].join(',')).remove();
                    var buttons = screen.get_buttons().filter(
                        function(button) {
                            return menu_action[0] == (
                                button.attributes.keyword || 'action');
                        });
                    if (buttons.length) {
                        menu.append(jQuery('<li/>', {
                            'role': 'separator',
                            'class': 'divider divider-button',
                        }));
                    }
                    buttons.forEach(function(button) {
                        jQuery('<li/>', {
                            'role': 'presentation',
                            'class': menu_action[0] + '_button'
                        })
                            .append(
                                jQuery('<a/>', {
                                    'role': 'menuitem',
                                    'href': '#',
                                    'tabindex': -1
                                }).text(
                                    button.attributes.string || ''))
                            .click(function(evt) {
                                evt.preventDefault();
                                screen.button(button.attributes);
                            })
                            .appendTo(menu);
                    });

                    var kw_plugins = [];
                    for (const plugin of Sao.Plugins) {
                        for (const spec of plugin.get_plugins(
                            screen.model.name)) {
                            var name = spec[0],
                                func = spec[1],
                                keyword = spec[2] || 'action';
                            if (keyword == menu_action[0]) {
                                kw_plugins.push([name, func]);
                            }
                        }
                    }
                    if (kw_plugins.length) {
                        menu.append(jQuery('<li/>', {
                            'role': 'separator',
                            'class': 'divider divider-plugin',
                        }));
                    }
                    kw_plugins.forEach(function(plugin) {
                        var name = plugin[0],
                            func = plugin[1];
                        jQuery('<li/>', {
                            'role': 'presentation',
                            'class': menu_action[0] + '_plugin',
                        }).append(
                            jQuery('<a/>', {
                                'role': 'menuitem',
                                'href': '#',
                                'tabindex': -1,
                            }).text(name))
                            .click(function(evt) {
                                evt.preventDefault();
                                var ids = screen.current_view.selected_records
                                    .map(function(record) {
                                        return record.id;
                                    });
                                var id = screen.current_record ?
                                    screen.current_record.id : null;
                                var model_context = screen.context_screen ?
                                    screen.context_screen.model_name : null;
                                func({
                                    'model': screen.model.name,
                                    'model_context': model_context,
                                    'id': id,
                                    'ids': ids,
                                    'paths': screen.selected_paths,
                                });
                            })
                            .appendTo(menu);
                    });
                });

                toolbars[menu_action[0]].forEach(action => {
                    jQuery('<li/>', {
                        'role': 'presentation'
                    })
                        .append(jQuery('<a/>', {
                            'role': 'menuitem',
                            'href': '#',
                            'tabindex': -1
                        }).text(action.name))
                        .click(evt => {
                            evt.preventDefault();
                            this.modified_save().then(function() {
                                var exec_action = jQuery.extend({}, action);
                                var record_id = null;
                                if (screen.current_record) {
                                    record_id = screen.current_record.id;
                                }
                                var records, paths;
                                if (action.records == 'listed') {
                                    records = screen.listed_records;
                                    paths = screen.listed_paths;
                                } else {
                                    records = screen.selected_records;
                                    paths = screen.selected_paths;
                                }
                                var record_ids = records.map(function(record) {
                                    return record.id;
                                });
                                var model_context = screen.context_screen ?
                                    screen.context_screen.model_name : null;
                                var data = {
                                    'model': screen.model_name,
                                    'model_context': model_context,
                                    'id': record_id,
                                    'ids': record_ids,
                                    'paths': paths,
                                };
                                Sao.Action.execute(exec_action, data,
                                    jQuery.extend({}, screen.local_context));
                            });
                        })
                        .appendTo(menu);
                });

                if (menu_action[0] != 'action') {
                    button._can_be_sensitive = Boolean(
                        menu.children().length);
                }

                if ((menu_action[0] == 'print') &&
                    toolbars.exports.length) {
                    button._can_be_sensitive = true;
                    if (toolbars.print.length) {
                        menu.append(jQuery('<li/>', {
                            'role': 'separator',
                            'class': 'divider',
                        }));
                    }
                    toolbars.exports.forEach(export_ => {
                        jQuery('<li/>', {
                            'role': 'presentation',
                        })
                            .append(jQuery('<a/>', {
                                'role': 'menuitem',
                                'href': '#',
                                'tabindex': -1,
                            }).text(export_.name))
                            .click(evt => {
                                evt.preventDefault();
                                this.do_export(export_);
                            })
                            .appendTo(menu);
                    });
                }
            });
            this.buttons.attach
                .on('dragover', false)
                .on('drop', this.attach_drop.bind(this));

            // if (this.screen.model_name === 'purchase.dashboard') {
            //     toolbar.hide();
            // }
            return toolbar;
        },
        create_tabcontent: function() {
            Sao.Tab.Form._super.create_tabcontent.call(this);
            this.attachment_preview = jQuery('<div/>', {
                'class': 'attachment-preview',
            }).hide().appendTo(this.main);
        },
        compare: function(attributes) {
            if (!attributes) {
                return false;
            }
            var compare = Sao.common.compare;
            return (
                (this.screen.view_index === 0) &&
                (this.screen.model_name === attributes.model) &&
                (this.attributes.res_id === attributes.res_id) &&
                (compare(
                    this.attributes.domain || [], attributes.domain || [])) &&
                (compare(
                    this.attributes.view_ids || [],
                    attributes.view_ids || [])) &&
                (attributes.view_ids ||
                    (compare(
                        this.attributes.mode || ['tree', 'form'],
                        attributes.mode || ['tree', 'form']))) &&
                (JSON.stringify(this.screen.local_context) ===
                    JSON.stringify(attributes.context)) &&
                (compare(
                    this.attributes.search_value || [],
                    attributes.search_value || [])) &&
                (JSON.stringify(this.screen.attributes.tab_domain) ===
                    JSON.stringify(attributes.tab_domain))
            );
        },
        _close_allowed: function() {
            return this.modified_save().then(null, function(result) {
                if (result) {
                    return jQuery.Deferred().resolve();
                } else {
                    return jQuery.Deferred().reject();
                }
            });
        },
        modified_save: function() {
            this.screen.save_tree_state();
            this.screen.current_view.set_value();
            if (this.screen.modified()) {
                return Sao.common.sur_3b.run(
                        Sao.i18n.gettext('This record has been modified\n' +
                            'do you want to save it?'))
                    .then(result => {
                        switch(result) {
                            case 'ok':
                                return this.save();
                            case 'ko':
                                var record_id = this.screen.current_record.id;
                                return this.reload(false).then(() => {
                                    if (record_id < 0) {
                                        return jQuery.Deferred().reject(true);
                                    }
                                    else if (this.screen.current_record) {
                                        if (record_id !=
                                            this.screen.current_record.id) {
                                            return jQuery.Deferred().reject();
                                        }
                                    }
                                });
                            default:
                                return jQuery.Deferred().reject();
                        }
                    });
            }
            return jQuery.when();
        },
        new_: function() {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name).create) {
                return jQuery.when();
            }
            return this.modified_save().then(() => {
                return this.screen.new_().then(() => {
                    this.info_bar.clear();
                    this.set_buttons_sensitive();
                });
            });
        },
        save: function(tab) {
            if (tab) {
                // Called from button so we must save the tree state
                this.screen.save_tree_state();
            }
            var access = Sao.common.MODELACCESS.get(this.screen.model_name);
            if (!(access.write || access.create)) {
                return jQuery.Deferred().reject();
            }
            return this.screen.save_current().then(
                () => {
                    this.info_bar.add(
                        Sao.i18n.gettext('Record saved.'), 'info');
                    this.screen.count_tab_domain(true);
                }, () => {
                    this.info_bar.add(
                        this.screen.invalid_message(), 'danger');
                    return jQuery.Deferred().reject();
                });
        },
        switch_: function() {
            return this.modified_save().then(() => this.screen.switch_view());
        },
        reload: function(test_modified=true) {
            const reload = () => {
                return this.screen.cancel_current().then(() => {
                    var set_cursor = false;
                    var record_id = null;
                    if (this.screen.current_record) {
                        record_id = this.screen.current_record.id;
                    }
                    if (this.screen.current_view.view_type != 'form') {
                        return this.screen.search_filter(
                            this.screen.screen_container.search_entry.val())
                            .then(() => {
                                for (const record of this.screen.group) {
                                    if (record.id == record_id) {
                                        this.screen.current_record = record;
                                        set_cursor = true;
                                    }
                                }
                                return set_cursor;
                            });
                    }
                    return set_cursor;
                })
                .then(set_cursor => {
                    return this.screen.display(set_cursor).then(() => {
                        this.info_bar.clear();
                        this.set_buttons_sensitive();
                        this.screen.count_tab_domain();
                    });
                });
            };
            if (test_modified) {
                return this.modified_save().then(reload);
            } else {
                this.screen.save_tree_state(false);
                return reload();
            }
        },
        copy: function() {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name).create) {
                return jQuery.when();
            }
            return this.modified_save().then(() => {
                return this.screen.copy().then(() => {
                    this.info_bar.add(
                            Sao.i18n.gettext(
                                'Working now on the duplicated record(s).'),
                            'info');
                    this.screen.count_tab_domain(true);
                });
            });
        },
        delete_: function() {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name)['delete']) {
                return jQuery.when();
            }
            var msg;
            if (this.screen.current_view.view_type == 'form') {
                msg = Sao.i18n.gettext('Are you sure to remove this record?');
            } else {
                msg = Sao.i18n.gettext('Are you sure to remove those records?');
            }
            return Sao.common.sur.run(msg).then(() => {
                return this.screen.remove(true, false, true).then(
                    () => {
                        this.info_bar.add(
                            Sao.i18n.gettext("Records removed."),
                            'info');
                        this.screen.count_tab_domain(true);
                    }, () => {
                        this.info_bar.add(
                            Sao.i18n.gettext("Records not removed."),
                            'danger');
                    });
            });
        },
        previous: function() {
            return this.modified_save().then(() => {
                var prm = this.screen.display_previous();
                this.info_bar.clear();
                this.set_buttons_sensitive();
                return prm;
            });
        },
        next: function() {
            return this.modified_save().then(() => {
                var prm = this.screen.display_next();
                this.info_bar.clear();
                this.set_buttons_sensitive();
                return prm;
            });
        },
        search: function() {
            var search_entry = this.screen.screen_container.search_entry;
            search_entry.parents('.filter-box').toggleClass('hidden-xs');
            if (search_entry.is(':visible')) {
                window.setTimeout(function() {
                    search_entry.focus();
                }, 0);
            }
            return jQuery.when();
        },
        logs: function() {
            var record = this.screen.current_record;
            if ((!record) || (record.id < 0)) {
                this.info_bar.add(
                        Sao.i18n.gettext('You have to select one record.'),
                        'info');
                return jQuery.when();
            }
            new Sao.Window.Log(record);
        },
        revision: function() {
            var current_id = null;
            if (this.screen.current_record) {
                current_id = this.screen.current_record.id;
            }
            const set_revision = revisions => {
                return revision => {
                    if (revision) {
                        // Add a millisecond as microseconds are truncated
                        revision.add(1, 'milliseconds');
                    }
                    if ((this.screen.current_view.view_type == 'form') &&
                            revision &&
                            (revision < revisions[revisions.length - 1][0])) {
                        revision = revisions[revisions.length - 1][0];
                    }
                    if (revision != this.screen.context._datetime) {
                        this.screen.clear();
                        // Update group context that will be propagated by
                        // recreating new group
                        this.screen.group._context._datetime = revision;
                        if (this.screen.current_view.view_type != 'form') {
                            this.screen.search_filter(
                                    this.screen.screen_container
                                    .search_entry.val());
                        } else {
                            this.screen.group.load([current_id]);
                        }
                        this.screen.display(true);
                        this.update_revision();
                    }
                };
            };
            return this.modified_save().then(() => {
                var ids = this.screen.current_view.selected_records.map(
                    record => record.id);
                return this.screen.model.execute('history_revisions',
                    [ids], this.screen.context)
                    .then(revisions => {
                        const revision = this.screen.context._datetime;
                        if (revision) {
                            // Remove a millisecond as microseconds are truncated
                            revision.add(-1, 'milliseconds');
                        }
                        new Sao.Window.Revision(
                            revisions, revision, set_revision(revisions));
                    });
            });
        },
        update_revision: function() {
            var revision = this.screen.context._datetime;
            var label_short, label_long, title;
            if (revision) {
                var date_format = Sao.common.date_format(
                    this.screen.context.date_format);
                var time_format = '%H:%M:%S.%f';
                var revision_label = ' @ ' + Sao.common.format_datetime(
                    date_format + ' ' + time_format, revision);
                label_long = Sao.common.ellipsize(
                    this.name_long, 80 - revision_label.length) + revision_label;
                label_short = Sao.common.ellipsize(
                    this.name_short, 80 - revision_label.length) + revision_label;
                title = this.name_long + revision_label;
            } else {
                label_long = Sao.common.ellipsize(this.name_long, 80);
                label_short = Sao.common.ellipsize(this.name_short, 80);
                title = this.name_long;
            }
            this.title_short.text(label_short);
            this.title_long.text(label_long);
            this.title.attr('title', title);
            this.set_buttons_sensitive();
        },
        set_buttons_sensitive: function() {
            var revision = this.screen.context._datetime;
            if (!revision) {
                var access = Sao.common.MODELACCESS.get(this.screen.model_name);
                var modified = this.screen.modified();
                const accesses = new Map([
                    ['new_', access.create && !modified],
                    ['save',
                        (access.create || access.write) &&
                        modified && !this.screen.readonly],
                    ['delete_', access.delete],
                    ['copy', access.create],
                    ['import', access.create],
                ]);
                for (const [name, access] of accesses) {
                    if (this.buttons[name]) {
                        this.buttons[name].prop('disabled', !access);
                    }
                    if (this.menu_buttons[name]) {
                        this.menu_buttons[name]
                            .toggleClass('disabled', !access);
                    }
                }
            } else {
                for (const name of [
                    'new_', 'save', 'delete_', 'copy', 'import']) {
                    if (this.buttons[name]) {
                        this.buttons[name].prop('disabled', true);
                    }
                    if (this.menu_buttons[name]) {
                        this.menu_buttons[name].addClass('disabled');
                    }
                }
            }
        },
        attach: function(evt) {
            const window_ = () => {
                return new Sao.Window.Attachment(record, () => {
                    this.refresh_resources(true);
                });
            };
            const preview = () => {
                if (this.attachment_preview.children().length) {
                    this.attachment_preview.empty();
                    this.attachment_preview.hide();
                    this.attachment_screen = null;
                } else {
                    this.attachment_preview.append(
                        this._attachment_preview_el());
                    this.attachment_preview.show();
                    this.refresh_attachment_preview();
                }
            };
            var dropdown = this.buttons.attach.parents('.dropdown');
            if (!evt) {
                window.setTimeout(() => {
                    this.buttons.attach.click();
                });
                return;
            }
            var record = this.screen.current_record;
            var menu = dropdown.find('.dropdown-menu');
            menu.empty();
            Sao.Window.Attachment.get_attachments(record)
                .then(function(attachments) {
                    attachments.forEach(function(value) {
                        var name = value[0],
                            callback = value[1];
                        var link = jQuery('<a/>', {
                            'role': 'menuitem',
                            'href': '#',
                            'tabindex': -1,
                        }).text(name).appendTo(jQuery('<li/>', {
                            'role': 'presentation',
                        }).appendTo(menu));
                        if (typeof callback == 'string') {
                            link.attr('href', callback);
                            link.attr('target', '_new');
                        } else {
                            link.click(function(evt) {
                                evt.preventDefault();
                                callback();
                            });
                        }
                    });
                }).always(function() {
                    menu.append(jQuery('<li/>', {
                        'class': 'divider',
                    }));
                    menu.append(jQuery('<li/>', {
                        'role': 'presentation',
                        'class': 'input-file',
                    }).append(jQuery('<input/>', {
                        'type': 'file',
                        'role': 'menuitem',
                        'multiple': true,
                        'tabindex': -1,
                    }).change(function() {
                        var attachment = window_();
                        Sao.common.get_input_data(
                            jQuery(this), function(data, filename) {
                                attachment.add_data(data, filename);
                            });
                    })).append(jQuery('<a/>', {
                        'role': 'menuitem',
                        'href': '#',
                        'tabindex': -1,
                    }).text(Sao.i18n.gettext('Add...'))));
                    menu.append(jQuery('<li/>', {
                        'role': 'presentation',
                    }).append(jQuery('<a/>', {
                        'role': 'menuitem',
                        'href': '#',
                        'tabindex': -1,
                    }).text(Sao.i18n.gettext("Preview"))
                        .click(function(evt) {
                            evt.preventDefault();
                            preview();
                        })));
                    menu.append(jQuery('<li/>', {
                        'role': 'presentation',
                    }).append(jQuery('<a/>', {
                        'role': 'menuitem',
                        'href': '#',
                        'tabindex': -1,
                    }).text(Sao.i18n.gettext('Manage...'))
                        .click(function(evt) {
                            evt.preventDefault();
                            window_();
                        })));
                });
        },
        attach_drop: function(evt) {
            evt.preventDefault();
            evt.stopPropagation();
            evt = evt.originalEvent;
            var record = this.screen.current_record;
            if (!record || record.id < 0) {
                return;
            }

            var i, file;
            var files = [],
                uris = [],
                texts = [];
            if (evt.dataTransfer.items) {
                Sao.Logger.debug("Attach drop items:", evt.dataTransfer.items);
                for (i = 0; i < evt.dataTransfer.items.length; i++) {
                    var item = evt.dataTransfer.items[i];
                    if (item.kind == 'string') {
                        var list;
                        if (item.type == 'text/uri-list') {
                            list = uris;
                        } else if (item.type == 'text/plain') {
                            list = texts;
                        } else {
                            continue;
                        }
                        var prm = jQuery.Deferred();
                        evt.dataTransfer.items[i].getAsString(prm.resolve);
                        list.push(prm);
                        break;
                    } else {
                        file = evt.dataTransfer.items[i].getAsFile();
                        if (file) {
                            files.push(file);
                        }
                    }
                }
            } else {
                for (i = 0; i < evt.dataTransfer.files.length; i++) {
                    file = evt.dataTransfer.files[i];
                    if (file) {
                        files.push(file);
                    }
                }
            }

            var window_ = new Sao.Window.Attachment(record, () => {
                this.refresh_resources(true);
            });
            for (const file of files) {
                Sao.common.get_file_data(file, window_.add_data.bind(window_));
            }
            jQuery.when.apply(jQuery, uris).then(function() {
                function empty(value) {
                    return Boolean(value);
                }
                for (const argument of arguments) {
                    argument.split('\r\n')
                        .filter(empty)
                        .forEach(window_.add_uri, window_);
                }
            });
            jQuery.when.apply(jQuery, texts).then(function() {
                for (const argument of arguments) {
                    window_.add_text(argument);
                }
            });
            if (evt.dataTransfer.items) {
                evt.dataTransfer.items.clear();
            } else {
                evt.dataTransfer.clearData();
            }
        },
        _attachment_preview_el: function() {
            var el = jQuery('<div/>', {
                'class': 'text-center',
            });
            var buttons = jQuery('<div/>', {
                'class': 'btn-group',
            }).appendTo(el);

            var but_prev = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Previous"),
                'title': Sao.i18n.gettext("Previous"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-back')
            ).appendTo(buttons);

            var label = jQuery('<span/>', {
                'class': 'badge',
            }).text('(0/0)').appendTo(jQuery('<span/>', {
                'class': 'btn btn-sm btn-link',
            }).appendTo(buttons));

            var but_next = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Next"),
                'title': Sao.i18n.gettext("Next"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-forward')
            ).appendTo(buttons);

            var screen = new Sao.Screen('ir.attachment', {
                'readonly': true,
                'mode': ['form'],
                'context': {
                    'preview': true,
                },
            });
            this.attachment_screen = screen;

            but_prev.click(function() {
                return screen.display_previous();
            });
            but_next.click(function() {
                return screen.display_next();
            });

            var preview = {};
            preview.record_message = function(position, length) {
                var text = (position || '_') + '/' + length;
                label.text(text).attr('title', text);
                but_prev.prop('disabled', !screen.has_previous());
                but_next.prop('disabled', !screen.has_next());
            };
            screen.windows.push(preview);

            screen.switch_view().done(function() {
                el.append(screen.screen_container.el);
            });
            return el;
        },
        refresh_attachment_preview: function(force) {
            if (!this.attachment_screen) {
                return;
            }
            var record = this.screen.current_record;
            if (!record) {
                return;
            }
            var resource = record.model.name + ',' + record.id;
            var domain = [
                ['resource', '=', resource],
                ['type', '=', 'data'],
            ];
            if (!Sao.common.compare(this.attachment_screen.domain, domain) ||
                force) {
                this.attachment_screen.domain = domain;
                this.attachment_screen.search_filter().then(() => {
                    const group = this.attachment_screen.group;
                    if (group.length) {
                        this.attachment_screen.current_record = group[0];
                        this.attachment_screen.display();
                    }
                });
            }
        },
        note: function() {
            var record = this.screen.current_record;
            if (!record || (record.id < 0)) {
                return;
            }
            new Sao.Window.Note(record, () => {
                this.refresh_resources(true);
            });
        },
        email: function() {
            function is_report(action) {
                return action.type == 'ir.action.report';
            }
            if (!this.buttons.email.prop('disabled')) {
                this.modified_save().then(() => {
                    var record = this.screen.current_record;
                    if (!record || (record.id < 0)) {
                        return;
                    }
                    var title = this.name_short;
                    this.screen.model.execute(
                        'view_toolbar_get', [], this.screen.context)
                        .then(function(toolbars) {
                            var prints = toolbars.print.filter(is_report);
                            var emails = {};
                            for (const email of toolbars.emails) {
                                emails[email.name] = email.id;
                            }
                            record.rec_name().then(function(rec_name) {
                                function email(template) {
                                    new Sao.Window.Email(
                                        title + ': ' + rec_name, record,
                                        prints, template);
                                }
                                Sao.common.selection(
                                    Sao.i18n.gettext("Template"), emails, true)
                                    .then(email, email);
                            });
                        });
                });
            }
        },
        refresh_resources: function(reload) {
            var record = this.screen.current_record;
            if (record) {
                record.get_resources(reload).always(
                    this.update_resources.bind(this));
            } else {
                this.update_resources();
            }
            if (reload) {
                this.refresh_attachment_preview(true);
            }
        },
        update_resources: function(resources) {
            if (!resources) {
                resources = {};
            }
            var record_id = this.screen.get_id();
            var disabled = (
                record_id < 0 || record_id === null || record_id === undefined);

            const update = (name, title, text, color) => {
                var button = this.buttons[name];

                var badge = button.find('.badge');
                if (!badge.length) {
                    badge = jQuery('<span/>', {
                        'class': 'badge'
                    }).appendTo(button);
                }
                if (color) {
                    color = Sao.config.icon_colors[color];
                } else {
                    color = '';
                }
                badge.css('background-color', color);
                badge.css('color', '#fff');
                badge.text(text);
                button.attr('title', title);
                button.prop('disabled', disabled);
            };

            var count = resources.attachment_count || 0;
            var badge = count || '';
            if (count > 99) {
                badge = '99+';
            }
            var title= Sao.i18n.gettext("Attachment (%1)", count);
            update('attach', title, badge, 1);

            count = resources.note_count || 0;
            var unread = resources.note_unread || 0;
            badge = '';
            var color = unread > 0 ? 2 : 1;
            if (count) {
                if (count > 9) {
                    badge = '+';
                } else {
                    badge = count;
                }
                if (unread > 9) {
                    badge = '+/' + badge;
                } else {
                    badge = unread + '/' + badge;
                }
            }
            title = Sao.i18n.gettext("Note (%1/%2)", unread, count);
            update('note', title, badge, color);
        },
        record_message: function(position, size, max_size, record_id) {
            const set_sensitive = (button_id, sensitive) => {
                if (this.buttons[button_id]) {
                    this.buttons[button_id].prop('disabled', !sensitive);
                }
                if (this.menu_buttons[button_id]) {
                    this.menu_buttons[button_id].toggleClass('disabled', !sensitive);
                }
            };

            var name = "_";
            if (position) {
                var selected = this.screen.selected_records.length;
                name = '' + position;
                if (selected > 1) {
                    name += '#' + selected;
                }
            }
            const view_type = this.screen.current_view.view_type;
            const has_views = this.screen.number_of_views > 1;
            var buttons = ['print', 'relate', 'email', 'attach'];
            for (const button_id of buttons) {
                const button = this.buttons[button_id];
                let can_be_sensitive = button._can_be_sensitive;
                if (can_be_sensitive === undefined) {
                    can_be_sensitive = true;
                }
                if ((button_id == 'print') ||
                    (button_id == 'relate') ||
                    (button_id == 'email')) {
                    can_be_sensitive |= this.screen.get_buttons().some(
                        function(button) {
                            var keyword = button.attributes.keyword || 'action';
                            return keyword == button_id;
                        });
                }
                set_sensitive(button_id, position && can_be_sensitive);
            }
            set_sensitive(
                'switch_', (position || (view_type == 'form')) && has_views);
            set_sensitive('delete_', this.screen.deletable);
            set_sensitive('previous', this.screen.has_previous());
            set_sensitive('next', this.screen.has_next());

            var msg;
            if (size < max_size) {
                msg = (
                    name + '@' +
                    Sao.common.humanize(size) + '/' +
                    Sao.common.humanize(max_size));
                if (max_size >= this.screen.count_limit) {
                    msg += '+';
                }
            } else {
                msg = name + '/' + Sao.common.humanize(size);
            }
            this.status_label.text(msg).attr('title', msg);
            this.info_bar.clear();
            this.set_buttons_sensitive();
            this.refresh_attachment_preview();
        },
        record_modified: function() {
            this.set_buttons_sensitive();
            this.info_bar.refresh();
        },
        record_saved: function() {
            this.set_buttons_sensitive();
            this.refresh_resources();
        },
        action: function() {
            window.setTimeout(() => {
                this.buttons.action.click();
            });
        },
        relate: function() {
            window.setTimeout(() => {
                this.buttons.relate.click();
            });
        },
        print: function() {
            window.setTimeout(() => {
                this.buttons.print.click();
            });
        },
        export: function(){
            this.modified_save().then(() => {
                new Sao.Window.Export(
                    this.name_short, this.screen,
                    this.screen.current_view.get_fields());
            });
        },
        do_export: function(export_) {
            this.modified_save().then(() => {
                var ids, paths;
                if (export_.records == 'listed') {
                    ids = this.screen.listed_records.map(r => r.id);
                    paths = this.screen.listed_paths;
                } else {
                    ids = this.screen.selected_records.map(r => r.id);
                    paths = this.screen.selected_paths;
                }
                var fields = export_['export_fields.'].map(
                    field => field.name);
                this.screen.model.execute(
                    'export_data', [ids, fields, export_.header],
                    this.screen.context)
                    .then(function(data) {
                        var unparse_obj = {
                            'data': data,
                        };
                        unparse_obj.data = data.map((row, i) => {
                            var indent = (
                                paths && paths[i] ? paths[i].length -1 : 0);
                            return Sao.Window.Export.format_row(row, indent);
                        });
                        var delimiter = ',';
                        if (navigator.platform &&
                            navigator.platform.slice(0, 3) == 'Win') {
                            delimiter = ';';
                        }
                        var csv = Papa.unparse(unparse_obj, {
                            quoteChar: '"',
                            delimiter: delimiter,
                        });
                        if (navigator.platform &&
                            navigator.platform.slice(0, 3) == 'Win') {
                            csv = Sao.BOM_UTF8 + csv;
                        }
                        Sao.common.download_file(
                            csv, export_.name + '.csv',
                            {'type': 'text/csv;charset=utf-8'});
                    });
            });
        },
        import: function(){
            if (!Sao.common.MODELACCESS.get(this.screen.model_name).create) {
                return;
            }
            new Sao.Window.Import(this.name_short, this.screen);
        },
        get_url: function() {
            return this.screen.get_url(this.name);
        },
        get current_view_type() {
            return this.screen.current_view.view_type;
        },
    });

    Sao.Tab.Board = Sao.class_(Sao.Tab, {
        class_: 'tab-board',
        init: function(attributes) {
            var UIView;
            Sao.Tab.Board._super.init.call(this, attributes);
            this.model = attributes.model;
            this.view_id = (attributes.view_ids.length > 0 ?
                    attributes.view_ids[0] : null);
            this.context = attributes.context;
            var name = attributes.name;
            if (!name) {
                name = Sao.common.MODELNAME.get(this.model);
            }
            this.dialogs = [];
            this.board = null;
            UIView = new Sao.Model('ir.ui.view');
            this.view_prm = UIView.execute(
                'view_get', [this.view_id], this.context);
            this.view_prm.done(view => {
                view = jQuery(jQuery.parseXML(view.arch));
                this.board = new Sao.View.Board(view, this.context);
                this.board.actions_prms.done(() => {
                    var i, len, action;
                    for (i = 0, len = this.board.actions.length; i < len; i++) {
                        action = this.board.actions[i];
                        action.screen.windows.push(this);
                    }
                    this.board.reload();
                });
                this.content.append(this.board.el);
            });
            this.create_tabcontent();
            this.set_name(name);
        },
        compare: function(attributes) {
            if (!attributes) {
                return false;
            }
            var compare = Sao.common.compare;
            return ((this.model === attributes.model) &&
                (compare(
                    this.attributes.view_ids || [], attributes.view_ids || [])) &&
                (JSON.stringify(this.context) === JSON.stringify(attributes.context))
            );
        },
        reload: function() {
            this.board.reload();
        },
        record_message: function() {
            for (const action of this.board.actions) {
                action.update_domain(this.board.actions);
            }
        },
        refresh_resources: function() {
        },
        update_resources: function() {
        },
    });

    Sao.Tab.Wizard = Sao.class_(Sao.Tab, {
        class_: 'tab-wizard',
        init: function(wizard) {
            Sao.Tab.Wizard._super.init.call(this);
            this.wizard = wizard;
            wizard.tab = this;
            this.create_tabcontent();
            this.set_name(wizard.name);
            this.content.append(wizard.form);
        },
        create_toolbar: function() {
            return jQuery('<span/>');
        },
        _close_allowed: function() {
            var wizard = this.wizard;
            if ((wizard.state !== wizard.end_state) &&
                (wizard.end_state in wizard.states)) {
                wizard.response(
                    wizard.states[wizard.end_state].attributes);
            }
            var dfd = jQuery.Deferred();
            if (wizard.state === wizard.end_state) {
                dfd.resolve();
            } else {
                dfd.reject();
            }
            return dfd.promise();
        }
    });
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.ScreenContainer = Sao.class_(Object, {
        init: function(tab_domain) {
            this.alternate_viewport = jQuery('<div/>', {
                'class': 'screen-container'
            });
            this.alternate_view = false;
            this.search_modal = null;
            this.search_form = null;
            this.last_search_text = '';
            this.tab_domain = tab_domain || [];
            this.tab_counter = [];
            this.el = jQuery('<div/>', {
                'class': 'screen-container'
            });
            this.filter_box = jQuery('<form/>', {
                'class': 'filter-box hidden-xs'
            }).submit(e => {
                e.preventDefault();
                this.do_search();
            });
            var search_row = jQuery('<div/>', {
                'class': 'row'
            }).appendTo(this.filter_box);
            this.el.append(this.filter_box);
            this.ag_grid_filter_toggle_bar = jQuery('<div/>', {
                'class': 'ag-grid-screen-filter-toggle-bar',
            }).hide();
            this.ag_grid_filter_toggle = jQuery('<button/>', {
                'type': 'button',
                'class': 'btn btn-default btn-sm ag-grid-screen-filter-toggle',
                'aria-expanded': 'true',
                'title': Sao.i18n.gettext('Hide filters'),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-unfold-less', {
                'aria-hidden': true,
            })).append(jQuery('<span/>', {
                'class': 'ag-grid-screen-filter-toggle-label',
                'text': ' ' + Sao.i18n.gettext('Hide filters'),
            })).appendTo(this.ag_grid_filter_toggle_bar);
            this.ag_grid_filter_toggle.click(
                this.toggle_ag_grid_filters.bind(this));
            this.el.append(this.ag_grid_filter_toggle_bar);
            this.filter_button = jQuery('<button/>', {
                type: 'button',
                'class': 'btn btn-link',
                'title': Sao.i18n.gettext("Filters"),
            }).text(Sao.i18n.gettext('Filters'));
            this.filter_button.click(this.search_box.bind(this));
            this.search_entry = jQuery('<input/>', {
                'class': 'form-control mousetrap',
                'placeholder': Sao.i18n.gettext('Search'),
                // workaround for
                // https://bugzilla.mozilla.org/show_bug.cgi?id=1474137
                'autocomplete': 'off',
            });
            this.search_list = jQuery('<datalist/>');
            this.search_list.uniqueId();
            this.search_entry.attr('list', this.search_list.attr('id'));
            this.search_entry.on('input', this.update.bind(this));

            var but_clear = jQuery('<button/>', {
                'type': 'button',
                'class': 'btn btn-default hidden-md hidden-lg',
                'aria-label': Sao.i18n.gettext("Clear Search"),
                'title': Sao.i18n.gettext("Clear Search"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-clear'));
            but_clear.hide();
            but_clear.click(() => {
                this.search_entry.val('').change();
                this.do_search();
            });

            this.search_entry.on('keyup change', () => {
                if (this.search_entry.val()) {
                    but_clear.show();
                } else {
                    but_clear.hide();
                }
                this.bookmark_match();
            });

            var but_submit = jQuery('<button/>', {
                'type': 'submit',
                'class': 'btn btn-default',
                'aria-label': Sao.i18n.gettext("Search"),
                'title': Sao.i18n.gettext("Search"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-search'));

            this.but_active = jQuery('<button/>', {
                type: 'button',
                'class': 'btn btn-default hidden-xs',
                'aria-expanded': false,
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-archive', {
                'aria-hidden': true,
            }));
            this._set_active_tooltip();
            this.but_active.click(this.search_active.bind(this));

            this.but_bookmark = jQuery('<button/>', {
                type: 'button',
                'class': 'btn btn-default dropdown-toggle',
                'data-toggle': 'dropdown',
                'aria-expanded': false,
                'aria-label': Sao.i18n.gettext("Bookmarks"),
                'title': Sao.i18n.gettext("Bookmarks"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-bookmark', {
                'aria-hidden': true,
            })).uniqueId();
            var dropdown_bookmark = jQuery('<ul/>', {
                'class': 'dropdown-menu dropdown-menu-right',
                'role': 'menu',
                'aria-labelledby': this.but_bookmark.attr('id'),
            });
            this.but_bookmark.click(() => {
                dropdown_bookmark.empty();
                var bookmarks = this.bookmarks();
                for (const bookmark of bookmarks) {
                    const name = bookmark[1];
                    const domain = bookmark[2];
                    jQuery('<li/>', {
                        'role': 'presentation'
                    })
                    .append(jQuery('<a/>', {
                        'role': 'menuitem',
                        'href': '#',
                        'tabindex': -1
                    }).text(name)
                        .click(domain, this.bookmark_activate.bind(this)))
                    .appendTo(dropdown_bookmark);
                }
            });
            this.but_star = jQuery('<button/>', {
                'class': 'btn btn-default hidden-xs',
                'type': 'button'
            }).append(jQuery('<img/>', {
                'class': 'icon',
                'aria-hidden': true
            }).data('star', false)).click(this.star_click.bind(this));
            this.set_star();

            jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            })
            .append(jQuery('<span/>', {
                'class': 'input-group-btn'
            }).append(this.filter_button))
            .append(this.search_entry)
            .append(this.search_list)
            .append(jQuery('<span/>', {
                'class': 'input-group-btn'
            }).append(but_clear)
                .append(but_submit)
                .append(this.but_star)
                .append(this.but_bookmark)
                .append(dropdown_bookmark)
                .append(this.but_active))
            .appendTo(jQuery('<div/>', {
                'class': 'col-sm-10 col-xs-12'
            }).appendTo(search_row));


            this.but_prev = jQuery('<button/>', {
                type: 'button',
                'class': 'btn btn-default btn-sm',
                'aria-label': Sao.i18n.gettext("Previous"),
                'title': Sao.i18n.gettext("Previous"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-back', {
                'aria-hidden': true,
            }));
            this.but_prev.click(this.search_prev.bind(this));
            this.but_next = jQuery('<button/>', {
                type: 'button',
                'class': 'btn btn-default btn-sm',
                'aria-label': Sao.i18n.gettext("Next"),
                'title': Sao.i18n.gettext("Next"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-forward', {
                'aria-hidden': true,
            }));
            this.but_next.click(this.search_next.bind(this));

            jQuery('<div/>', {
                'class': 'btn-group',
                role: 'group',
            })
            .append(this.but_prev)
            .append(this.but_next)
            .appendTo(jQuery('<div/>', {
                'class': 'col-sm-2 pull-right'
            }).appendTo(search_row));

            this.content_box = jQuery('<div/>', {
                'class': 'content-box'
            });

            if (!jQuery.isEmptyObject(this.tab_domain)) {
                this.tab = jQuery('<div/>', {
                    'class': 'tab-domain'
                }).appendTo(this.el);
                var nav = jQuery('<ul/>', {
                    'class': 'nav nav-tabs',
                    role: 'tablist'
                }).appendTo(this.tab);
                jQuery('<div/>', {
                    'class': 'tab-content'
                }).appendTo(this.tab);
                this.tab_domain.forEach((tab_domain, i) => {
                    var name = tab_domain[0];
                    var counter = jQuery('<span/>', {
                        'class': 'badge badge-empty'
                    }).html('&nbsp;');
                    counter.css('visibility', 'hidden');
                    jQuery('<li/>', {
                        role: 'presentation',
                        id: 'nav-' + i
                    }).append(jQuery('<a/>', {
                        'aria-controls':  i,
                        role: 'tab',
                        'data-toggle': 'tab',
                        'href': '#' + i
                    }).text(name + ' ').append(counter)).appendTo(nav);
                    this.tab_counter.push(counter);
                });
                nav.find('a:first').tab('show');
                var self = this;
                nav.find('a').click(function(e) {
                    e.preventDefault();
                    jQuery(this).tab('show');
                    self.do_search();
                    self.screen.count_tab_domain(true);
                });
            } else {
                this.tab = null;
            }
            this.el.append(this.content_box);
        },
        set_text: function(value) {
            this.search_entry.val(value);
            this.bookmark_match();
        },
        update: function() {
            var completions = this.screen.domain_parser.completion(
                    this.get_text());
            this.search_list.empty();
            for (const e of completions) {
                jQuery('<option/>', {
                    'value': e.trim()
                }).appendTo(this.search_list);
            }
        },
        set_star: function(star) {
            var img = this.but_star.children('img');
            var title, icon;
            if (star) {
                icon = 'tryton-star';
                title = Sao.i18n.gettext("Remove this bookmark");
            } else {
                icon = 'tryton-star-border';
                title = Sao.i18n.gettext('Bookmark this filter');
            }
            this.but_star.data('star', Boolean(star));
            this.but_star.attr('title', title);
            this.but_star.attr('aria-label', title);
            Sao.common.ICONFACTORY.get_icon_url(icon).then(function(url) {
                img.attr('src', url);
            });
        },
        get_star: function() {
            return this.but_star.data('star');
        },
        star_click: function() {
            var star = this.get_star();
            var model_name = this.screen.model_name;
            const refresh = () => {
                this.bookmark_match();
                this.but_bookmark.prop('disabled',
                        jQuery.isEmptyObject(this.bookmarks()));
            };
            if (!star) {
                var text = this.get_text();
                if (!text) {
                    return;
                }
                Sao.common.ask.run(Sao.i18n.gettext('Bookmark Name:'), 'bookmark')
                    .then(name => {
                        if (!name) {
                            return;
                        }
                        var domain = this.screen.domain_parser.parse(text);
                        Sao.common.VIEW_SEARCH.add(model_name, name, domain)
                        .then(function() {
                            refresh();
                        });
                        this.set_text(
                            this.screen.domain_parser.string(domain));
                    });
            } else {
                var id = this.bookmark_match();
                Sao.common.VIEW_SEARCH.remove(model_name, id).then(function() {
                    refresh();
                });
            }
        },
        bookmarks: function() {
            var searches = Sao.common.VIEW_SEARCH.get(this.screen.model_name);
            return searches.filter(
                search => this.screen.domain_parser.stringable(search[2]));
        },
        bookmark_activate: function(e) {
            e.preventDefault();
            var domain = e.data;
            this.set_text(this.screen.domain_parser.string(domain));
            this.do_search();
        },
        bookmark_match: function() {
            var current_text = this.get_text();
            if (current_text) {
                var current_domain = this.screen.domain_parser.parse(
                        current_text);
                var bookmarks = this.bookmarks();
                for (const bookmark of bookmarks) {
                    const id = bookmark[0];
                    const domain = bookmark[2];
                    const access = bookmark[3];
                    const text = this.screen.domain_parser.string(domain);
                    if ((text === current_text) ||
                            (Sao.common.compare(domain, current_domain))) {
                        this.set_star(true);
                        this.but_star.prop('disabled', !access);
                        return id;
                    }
                }
                this.but_star.prop('disabled', !current_text);
            }
            this.set_star(false);
        },
        search_prev: function() {
            this.screen.search_prev(this.get_text());
        },
        search_next: function() {
            this.screen.search_next(this.get_text());
        },
        search_active: function() {
            this.but_active.toggleClass('active');
            this._set_active_tooltip();
            this.screen.search_filter(this.get_text());
        },
        _set_active_tooltip: function() {
            var tooltip;
            if (this.but_active.hasClass('active')) {
                tooltip = Sao.i18n.gettext('Show active records');
            } else {
                tooltip = Sao.i18n.gettext('Show inactive records');
            }
            this.but_active.attr('aria-label', tooltip);
            this.but_active.attr('title', tooltip);
        },
        get_tab_index: function() {
            if (!this.tab) {
                return -1;
            }
            return this.tab.find('li').index(this.tab.find('li.active'));
        },
        get_tab_domain: function() {
            if (!this.tab) {
                return [];
            }
            var idx = this.get_tab_index();
            if (idx < 0) {
                return [];
            }
            return this.tab_domain[idx][1];
        },
        set_tab_counter: function(count, idx=null) {
            if (jQuery.isEmptyObject(this.tab_counter) || !this.tab) {
                return;
            }
            if (idx === null) {
                idx = this.tab.find('li').index(this.tab.find('li.active'));
            }
            if (idx < 0) {
                return;
            }
            var counter = this.tab_counter[idx];
            if (count === null) {
                counter.attr('title', '');
                counter.html('&nbsp;');
                counter.css('visibility', 'hidden');
            } else {
                var title = Sao.common.humanize(count);
                if (count >= 1000) {
                    title += '+';
                }
                counter.attr('title', title);
                var text = count;
                if (count > 99) {
                    text = '99+';
                }
                counter.text(text);
                counter.css('visibility', 'visible');
            }
        },
        do_search: function() {
            return this.screen.search_filter(this.get_text());
        },
        set_screen: function(screen) {
            this.screen = screen;
            this.but_bookmark.prop('disabled',
                    jQuery.isEmptyObject(this.bookmarks()));
            this.bookmark_match();
        },
        show_filter: function() {
            this.filter_box.show();
            if (this.tab) {
                this.tab.show();
            }
        },
        hide_filter: function() {
            this.filter_box.hide();
            if (this.tab) {
                this.tab.hide();
            }
        },
        toggle_ag_grid_filters: function() {
            this.set_ag_grid_filters_collapsed(
                !this.el.hasClass('ag-grid-screen-filters-collapsed'));
        },
        set_ag_grid_filters_collapsed: function(collapsed) {
            this.el.toggleClass('ag-grid-screen-filters-collapsed', collapsed);
            if (collapsed) {
                this.filter_box.hide();
            } else {
                this.filter_box.show();
            }
            if (this.ag_grid_filter_toggle) {
                this.ag_grid_filter_toggle
                    .attr('aria-expanded', String(!collapsed))
                    .attr('title', Sao.i18n.gettext(
                        collapsed ? 'Show filters' : 'Hide filters'));
                this.ag_grid_filter_toggle
                    .find('img.icon')
                    .replaceWith(Sao.common.ICONFACTORY.get_icon_img(
                        collapsed ? 'tryton-unfold-more' :
                            'tryton-unfold-less', {
                            'aria-hidden': true,
                        }));
                this.ag_grid_filter_toggle
                    .find('.ag-grid-screen-filter-toggle-label')
                    .text(' ' + Sao.i18n.gettext(
                        collapsed ? 'Show filters' : 'Hide filters'));
            }
            window.setTimeout(() => {
                window.dispatchEvent(new Event('resize'));
            }, 0);
        },
        set_ag_grid_filter_toggle_visible: function(visible) {
            if (!this.ag_grid_filter_toggle_bar) {
                return;
            }
            this.el.toggleClass('ag-grid-screen-with-filter-toggle', visible);
            if (visible) {
                this.ag_grid_filter_toggle_bar.show();
                this.set_ag_grid_filters_collapsed(
                    this.el.hasClass('ag-grid-screen-filters-collapsed'));
            } else {
                this.ag_grid_filter_toggle_bar.hide();
                this.el.removeClass('ag-grid-screen-filters-collapsed');
            }
        },
        set: function(widget) {
            if (this.alternate_view) {
                this.alternate_viewport.children().detach();
                this.alternate_viewport.append(widget);
            } else {
                var is_ag_grid = Boolean(widget && widget.hasClass &&
                    widget.hasClass('ag-grid-tree-container'));
                this.content_box.children().detach();
                this.content_box.toggleClass(
                    'ag-grid-content-box',
                    is_ag_grid);
                if (!is_ag_grid) {
                    this.content_box.css({
                        height: '',
                        minHeight: '',
                        flexBasis: '',
                        flexShrink: '',
                        width: '',
                        maxWidth: '',
                    });
                }
                this.content_box.append(widget);
                if (is_ag_grid) {
                    var toolbar = widget.find('.ag-grid-tree-toolbar').first();
                    if (toolbar.length) {
                        toolbar.append(this.ag_grid_filter_toggle_bar);
                    }
                } else {
                    this.el.append(this.ag_grid_filter_toggle_bar);
                }
                this.set_ag_grid_filter_toggle_visible(is_ag_grid);
            }
        },
        get_text: function() {
            return this.search_entry.val();
        },
        search_box: function() {
            var domain_parser = this.screen.domain_parser;
            const search = () => {
                this.search_modal.modal('hide');
                var text = '';
                var quote = domain_parser.quote.bind(domain_parser);
                for (const field of this.search_form.fields) {
                    const label = field[0];
                    const entry = field[1];
                    let value;
                    if ((entry instanceof Sao.ScreenContainer.Between) ||
                        (entry instanceof Sao.ScreenContainer.Selection)) {
                        value = entry.get_value(quote);
                    } else {
                        value = quote(entry.val());
                    }
                    if (value) {
                        text += quote(label) + ': ' + value + ' ';
                    }
                }
                this.set_text(text);
                this.do_search().then(() => {
                    this.last_search_text = this.get_text();
                });
            };
            if (!this.search_modal) {
                var dialog = new Sao.Dialog(
                        Sao.i18n.gettext('Filters'), '', 'lg');
                this.search_modal = dialog.modal;
                this.search_form = dialog.content;
                this.search_form.addClass('form-horizontal');
                this.search_form.submit(function(e) {
                    e.preventDefault();
                    search();
                });

                var fields = [];
                for (var f in domain_parser.fields) {
                    const field = domain_parser.fields[f];
                    if ((field.searchable || field.searchable === undefined) &&
                        !field.name.contains('.')) {
                        fields.push(field);
                    }
                }

                var prefix = 'filter-' + this.screen.model_name + '-';
                this.search_form.fields = [];
                for (const field of fields) {
                    var form_group = jQuery('<div/>', {
                        'class': 'form-group form-group-sm'
                    }).append(jQuery('<label/>', {
                        'class': 'col-sm-4 control-label',
                        'for': prefix + field.name,
                        text: field.string
                    })).appendTo(dialog.body);

                    var input;
                    var entry;
                    var format, date_format, time_format;
                    switch (field.type) {
                        case 'boolean':
                            entry = input = jQuery('<select/>', {
                                'class': 'form-control input-sm',
                                id: prefix + field.name
                            });
                            for (const e of [
                                '',
                                Sao.i18n.gettext('True'),
                                Sao.i18n.gettext('False')]) {
                                jQuery('<option/>', {
                                    value: e,
                                    text: e
                                }).appendTo(input);
                            }
                            break;
                        case 'selection':
                        case 'multiselection':
                            entry = new Sao.ScreenContainer.Selection(
                                    field.selection, prefix + field.name);
                            input = entry.el;
                            break;
                        case 'date':
                            format = Sao.common.date_format(
                                this.screen.context.date_format);
                            entry = new Sao.ScreenContainer.Dates(
                                format, prefix + field.name);
                            input = entry.el;
                            break;
                        case 'datetime':
                            date_format = Sao.common.date_format(
                                this.screen.context.date_format);
                            time_format = new Sao.PYSON.Decoder({}).decode(
                                field.format);
                            time_format = Sao.common.moment_format(time_format);
                            format = date_format + ' ' + time_format;
                            entry = new Sao.ScreenContainer.DateTimes(
                                format, prefix + field.name);
                            input = entry.el;
                            break;
                        case 'time':
                            time_format = new Sao.PYSON.Decoder({}).decode(
                                field.format);
                            format = Sao.common.moment_format(time_format);
                            entry = new Sao.ScreenContainer.Times(
                                format, prefix + field.name);
                            input = entry.el;
                            break;
                        case 'integer':
                        case 'float':
                        case 'numeric':
                            entry = new Sao.ScreenContainer.Numbers(prefix + field.name);
                            input = entry.el;
                            break;
                        default:
                            entry = input = jQuery('<input/>', {
                                'class': 'form-control input-sm',
                                type: 'text',
                                placeholder: field.string,
                                id: prefix + field.name
                            });
                            break;
                    }
                    jQuery('<div/>', {
                        'class': 'col-sm-8'
                    }).append(input).appendTo(form_group);
                    this.search_form.fields.push([field.string, entry, input]);
                }

                jQuery('<button/>', {
                    'class': 'btn btn-primary',
                    type: 'submit',
                    'title': Sao.i18n.gettext("Find"),
                }).text(Sao.i18n.gettext('Find'))
                .click(search).appendTo(dialog.footer);
            }
            this.search_modal.modal('show');
            if (this.last_search_text.trim() !== this.get_text().trim()) {
                for (var j = 0; j < this.search_form.fields.length; j++) {
                    var fentry = this.search_form.fields[j][1];
                    if (fentry instanceof Sao.ScreenContainer.Selection) {
                        fentry.set_value([]);
                    } else if (fentry instanceof Sao.ScreenContainer.Between) {
                        fentry.set_value(null, null);
                    } else {
                        fentry.val('');
                    }
                }
                this.search_form.fields[0][2].focus();
            }
        }
    });

    Sao.ScreenContainer.Between = Sao.class_(Object, {
        init: function(id) {
            this.el = jQuery('<div/>', {
                'class': 'row',
                id: id
            });
            this.from = this.build_entry(Sao.i18n.gettext("From"),
                jQuery('<div/>', {
                    'class': 'col-md-5'
                }).appendTo(this.el));
            jQuery('<p/>', {
                'class': 'text-center'
            }).append('..').appendTo(jQuery('<div/>', {
                'class': 'col-md-1'
            }).appendTo(this.el));
            this.to = this.build_entry(Sao.i18n.gettext("To"),
                jQuery('<div/>', {
                    'class': 'col-md-5'
                }).appendTo(this.el));
        },
        build_entry: function(placeholder, el) {
        },
        get_value: function(quote) {
            var from = this._get_value(this.from);
            var to = this._get_value(this.to);
            if (from && to) {
                if (from !== to) {
                    return quote(from) + '..' + quote(to);
                } else {
                    return quote(from);
                }
            } else if (from) {
                return '>=' + quote(from);
            } else if (to) {
                return '<=' + quote(to);
            }
        },
        _get_value: function(entry) {
        },
        set_value: function(from, to) {
            this._set_value(this.from, from);
            this._set_value(this.to, to);
        },
        _set_value: function(entry, value) {
        },
        _from_changed: function(evt) {
            this._set_value(this.to, this._get_value(this.from));
        },
    });

    Sao.ScreenContainer.BetweenDates = Sao.class_(Sao.ScreenContainer.Between, {
        init: function(format, id) {
            this.format = format;
            Sao.ScreenContainer.BetweenDates._super.init.call(this, id);
            this.from.change(this._from_changed.bind(this));
        },
        _get_value: function(entry, value) {
            return entry.find('input[type=text]').val();
        },
        _set_value: function(entry, value) {
            entry.find('input[type=text]').val(value);
        },
    });

    Sao.ScreenContainer.Dates = Sao.class_(
        Sao.ScreenContainer.BetweenDates, {
            _input: 'date',
            _input_format: '%Y-%m-%d',
            _format: Sao.common.format_date,
            _parse: Sao.common.parse_date,
            build_entry: function(placeholder, el) {
                var entry = jQuery('<div/>', {
                    'class': ('input-group input-group-sm ' +
                        'input-icon input-icon-secondary ' +
                        'input-' + this._input),
                }).appendTo(el);
                var date = jQuery('<input/>', {
                    'type': 'text',
                    'class': 'form-control input-sm mousetrap',
                }).appendTo(entry);
                var input = jQuery('<input/>', {
                    'type': this._input,
                    'role': 'button',
                    'tabindex': -1,
                });
                input.click(() => {
                    var value = this._parse(this.format, date.val());
                    value = this._format(this._input_format, value);
                    input.val(value);
                });
                input.change(() => {
                    var value = input.val();
                    if (value) {
                        value = this._parse(this._input_format, value);
                        value = this._format(this.format, value);
                        date.val(value);
                        date.focus();
                    }
                });
                if (input[0].type == this._input) {
                    var icon = jQuery('<div/>', {
                        'class': 'icon-input icon-secondary',
                        'aria-label': Sao.i18n.gettext("Open the calendar"),
                        'title': Sao.i18n.gettext("Open the calendar"),
                    }).appendTo(entry);
                    input.appendTo(icon);
                    Sao.common.ICONFACTORY.get_icon_img('tryton-date')
                        .appendTo(icon);
                }
                var mousetrap = new Mousetrap(date[0]);

                mousetrap.bind('enter', (e, combo) => {
                    var value = this._parse(this.format, date.val());
                    value = this._format(this.format, value);
                    date.val(value);
                });
                mousetrap.bind('=', (e, combo) => {
                    e.preventDefault();
                    date.val(this._format(this.format, moment()));
                });

                Sao.common.DATE_OPERATORS.forEach(operator => {
                    mousetrap.bind(operator[0], (e, combo) => {
                        e.preventDefault();
                        var value = (this._parse(this.format, date.val()) ||
                            Sao.DateTime());
                        value.add(operator[1]);
                        date.val(this._format(this.format, value));
                    });
                });
                return entry;
        },
    });

    Sao.ScreenContainer.DateTimes = Sao.class_(
        Sao.ScreenContainer.Dates, {
            _input: 'datetime-local',
            _input_format: '%Y-%m-%dT%H:%M:%S',
            _format: Sao.common.format_datetime,
            _parse: Sao.common.parse_datetime,
        });

    Sao.ScreenContainer.Times = Sao.class_(
        Sao.ScreenContainer.Dates, {
            _input: 'time',
            _input_format: '%H:%M:%S',
            _format: Sao.common.format_time,
            _parse: Sao.common.parse_time,
            build_entry: function(placeholder, el) {
                var entry = Sao.ScreenContainer.Times._super.build_entry.call(
                    this, placeholder, el);
                if (~navigator.userAgent.indexOf("Firefox")) {
                    // time input on Firefox does not have a pop-up
                    entry.find('.icon-input').hide();
                }
                return entry;
            },
        });

    Sao.ScreenContainer.Numbers = Sao.class_(Sao.ScreenContainer.Between, {
        init: function(id) {
            Sao.ScreenContainer.Numbers._super.init.call(this, id);
            this.from.change(this._from_changed.bind(this));
        },
        build_entry: function(placeholder, el) {
            var entry = jQuery('<input/>', {
                'class': 'form-control input-sm',
                'type': 'number',
                'step': 'any',
            }).appendTo(el);
            return entry;
        },
        _get_value: function(entry, value) {
            return entry.val();
        },
        _set_value: function(entry, value) {
            return entry.val(value);
        },
    });

    Sao.ScreenContainer.Selection = Sao.class_(Object, {
        init: function(selections, id) {
            this.el = jQuery('<select/>', {
                'class': 'form-control input-sm',
                multiple: true,
                id: id
            });
            for (const s of selections) {
                jQuery('<option/>', {
                    value: s[1],
                    text: s[1]
                }).appendTo(this.el);
            }
        },
        get_value: function(quote) {
            var value = this.el.val();
            if (!jQuery.isEmptyObject(value)) {
                value = jQuery.map(value, quote).reduce(function(a, b) {
                    if (a) {a += ';';}
                    return a + b;
                });
            } else {
                value = null;
            }
            return value;
        },
        set_value: function(value) {
            this.el.val(value);
        }
    });

    Sao.Screen = Sao.class_(Object, {
        init: function(model_name, attributes) {
            this.model_name = model_name;
            this.windows = [];
            this.model = new Sao.Model(model_name, attributes);
            this.attributes = jQuery.extend({}, attributes);
            this.view_ids = jQuery.extend([], attributes.view_ids);
            this.view_to_load = jQuery.extend([],
                attributes.mode || ['tree', 'form']);
            this.views = [];
            this.views_preload = attributes.views_preload || {};
            this.exclude_field = attributes.exclude_field;
            this.current_view = null;
            this.domain = attributes.domain || [];
            this.context_domain = attributes.context_domain;
            this.size_limit = null;
            if (this.attributes.limit === undefined) {
                this.limit = Sao.config.limit;
            } else {
                this.limit = attributes.limit;
            }
            this._current_domain = [];
            this.offset = 0;
            this.order = this.default_order = attributes.order;
            this.readonly = this.attributes.readonly || false;
            var access = Sao.common.MODELACCESS.get(model_name);
            if (!(access.write || access.create)) {
                this.readonly = true;
            }
            this.search_count = 0;
            this.new_group(attributes.context || {});
            this.current_record = null;
            this.screen_container = new Sao.ScreenContainer(
                attributes.tab_domain);
            this.breadcrumb = attributes.breadcrumb || [];

            this.context_screen = null;
            if (attributes.context_model) {
                this.context_screen = new Sao.Screen(
                        attributes.context_model, {
                            'mode': ['form'],
                            'context': attributes.context });

                this.context_screen_prm = this.context_screen.switch_view()
                    .then(() => {
                        jQuery('<div/>', {
                            'class': 'row'
                        }).append(jQuery('<div/>', {
                            'class': 'col-md-12'
                        }).append(this.context_screen.screen_container.el))
                        .prependTo(this.screen_container.filter_box);
                        return this.context_screen.new_(false).then(
                            // Set manually default to get context_screen_prm
                            // resolved when default is set.
                            record => record.default_get());
                    });
            }

            if (!attributes.row_activate) {
                this.row_activate = this.default_row_activate;
            } else {
                this.row_activate = attributes.row_activate;
            }
            this.tree_states = {};
            this.tree_states_done = [];
            this.fields_view_tree = {};
            this._domain_parser = {};
            this.pre_validate = false;
            this.switch_callback = null;
        },
        get readonly() {
            var readonly_records = this.selected_records.some(function(r) {
                return r.readonly;
            });
            return this.__readonly || readonly_records;
        },
        set readonly(value) {
            this.__readonly = value;
        },
        get deletable() {
            return this.selected_records.every(function(r) {
                return r.deletable;
            });
        },
        get count_limit() {
            return this.limit * 100 + this.offset;
        },
        load_next_view: function() {
            if (!jQuery.isEmptyObject(this.view_to_load)) {
                var view_id;
                if (!jQuery.isEmptyObject(this.view_ids)) {
                    view_id = this.view_ids.shift();
                }
                var view_type = this.view_to_load.shift();
                return this.add_view_id(view_id, view_type);
            }
            return jQuery.when();
        },
        add_view_id: function(view_id, view_type) {
            var view;
            if (view_id && this.views_preload[String(view_id)]) {
                view = this.views_preload[String(view_id)];
            } else if (!view_id && this.views_preload[view_type]) {
                view = this.views_preload[view_type];
            } else {
                var prm = this.model.execute('fields_view_get',
                        [view_id, view_type], this.context);
                return prm.pipe(this.add_view.bind(this));
            }
            this.add_view(view);
            return jQuery.when();
        },
        add_view: function(view) {
            var arch = view.arch;
            var fields = view.fields;
            var view_id = view.view_id;
            var xml_view = jQuery(jQuery.parseXML(arch));

            if (xml_view.children().prop('tagName') == 'tree') {
                this.fields_view_tree[view_id] = view;
            }

            var loading = 'eager';
            if (xml_view.children().prop('tagName') == 'form') {
                loading = 'lazy';
            }
            for (var field in fields) {
                if (!(field in this.model.fields) || loading == 'eager') {
                    fields[field].loading = loading;
                } else {
                    fields[field].loading = this.model.fields[field]
                        .description.loading;
                }
            }
            this.group.add_fields(fields);
            for (field in fields) {
                this.group.model.fields[field].views.add(view_id);
            }
            var view_widget = Sao.View.parse(
                this, view_id, view.type, xml_view, view.field_childs);
            this.views.push(view_widget);

            return view_widget;
        },
        get number_of_views() {
            return this.views.length + this.view_to_load.length;
        },
        get view_index() {
            return this.views.indexOf(this.current_view);
        },
        switch_view: function(
            view_type=null, view_id=null, creatable=null, display=true) {
            if (view_id !== null) {
                view_id = Number(view_id);
            }
            if (this.current_view) {
                this.current_view.set_value();
                if (this.current_record &&
                        !~this.current_record.group.indexOf(
                            this.current_record)) {
                    this.current_record = null;
                }
                var fields = this.current_view.get_fields();
                if (this.current_record && this.current_view.editable &&
                        !this.current_record.validate(
                            fields, false, false, true)) {
                    this.screen_container.set(this.current_view.el);
                    return this.current_view.display().done(() => {
                        this.set_cursor();
                    });
                }
            }
            const found = () => {
                if (!this.current_view) {
                    return false;
                }
                var result = true;
                if (view_type !== null) {
                    result &= this.current_view.view_type == view_type;
                }
                if (view_id !== null) {
                    result &= this.current_view.view_id == view_id;
                }
                if (creatable !== null) {
                    result &= this.current_view.creatable == creatable;
                }
                return result;
            };
            const _switch = () => {
                const set_container = () => {
                    this.screen_container.set(this.current_view.el);
                    var prm;
                    if (display) {
                        prm = this.display().done(() => {
                            this.set_cursor();
                        });
                    } else {
                        prm = jQuery.when();
                    }
                    return prm.done(() => {
                        if (this.switch_callback) {
                            this.switch_callback();
                        }
                        const tab = Sao.Tab.tabs.get_current();
                        Sao.Tab.set_view_type(tab ? tab.current_view_type : null);
                    });
                };
                const set_current_view = () => {
                    this.current_view = this.views[this.views.length - 1];
                };
                const switch_current_view = () => {
                    set_current_view();
                    if (!found()) {
                        return _switch();
                    } else {
                        return set_container();
                    }
                };
                const is_view_id = view => view.view_id == view_id;

                for (var n = 0; n < this.views.length + this.view_to_load.length; n++) {
                    if (this.view_to_load.length) {
                        return this.load_next_view().then(switch_current_view);
                    } else if ((view_id !== null) &&
                        !this.views.find(is_view_id)) {
                        return this.add_view_id(view_id, view_type)
                            .then(set_current_view);
                    } else {
                        this.current_view = this.views[
                            (this.view_index + 1) % this.views.length];
                    }
                    if (found()) {
                        break;
                    }
                }
                return set_container();
            };
            return _switch();
        },
        search_filter: function(search_string, only_ids) {
            only_ids = only_ids || false;
            if (this.context_screen && !only_ids) {
                if (this.context_screen_prm.state() == 'pending') {
                    return this.context_screen_prm.then(
                        () => this.search_filter(search_string));
                }
                var context_record = this.context_screen.current_record;
                if (context_record &&
                        !context_record.validate(null, false, null, true)) {
                    this.new_group();
                    this.context_screen.display(true);
                    return jQuery.when();
                }
                var screen_context = this.context_screen.get_on_change_value();
                delete screen_context.id;
                this.new_group(jQuery.extend(
                    this.local_context, screen_context));
            }

            var inversion = new Sao.common.DomainInversion();
            var domain = this.search_domain(search_string, true);
            var canonicalized = inversion.canonicalize(domain);
            if (!Sao.common.compare(canonicalized, this._current_domain)) {
                this._current_domain = canonicalized;
                this.offset = 0;
            }

            var context = this.context;
            if ((this.screen_container.but_active.css('display') != 'none') &&
                this.screen_container.but_active.hasClass('active')) {
                context.active_test = false;
            }
            const search = () => {
                return this.model.execute(
                    'search', [domain, this.offset, this.limit, this.order],
                    context)
                    .then(ids => {
                        if (ids.length || this.offset <= 0) {
                            return ids;
                        } else {
                            this.offset = Math.max(this.offset - this.limit, 0);
                            return search();
                        }
                    });
            };
            return search().then(ids => {
                    var count_prm = jQuery.when(this.search_count);
                    if (!only_ids) {
                        if ((this.limit !== null) &&
                            (ids.length == this.limit)) {
                            count_prm = this.model.execute(
                                'search_count',
                                [domain, 0, this.count_limit], context,
                                true, false)
                                .then(count => {
                                    this.search_count = count;
                                    return this.search_count;
                                }, () => {
                                    this.search_count = 0;
                                    return this.search_count;
                                });
                        } else {
                            this.search_count = ids.length;
                        }
                    }
                    return count_prm.then(count => {
                        this.screen_container.but_next.prop('disabled',
                            !(this.limit !== undefined &&
                                ids.length == this.limit &&
                                count > this.limit + this.offset));
                        this.screen_container.but_prev.prop('disabled', this.offset <= 0);
                        if (only_ids) {
                            return ids;
                        }
                        this.clear();
                        return this.load(ids).then(() => {
                            this.count_tab_domain();
                        });
                    });
                });
        },
        search_domain: function(search_string=null, set_text=false, with_tab=true) {
            set_text = set_text || false;
            var domain = [];

            // Test first parent to avoid calling unnecessary domain_parser
            if (!this.group.parent && this.domain_parser) {
                var domain_parser = this.domain_parser;
                if (search_string !== null) {
                    domain = domain_parser.parse(search_string);
                } else {
                    domain = this.attributes.search_value;
                    this.attributes.search_value = null;
                }
                if (set_text) {
                    this.screen_container.set_text(
                            domain_parser.string(domain));
                }
            } else {
                domain = [['id', 'in', this.group.map(function(r) {
                    return r.id;
                })]];
            }

            if (!jQuery.isEmptyObject(domain)) {
                if (!jQuery.isEmptyObject(this.domain)) {
                    domain = ['AND', domain, this.domain];
                }
            } else {
                domain = this.domain;
            }
            if ((this.screen_container.but_active.css('display') != 'none') &&
                this.screen_container.but_active.hasClass('active')) {
                if (!jQuery.isEmptyObject(domain)) {
                    domain = [domain, ['active', '=', false]];
                } else {
                    domain = [['active', '=', false]];
                }
            }
            if (this.current_view &&
                    this.current_view.view_type == 'calendar') {
                if (!jQuery.isEmptyObject(domain)) {
                   domain = ['AND', domain,
                        this.current_view.current_domain()];
                } else {
                    domain = this.current_view.current_domain();
                }
            }
            if (this.context_domain) {
                var decoder = new Sao.PYSON.Decoder(this.context);
                domain = ['AND', domain, decoder.decode(this.context_domain)];
            }
            if (with_tab) {
                var tab_domain = this.screen_container.get_tab_domain();
                if (!jQuery.isEmptyObject(tab_domain)) {
                    domain = ['AND', domain, tab_domain];
                }
            }
            return domain;
        },
        count_tab_domain: function(current=false) {
            var screen_domain = this.search_domain(
                this.screen_container.get_text(), false, false);
            var index = this.screen_container.get_tab_index();
            this.screen_container.tab_domain.forEach((tab_domain, i) => {
                if (tab_domain[2] && (!current || (i == index))) {
                    var domain = ['AND', tab_domain[1], screen_domain];
                    this.screen_container.set_tab_counter(null, i);
                    this.group.model.execute(
                        'search_count', [domain, 0, 1000], this.context)
                        .then(count => {
                            this.screen_container.set_tab_counter(count, i);
                        });
                }
            });
        },
        get context() {
            var context = this.group.context;
            if ( this.context_screen ){
                context.context_model = this.context_screen.model_name;
            }
            return context;
        },
        get local_context() {
            var context = this.group.local_context;
            if (this.context_screen) {
                context.context_model = this.context_screen.model_name;
            }
            return context;
        },
        set_group: function(group) {
            var fields = {},
                fields_views = {},
                name;
            if (this.group) {
                for (name in this.group.model.fields) {
                    var field = this.group.model.fields[name];
                    fields[name] = field.description;
                    fields_views[name] = field.views;
                }
                this.group.screens.splice(
                        this.group.screens.indexOf(this), 1);
                jQuery.extend(group.on_write, this.group.on_write);
                group.on_write = group.on_write.filter(function(e, i, a) {
                    return i == a.indexOf(e);
                });
                if (this.group.parent && !group.parent) {
                    group.parent = this.group.parent;
                }
            }
            group.screens.push(this);
            this.tree_states_done = [];
            this.views.map(function(view) {
                view.reset();
            });
            this.group = group;
            this.model = group.model;
            if (this.group.parent) {
                this.order = null;
            }
            this.current_record = null;
            this.group.add_fields(fields);
            for (name in fields_views) {
                var views = fields_views[name];
                for (const view of views) {
                    this.group.model.fields[name].views.add(view);
                }
            }
            this.group.exclude_field = this.exclude_field;
        },
        new_group: function(context) {
            if (!context) {
                context = this.context;
            }
            var group = new Sao.Group(this.model, context, []);
            group.readonly = this.__readonly;
            this.set_group(group);
        },
        record_modified: function(display=true) {
            for (const window_ of this.windows) {
                if (window_.record_modified) {
                    window_.record_modified();
                }
            }
            if (display) {
                return this.display();
            }
        },
        record_notify: function(notifications) {
            for (const window_ of this.windows) {
                if (window_.info_bar) {
                    window_.info_bar.refresh('notification');
                    for (const notification of notifications) {
                        const type = notification[0];
                        const message = notification[1];
                        window_.info_bar.add(message, type, 'notification');
                    }
                }
            }
        },
        record_message: function(position, size, max_size, record_id) {
            for (const window_ of this.windows) {
                if (window_.record_message) {
                    window_.record_message(position, size, max_size, record_id);
                }
            }
        },
        record_saved: function() {
            for (const window_ of this.windows) {
                if (window_.record_saved) {
                    window_.record_saved();
                }
            }
        },
        update_resources: function(resources) {
            for (const window_ of this.windows) {
                if (window_.update_resources) {
                    window_.update_resources(resources);
                }
            }
        },
        has_update_resources: function() {
            return this.windows.some(function(window_) {
                return window_.update_resources;
            });
        },
        get current_record() {
            return this.__current_record;
        },
        set current_record(record) {
            if ((this.__current_record === record) && record) {
                return;
            }
            this.__current_record = record;
            var pos = null;
            var record_id = null;
            if (record) {
                var i = this.group.indexOf(record);
                if (i >= 0) {
                    pos = i + this.offset + 1;
                } else {
                    pos = record.get_index_path();
                }
                record_id = record.id;
            }
            this.record_message(
                pos || 0, this.group.length + this.offset, this.search_count,
                record_id);
            if (this.switch_callback) {
                this.switch_callback();
            }
            if (this.has_update_resources()) {
                if (record) {
                    record.get_resources().always(
                        this.update_resources.bind(this));
                } else {
                    this.update_resources();
                }
            }
        },
        load: function(ids, set_cursor=true, modified=false, position=-1) {
            this.group.load(ids, modified, position, null);
            if (this.current_view) {
                this.current_view.reset();
            }
            this.current_record = null;
            return this.display(set_cursor);
        },
        display: function(set_cursor) {
            var deferreds = [];
            if (this.views && this.current_view) {
                var search_prm = this.search_active(
                        ~['tree', 'graph', 'calendar'].indexOf(
                            this.current_view.view_type));
                deferreds.push(search_prm);
                for (const view of this.views) {
                    if (view &&
                        ((view == this.current_view) ||
                            view.el.parent().length)) {
                        deferreds.push(view.display());
                    }
                }
                if (this.current_view.view_type == 'tree') {
                    let view_tree = this.fields_view_tree[
                        this.current_view.view_id] || {};
                    if ('active' in view_tree.fields) {
                        this.screen_container.but_active.show();
                    } else {
                        this.screen_container.but_active.hide();
                    }
                } else {
                    this.screen_container.but_active.hide();
                }
            }
            return jQuery.when.apply(jQuery, deferreds).then(
                () => this.set_tree_state().then(() => {
                    var record = this.current_record
                    this.current_record = record;
                    // set_cursor must be called after set_tree_state because
                    // set_tree_state redraws the tree
                    if (set_cursor) {
                        this.set_cursor(false, false);
                    }
                }));
        },
        _get_next_record: function() {
            var view = this.current_view;
            if (view &&
                ~['tree', 'form', 'list-form'].indexOf(view.view_type) &&
                this.current_record && this.current_record.group) {
                var group = this.current_record.group;
                var record = this.current_record;
                while (group) {
                    var index = group.indexOf(record);
                    if (index < group.length - 1) {
                        record = group[index + 1];
                        break;
                    } else if (group.parent &&
                            (record.group.model.name ==
                             group.parent.group.model.name)) {
                        record = group.parent;
                        group = group.parent.group;
                    } else {
                        break;
                    }
                }
                return record;
            } else {
                return this.group[0];
            }
        },
        has_next: function() {
            var next_record = this._get_next_record();
            return next_record &&
                (next_record !== this.current_record);
        },
        display_next: function() {
            var view = this.current_view;
            if (view) {
                view.set_value();
            }
            this.set_cursor(false, false);
            this.current_record = this._get_next_record();
            this.set_cursor(false, false);
            return view ? view.display() : jQuery.when();
        },
        _get_previous_record: function() {
            var view = this.current_view;
            if (view &&
                ~['tree', 'form', 'list-form'].indexOf(view.view_type) &&
                this.current_record && this.current_record.group) {
                var group = this.current_record.group;
                var record = this.current_record;
                while (group) {
                    var index = group.indexOf(record);
                    if (index > 0) {
                        record = group[index - 1];
                        break;
                    } else if (group.parent &&
                            (record.group.model.name ==
                             group.parent.group.model.name)) {
                        record = group.parent;
                        group = group.parent.group;
                    } else {
                        break;
                    }
                }
                return record;
            } else {
                return this.group[0];
            }
        },
        has_previous: function() {
            var previous_record = this._get_previous_record();
            return previous_record &&
                (previous_record !== this.current_record);
        },
        display_previous: function() {
            var view = this.current_view;
            if (view) {
                view.set_value();
            }
            this.set_cursor(false, false);
            this.current_record = this._get_previous_record();
            this.set_cursor(false, false);
            return view ? view.display() : jQuery.when();
        },
        get selected_records() {
            if (this.current_view) {
                return this.current_view.selected_records;
            }
            return [];
        },
        get selected_paths() {
            if (this.current_view && this.current_view.view_type == 'tree') {
                return this.current_view.get_selected_paths();
            } else {
                return [];
            }
        },
        get listed_records() {
            if (this.current_view &&
                ~['tree', 'calendar', 'list-form'].indexOf(
                    this.current_view.view_type)) {
                return this.current_view.listed_records;
            } else if (this.current_record) {
                return [this.current_record];
            } else {
                return [];
            }
        },
        get listed_paths() {
            if (this.current_view && this.current_view.view_type == 'tree') {
                return this.current_view.get_listed_paths();
            } else {
                return [];
            }
        },
        clear: function() {
            this.current_record = null;
            this.group.clear();
            this.tree_states_done = [];
            this.views.map(function(view) {
                view.reset();
            });
        },
        default_row_activate: function() {
            if (this.current_view &&
                (this.current_view.view_type == 'tree') &&
                (this.current_view.attributes.keyword_open == 1)) {
                const id = this.get_id();
                if (id) {
                    Sao.Action.exec_keyword('tree_open', {
                        'model': this.model_name,
                        'id': this.get_id(),
                        'ids': [this.get_id()]
                    }, this.local_context, false);
                }
            } else {
                if (!this.modified()) {
                    this.switch_view('form');
                }
            }
        },
        get_id: function() {
            if (this.current_record) {
                return this.current_record.id;
            }
        },
        new_: function(default_=true, defaults=null) {
            var previous_view = this.current_view;
            var prm = jQuery.when();
            if (this.current_view &&
                this.current_view.view_type == 'calendar') {
                var selected_date = this.current_view.get_selected_date();
            }
            if (this.current_view && !this.current_view.creatable) {
                prm = this.switch_view('form', undefined, true, false);
            }
            return prm.then(() => {
                if (!this.current_view || !this.current_view.editable) {
                    return;
                }
                var group;
                if (this.current_record) {
                    group = this.current_record.group;
                } else {
                    group = this.group;
                }
                var record = group.new_(false);
                var prm;
                if (default_) {
                    prm = record.default_get(defaults);
                } else {
                    prm = jQuery.when();
                }
                return prm.then(() => {
                    group.add(record, this.new_position);
                    this.current_record = record;
                    if (previous_view.view_type == 'calendar') {
                        previous_view.set_default_date(record, selected_date);
                    }
                    this.display().done(() => {
                        this.set_cursor(true, true);
                    });
                    return record;
                });
            });
        },
        get new_position() {
            var order;
            if (this.order !== null) {
                order = this.order;
            } else {
                order = this.default_order;
            }
            if (order) {
                for (var j = 0; j < order.length; j++) {
                    var oexpr = order[j][0],
                        otype = order[j][1];
                    if ((oexpr == 'id') && otype) {
                        if (otype.startsWith('DESC')) {
                            return 0;
                        } else if (otype.startsWith('ASC')) {
                            return -1;
                        }
                    }
                }
            }
            if (this.group.parent) {
                return -1;
            } else {
                return 0;
            }
        },
        set_on_write: function(name) {
            if(name) {
                if (!~this.group.on_write.indexOf(name)) {
                    this.group.on_write.push(name);
                }
            }
        },
        cancel_current: function(initial_value) {
            var prms = [];
            if (this.current_record) {
                this.current_record.cancel();
                if (this.current_record.id < 0) {
                    if (initial_value) {
                        this.current_record.reset(initial_value);
                        this.display();
                    } else {
                        prms.push(this.remove(
                            false, false, false, [this.current_record]));
                    }
                }
            }
            return jQuery.when.apply(jQuery, prms);
        },
        save_current: function() {
            var current_record = this.current_record;
            if (!current_record) {
                if (this.current_view &&
                    (this.current_view.view_type == 'tree') &&
                    this.group && this.group.length) {
                    this.current_record = this.group[0];
                    current_record = this.current_record;
                } else {
                    return jQuery.when();
                }
            }
            if (this.current_view) {
                this.current_view.set_value();
                var fields = this.current_view.get_fields();
            }
            var path = current_record.get_path(this.group);
            var prm = jQuery.Deferred();
            if (this.current_view && (this.current_view.view_type == 'tree')) {
                prm = this.group.save().then(() => this.current_record);
            } else if (current_record.validate(fields, null, null, true)) {
                prm = current_record.save().then(() => current_record);
            } else if (this.current_view) {
                return this.current_view.display().then(() => {
                    this.set_cursor();
                    return jQuery.Deferred().reject();
                });
            }
            const display = () => {
                // Return the original promise to keep succeed/rejected state
                return this.display()
                    .always(() => this.record_saved())
                    .then(() => prm, () => prm);
            };
            return prm.then(current_record => {
                if (path && current_record && current_record.id) {
                    path.splice(-1, 1,
                            [path[path.length - 1][0], current_record.id]);
                }
                return this.group.get_by_path(path).then(record => {
                    this.current_record = record;
                });
            }).then(display, display);
        },
        set_cursor: function(new_, reset_view) {
            if (!this.current_view) {
                return;
            } else if (~['tree', 'form', 'list-form'].indexOf(
                    this.current_view.view_type)) {
                this.current_view.set_cursor(new_, reset_view);
            }
        },
        modified: function() {
            var test = function(record) {
                return (record.modified || record.id < 0);
            };
            if (this.current_view && (this.current_view.view_type != 'tree')) {
                if (this.current_record) {
                    if (test(this.current_record)) {
                        return true;
                    }
                }
            } else {
                if (this.group.some(test)) {
                    return true;
                }
            }
            if (this.current_view && this.current_view.modified) {
                return true;
            }
            return false;
        },
        unremove: function() {
            if (this.current_view) {
                var records = this.current_view.selected_records;
                for (const record of records) {
                    record.group.unremove(record);
                }
            }
        },
        remove: function(delete_, remove, force_remove, records) {
            var prm = jQuery.when();
            if (!records && this.current_view) {
                records = this.current_view.selected_records;
            }
            if (jQuery.isEmptyObject(records)) {
                return prm;
            }
            if (delete_) {
                // TODO delete children before parent
                prm = this.group.delete_(records);
            }
            return prm.then(() => {
                for (const record of records) {
                    record.group.remove(record, remove, force_remove, false);
                }
                // trigger changed only once
                records[0].group.record_modified();
                var prms = [];
                if (delete_) {
                    for (const record of records) {
                        if (record.group.parent) {
                            prms.push(record.group.parent.save(false));
                        }
                        if (~record.group.record_deleted.indexOf(record)) {
                            record.group.record_deleted.splice(
                                record.group.record_deleted.indexOf(record), 1);
                        }
                        if (~record.group.record_removed.indexOf(record)) {
                            record.group.record_removed.splice(
                                record.group.record_removed.indexOf(record), 1);
                        }
                        // TODO destroy
                    }
                }
                this.current_record = null;
                return jQuery.when.apply(jQuery, prms).then(() => {
                    return this.display().done(() => {
                        this.set_cursor();
                    });
                });
            });
        },
        copy: function() {
            var dfd = jQuery.Deferred();
            var records = (
                this.current_view ? this.current_view.selected_records : []);
            this.model.copy(records, this.context)
                .then(new_ids => {
                    this.group.load(new_ids, false, this.new_position, null);
                    if (!jQuery.isEmptyObject(new_ids)) {
                        this.current_record = this.group.get(new_ids[0]);
                    }
                    this.display(true).always(dfd.resolve);
                }, dfd.reject);
            return dfd.promise();
        },
        search_active: function(active) {
            if (active && !this.group.parent) {
                this.screen_container.set_screen(this);
                this.screen_container.show_filter();
            } else {
                this.screen_container.hide_filter();
            }
            return jQuery.when();
        },
        get domain_parser() {
            var view_id, view_tree, domain_parser;
            if (this.current_view) {
                view_id = this.current_view.view_id;
            } else {
                view_id = null;
            }
            if (view_id in this._domain_parser) {
                return this._domain_parser[view_id];
            }
            if (!(view_id in this.fields_view_tree)) {
                view_tree = this.model.execute('fields_view_get', [false, 'tree'],
                    this.context, false);
                this.fields_view_tree[view_id] = view_tree;
            } else {
                view_tree = this.fields_view_tree[view_id];
            }
            var fields = jQuery.extend({}, view_tree.fields);

            for (var name in fields) {
                var props = fields[name];
                if ((props.type != 'selection') &&
                    (props.type != 'multiselection') &&
                    (props.type != 'reference')) {
                    continue;
                }
                if (props.selection instanceof Array) {
                    continue;
                }
                props = jQuery.extend({}, props);
                props.selection = this.get_selection(props);
                fields[name] = props;
            }

            if ('arch' in view_tree) {
                // Filter only fields in XML view
                var xml_view = jQuery(jQuery.parseXML(view_tree.arch));
                var dom_fields = {};
                xml_view.find('tree').children().each(function(i, node) {
                    if (node.tagName == 'field') {
                        var name = node.getAttribute('name');
                        // If a field is defined multiple times in the XML,
                        // take only the first definition
                        if (!(name in dom_fields)) {
                            dom_fields[name] = fields[name];
                            for (const attr of ['string', 'factor']) {
                                if (node.getAttribute(attr)) {
                                    dom_fields[name][attr] = node.getAttribute(attr);
                                }
                            }
                        }
                        var symbol = node.getAttribute('symbol');
                        if (symbol && !(symbol in dom_fields)) {
                            dom_fields[symbol] = fields[symbol];
                        }
                    }
                });
                fields = dom_fields;
            }

            // Add common fields
            const common_fields = new Set([
                ['id', Sao.i18n.gettext('ID'), 'integer'],
                ['create_uid', Sao.i18n.gettext('Created by'), 'many2one'],
                ['create_date', Sao.i18n.gettext('Created at'), 'datetime'],
                ['write_uid', Sao.i18n.gettext('Modified by'), 'many2one'],
                ['write_date', Sao.i18n.gettext('Modified at'), 'datetime']
            ]);
            for (const [name, string, type] of common_fields) {
                if (!(name in fields)) {
                    fields[name] = {
                        'string': string,
                        'name': name,
                        'type': type
                    };
                    if (type == 'datetime') {
                        fields[name].format = '"%H:%M:%S"';
                    }
                }
            }

            domain_parser = new Sao.common.DomainParser(fields, this.context);
            this._domain_parser[view_id] = domain_parser;
            return domain_parser;
        },
        get_selection: function(props) {
            var selection;
            var change_with = props.selection_change_with;
            if (!jQuery.isEmptyObject(change_with)) {
                var values = {};
                for (const p of change_with) {
                    values[p] = null;
                }
                selection = this.model.execute(props.selection,
                        [values], undefined, false, true);
            } else {
                selection = this.model.execute(props.selection,
                        [], undefined, false, true);
            }
            return selection.sort(function(a, b) {
                return a[1].localeCompare(b[1]);
            });
        },
        search_prev: function(search_string) {
            if (this.limit) {
                this.offset = Math.max(this.offset - this.limit, 0);
            }
            this.search_filter(search_string);
        },
        search_next: function(search_string) {
            if (this.limit) {
                this.offset += this.limit;
            }
            this.search_filter(search_string);
        },
        invalid_message: function(record) {
            if (!record) {
                record = this.current_record;
            }
            var fields_desc = {};
            for (var fname in record.model.fields) {
                var field = record.model.fields[fname];
                fields_desc[fname] = field.description;
            }
            var domain_parser = new Sao.common.DomainParser(fields_desc);
            var fields = [];
            var invalid_fields = record.invalid_fields();
            for (const field of Object.keys(invalid_fields).sort()) {
                var invalid = invalid_fields[field];
                var string = record.model.fields[field].description.string;
                if ((invalid == 'required') ||
                    (Sao.common.compare(invalid, [[field, '!=', null]]))) {
                    fields.push(Sao.i18n.gettext(
                        '"%1" is required.', string));
                } else if (invalid == 'domain') {
                    fields.push(Sao.i18n.gettext(
                        '"%1" is not valid according to its domain.',
                        string));
                } else if (invalid == 'children') {
                    fields.push(Sao.i18n.gettext(
                        'The values of "%1" are not valid.', string));
                } else {
                    if (domain_parser.stringable(invalid)) {
                        fields.push(domain_parser.string(invalid));
                    } else {
                        fields.push(Sao.i18n.gettext(
                            '"%1" is not valid according to its domain.'),
                            string);
                    }
                }
            }
            if (fields.length > 5) {
                fields.splice(5, fields.length);
                fields.push('...');
            }
            return fields.join('\n');
        },
        get: function() {
            if (!this.current_record) {
                return null;
            }
            if (this.current_view) {
                this.current_view.set_value();
            }
            return this.current_record.get();
        },
        get_on_change_value: function() {
            if (!this.current_record) {
                return null;
            }
            if (this.current_view) {
                this.current_view.set_value();
            }
            return this.current_record.get_on_change_value();
        },
        reload: function(ids, written) {
            this.group.reload(ids);
            var promises = [];
            if (written) {
                promises.push(this.group.written(ids));
            }
            if (this.group.parent) {
                promises.push(this.group.parent.root_parent.reload());
            }
            return jQuery.when.apply(jQuery, promises).then(() => {
                return this.display();
            });
        },
        get_buttons: function() {
            var selected_records = (
                this.current_view ? this.current_view.selected_records : []);
            if (jQuery.isEmptyObject(selected_records)) {
                return [];
            }
            var buttons = (
                this.current_view ? this.current_view.get_buttons() : []);
            for (const record of selected_records) {
                buttons = buttons.filter(function(button) {
                    if (button.attributes.type === 'instance') {
                        return false;
                    }
                    var states = record.expr_eval(
                        button.attributes.states || {});
                    return !(states.invisible || states.readonly);
                });
            }
            return buttons;
        },
        button: function(attributes) {
            var ids;
            const process_action = action => {
                if (typeof action == 'string') {
                    return this.reload(ids, true).then(() => {
                        return this.client_action(action);
                    });
                }
                else if (action) {
                    return Sao.Action.execute(action, {
                        model: this.model_name,
                        id: this.current_record.id,
                        ids: ids
                    }, null, this.context, true).always(() => {
                        return this.reload(ids, true)
                            .always(() => this.record_saved());
                    });
                } else {
                    return this.reload(ids, true)
                        .always(() => this.record_saved());
                }
            };

            if (this.current_view) {
                var selected_records = this.current_view.selected_records;
                this.current_view.set_value();
                var fields = this.current_view.get_fields();
            }

            var prms = [];
            const reset_state = record => {
                return () => {
                    this.display(true);
                    // Reset valid state with normal domain
                    record.validate(fields);
                };
            };
            for (const record of selected_records) {
                const domain = record.expr_eval(
                    (attributes.states || {})).pre_validate || [];
                prms.push(record.validate(fields, false, domain));
            }
            return jQuery.when.apply(jQuery, prms).then((...args) => {
                var record;
                for (var i = 0; i < selected_records.length; i++) {
                    record = selected_records[i];
                    var result = args[i];
                    if (result) {
                        continue;
                    }
                    Sao.common.warning.run(
                            this.invalid_message(record),
                            Sao.i18n.gettext('Pre-validation'))
                        .then(reset_state(record));
                    return;
                }
                var prm = jQuery.when();
                if (attributes.confirm) {
                    prm = Sao.common.sur.run(attributes.confirm);
                }
                return prm.then(() => {
                    var record = this.current_record;
                    if (attributes.type === 'instance') {
                        var args = record.expr_eval(attributes.change || []);
                        var values = record._get_on_change_args(args);
                        return record.model.execute(attributes.name, [values],
                            this.context).then(function(changes) {
                            record.set_on_change(changes);
                            record.set_modified();
                        });
                    } else {
                        return record.save(false).then(() => {
                            var context = this.context;
                            context._timestamp = {};
                            ids = [];
                            for (i = 0; i < selected_records.length; i++) {
                                record = selected_records[i];
                                jQuery.extend(context._timestamp,
                                    record.get_timestamp());
                                ids.push(record.id);
                            }
                            return record.model.execute(attributes.name,
                                [ids], context)
                                .then(process_action)
                                .fail(() => this.reload(ids, true));
                        });
                    }
                });
            });
        },
        client_action: function(action) {
            var access = Sao.common.MODELACCESS.get(this.model_name);
            if (action == 'new') {
                if (access.create) {
                    return this.new_();
                }
            } else if (action == 'delete') {
                if (access['delete'] && (
                        this.current_record ? this.current_record.deletable :
                        true)) {
                    return this.remove(!this.group.parent, false, !this.group.parent);
                }
            } else if (action == 'remove') {
                if (access.write && access.read && this.group.parent) {
                    return this.remove(false, true, false);
                }
            } else if (action == 'copy') {
                if (access.create) {
                    return this.copy();
                }
            } else if (action == 'next') {
                return this.display_next();
            } else if (action == 'previous') {
                return this.display_previous();
            } else if (action == 'close') {
                Sao.Tab.tabs.close_current();
            } else if (action.startsWith('switch')) {
                return this.switch_view.apply(this, action.split(' ', 3).slice(1));
            } else if (action == 'reload') {
                if (this.current_view && 
                    ~['tree', 'graph', 'calendar'].indexOf(this.current_view.view_type) &&
                    !this.group.parent) {
                    return this.search_filter();
                }
            } else if (action == 'reload menu') {
                return Sao.Session.current_session.reload_context()
                    .then(function() {
                        Sao.menu();
                    });
            } else if (action == 'reload context') {
                return Sao.Session.current_session.reload_context();
            }
        },
        get_url: function(name) {
            function dumps(value) {
                return JSON.stringify(Sao.rpc.prepareObject(value));
            }
            var query_string = [];
            if (!jQuery.isEmptyObject(this.domain)) {
                query_string.push(['domain', dumps(this.domain)]);
            }
            var context = this.local_context;  // Avoid rpc context
            if (!jQuery.isEmptyObject(context)) {
                query_string.push(['context', dumps(context)]);
            }
            if (this.context_screen) {
                query_string.push(
                    ['context_model', this.context_screen.model_name]);
            }
            if (name) {
                query_string.push(['name', dumps(name)]);
            }
            var path = ['model', this.model_name];
            var view_ids = this.views.map(
                function(v) {return v.view_id;}).concat(this.view_ids);
            if (this.current_view && (this.current_view.view_type != 'form')) {
                if (!jQuery.isEmptyObject(this.attributes.tab_domain)) {
                    query_string.push([
                        'tab_domain', dumps(this.attributes.tab_domain)]);
                }
                var search_value;
                if (this.attributes.search_value) {
                    search_value = this.attributes.search_value;
                } else {
                    var search_string = this.screen_container.get_text();
                    search_value = this.domain_parser.parse(search_string);
                }
                if (!jQuery.isEmptyObject(search_value)) {
                    query_string.push(['search_value', dumps(search_value)]);
                }
            } else if (this.current_record && (this.current_record.id > -1)) {
                path.push(this.current_record.id);
                if (this.current_view) {
                    var i = view_ids.indexOf(this.current_view.view_id);
                    view_ids = view_ids.slice(i).concat(view_ids.slice(0, i));
                }
            }
            if (!jQuery.isEmptyObject(view_ids)) {
                query_string.push(['views', dumps(view_ids)]);
            }
            query_string = query_string.map(function(e) {
                return e.map(encodeURIComponent).join('=');
            }).join('&');
            path = path.join('/');
            if (query_string) {
                path += ';' + query_string;
            }
            return path;
        },
        save_tree_state: function(store=true) {
            var prms = [];
            var prm;
            var i, len, view, widgets, wi, wlen;
            var parent_ = this.group.parent ? this.group.parent.id : null;
            var clear_cache = function() {
                Sao.Session.current_session.cache.clear(
                    'model.ir.ui.view_tree_state.get');
            };
            const set_session_fail = () => {
                Sao.Logger.warn(
                    "Unable to set view tree state for %s",
                    this.model_name);
            };
            for (i = 0, len = this.views.length; i < len; i++) {
                view = this.views[i];
                if (view.view_type == 'form') {
                    for (var wid_key in view.widgets) {
                        widgets = view.widgets[wid_key];
                        for (wi = 0, wlen = widgets.length; wi < wlen; wi++) {
                            if (widgets[wi].screen) {
                                prm = widgets[wi].screen.save_tree_state(store);
                                prms.push(prm);
                            }
                        }
                    }
                    if ((this.views.length == 1) && this.current_record) {
                        if (!(parent_ in this.tree_states)) {
                            this.tree_states[parent_] = {};
                        }
                        this.tree_states[parent_][
                            view.children_field || null] = [
                                [], [[this.current_record.id]]];
                    }
                } else if (~['tree', 'list-form'].indexOf(view.view_type)) {
                    var paths;
                    if (view.view_type == 'tree') {
                        paths = view.get_expanded_paths();
                    } else {
                        paths = [];
                    }
                    var selected_paths = view.get_selected_paths();
                    if (!(parent_ in this.tree_states)) {
                        this.tree_states[parent_] = {};
                    }
                    this.tree_states[parent_][view.children_field || null] = [
                        paths, selected_paths];
                    if (store && parseInt(view.attributes.tree_state, 10)) {
                        var tree_state_model = new Sao.Model(
                                'ir.ui.view_tree_state');
                        prm = tree_state_model.execute('set', [
                                this.model_name,
                                this.get_tree_domain(parent_),
                                view.children_field,
                                JSON.stringify(paths),
                                JSON.stringify(selected_paths)], {})
                            .then(clear_cache)
                            .fail(set_session_fail);
                        prms.push(prm);
                    }
                }
            }
            return jQuery.when.apply(jQuery, prms);
        },
        get_tree_domain: function(parent_) {
            var domain;
            if (parent_) {
                domain = (this.domain || []).concat([
                        [this.exclude_field, '=', parent_]]);
            } else {
                domain = this.domain;
            }
            return JSON.stringify(Sao.rpc.prepareObject(domain));
        },
        set_tree_state: function() {
            var parent_, state, state_prm, tree_state_model;
            var view = this.current_view;
            if (!view) {
                return jQuery.when();
            }
            if (!~['tree', 'form', 'list-form'].indexOf(view.view_type)) {
                return jQuery.when();
            }

            if (~this.tree_states_done.indexOf(view)) {
                return jQuery.when();
            }
            if (view.view_type == 'form' &&
                    !jQuery.isEmptyObject(this.tree_states_done)) {
                return jQuery.when();
            }
            if ((~['tree', 'list-form'].indexOf(view.view_type)) &&
                !parseInt(view.attributes.tree_state, 10)) {
                this.tree_states_done.push(view);
            }

            parent_ = this.group.parent ? this.group.parent.id : null;
            if (parent_ < 0) {
                return jQuery.when();
            }
            if (!(parent_ in this.tree_states)) {
                this.tree_states[parent_] = {};
            }
            state = this.tree_states[parent_][view.children_field || null];
            if ((state === undefined) &&
                (parseInt(view.attributes.tree_state, 10))) {
                tree_state_model = new Sao.Model('ir.ui.view_tree_state');
                state_prm = tree_state_model.execute('get', [
                        this.model_name,
                        this.get_tree_domain(parent_),
                        view.children_field], {})
                    .then(state => {
                        state = [JSON.parse(state[0]), JSON.parse(state[1])];
                        if (!(parent_ in this.tree_states)) {
                            this.tree_states[parent_] = {};
                        }
                        this.tree_states[parent_][view.children_field || null] = state;
                        return state;
                    })
                    .fail(() => {
                        Sao.Logger.warn(
                            "Unable to get view tree state for %s",
                            this.model_name);
                    });
            } else {
                state_prm = jQuery.when(state);
            }
            this.tree_states_done.push(view);
            return state_prm.done(state => {
                var expanded_nodes = [], selected_nodes = [];
                if (state) {
                    expanded_nodes = state[0];
                    selected_nodes = state[1];
                }
                if (view.view_type == 'tree') {
                    return view.display(selected_nodes, expanded_nodes);
                } else if (view.view_type == 'list-form') {
                    return view.display(selected_nodes);
                } else {
                    var record;
                    if (!jQuery.isEmptyObject(selected_nodes)) {
                        for (const id of selected_nodes[0]) {
                            const new_record = this.group.get(id);
                            if (!new_record) {
                                break;
                            } else {
                                record = new_record;
                            }
                        }
                        if (record && (record != this.current_record)) {
                            this.current_record = record;
                            // Force a display of the view to synchronize the
                            // widgets with the new record
                            view.display();
                        }
                    }
                }
            });
        }
    });
    Sao.Screen.tree_column_optional = {};
    Sao.Screen.ag_grid_states = {};
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.View = Sao.class_(Object, {
        view_type: null,
        el: null,
        mnemonic_widget: null,
        view_id: null,
        modified: null,
        editable: null,
        creatable: null,
        children_field: null,
        xml_parser: null,
        init: function(view_id, screen, xml) {
            this.view_id = view_id;
            this.screen = screen;
            this.widgets = {};
            this.state_widgets = [];
            var attributes = xml.children()[0].attributes;
            this.attributes = {};
            for (const attribute of attributes) {
                this.attributes[attribute.name] = attribute.value;
            }
            screen.set_on_write(this.attributes.on_write);

            var field_attrs = {};
            for (var name in this.screen.model.fields) {
                field_attrs[name] = this.screen.model.fields[name].description;
            }
            if (this.xml_parser) {
                new this.xml_parser(
                    this, this.screen.exclude_field, field_attrs)
                    .parse(xml.children()[0]);
            }
            this.reset();
        },
        set_value: function() {
        },
        get record() {
            return this.screen.current_record;
        },
        set record(value) {
            this.screen.current_record = value;
        },
        get group() {
            return this.screen.group;
        },
        get selected_records() {
            return [];
        },
        get_fields: function() {
            return [];
        },
        get_buttons: function() {
            return [];
        },
        reset: function() {
        },
    });

    Sao.View.idpath2path = function(tree, idpath) {
        var path = [];
        var child_path;
        if (!idpath) {
            return [];
        }
        for (var i = 0, len = tree.rows.length; i < len; i++) {
            if (tree.rows[i].record.id == idpath[0]) {
                path.push(i);
                child_path = Sao.View.idpath2path(tree.rows[i],
                        idpath.slice(1, idpath.length));
                path = path.concat(child_path);
                break;
            }
        }
        return path;
    };

    Sao.View.parse = function(screen, view_id, type, xml, children_field) {
        switch (type) {
            case 'tree':
                if (Sao.View.get_tree_view) {
                    return Sao.View.get_tree_view(
                        view_id, screen, xml, children_field);
                }
                return new Sao.View.Tree(view_id, screen, xml, children_field);
            case 'form':
                return new Sao.View.Form(view_id, screen, xml);
            case 'graph':
                const dom = xml[0]?.documentElement;
                if (dom?.getAttribute('type') === 'visgraph') {
                    return new Sao.View.VisGraph(view_id, screen, xml);
                }
                return new Sao.View.Graph(view_id, screen, xml);
            case 'calendar':
                return new Sao.View.Calendar(view_id, screen, xml);
            case 'list-form':
                return new Sao.View.ListForm(view_id, screen, xml);
        }
    };

    Sao.View.XMLViewParser = Sao.class_(Object, {
        init: function(view, exclude_field, field_attrs) {
            this.view = view;
            this.exclude_field = exclude_field;
            this.field_attrs = field_attrs;
        },
        _node_attributes: function(node) {
            var node_attrs = {};
            for (var attribute of node.attributes) {
                node_attrs[attribute.name] = attribute.value;
            }

            var field = {};
            if (node_attrs.name) {
                field = this.field_attrs[node_attrs.name] || {};
            }

            for (const name of ['readonly', 'homogeneous']) {
                if (node_attrs[name]) {
                    node_attrs[name] = node_attrs[name] == 1;
                }
            }
            for (const name of [
                'yexpand', 'yfill',
                'xexpand', 'xfill',
                'colspan', 'position', 'height', 'width']) {
                if (node_attrs[name]) {
                    node_attrs[name] = Number(node_attrs[name]);
                }
            }
            for (const name of ['xalign', 'yalign']) {
                if (node_attrs[name]) {
                    node_attrs[name] = Number(node_attrs[name]);
                }
            }

            if (!jQuery.isEmptyObject(field)) {
                if (!node_attrs.widget) {
                    node_attrs.widget = field.type;
                }
                if ((node.tagName == 'label') &&
                        (node_attrs.string === undefined)) {
                    node_attrs.string = field.string + Sao.i18n.gettext(':');
                }
                if ((node.tagName == 'field') && (!node_attrs.help)) {
                    node_attrs.help = field.help;
                }

                for (const name of [
                    'relation', 'domain', 'selection', 'string', 'states',
                    'relation_field', 'views', 'invisible', 'add_remove',
                    'sort', 'context', 'size', 'filename', 'autocomplete',
                    'translate', 'create', 'delete', 'selection_change_with',
                    'schema_model', 'required', 'help_selection', 'help_field',
                    'order', 'symbol', 'monetary']) {
                    if ((name in field) && (!(name in node_attrs))) {
                        node_attrs[name] = field[name];
                    }
                }
            }
            return node_attrs;
        },
        parse: function(node) {
            if (node.tagName) {
                var attributes = this._node_attributes(node);
                this['_parse_' + node.tagName](node, attributes);
            }
        },
    });
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */

/* eslint-disable no-with */
// Must be defined in non strict context otherwise is invalid
function eval_pyson(value){
    with (Sao.PYSON.eval) {
        // Add parenthesis to parse as object instead of statement
        return eval('(' + value + ')');
    }
}
/* eslint-enable no-with */

(function() {
    'use strict';

    Sao.View.FormXMLViewParser = Sao.class_(Sao.View.XMLViewParser, {
        init: function(view, exclude_field, field_attrs) {
            Sao.View.FormXMLViewParser._super.init.call(
                this, view, exclude_field, field_attrs);
            this._containers = [];
            this._mnemonics = {};
        },
        get container() {
            if (this._containers.length > 0) {
                return this._containers[this._containers.length - 1];
            }
            return null;
        },
        _parse_form: function(node, attributes) {
            var container = new Sao.View.Form.Container(
                Number(node.getAttribute('col') || 4),
                attributes.col_widths);
            this.view.containers.push(container);
            this.parse_child(node, container);
            if (this._containers.length > 0) {
                throw 'AssertionError';
            }
            // Append after parsing child to minimize browser reflow
            this.view.el.append(container.el);
        },
        parse_child: function(node, container) {
            if (container) {
                this._containers.push(container);
            }
            for (const child of node.childNodes) {
                this.parse(child);
            }
            if (container) {
                if (container instanceof Sao.View.Form.Container) {
                    container.setup_grid_template();
                }
                this._containers.pop();
            }
        },
        _parse_field: function(node, attributes) {
            var name = attributes.name;
            if (name && (name == this.exclude_field)) {
                this.container.add(null, attributes);
                return;
            }

            if (attributes.loading == 'eager') {
                this.field_attrs[name].loading = 'eager';
            }

            var WidgetFactory = Sao.View.FormXMLViewParser.WIDGETS[
                attributes.widget];
            if (!WidgetFactory) {
                console.error('[FORM][UNKNOWN_WIDGET]', {
                    viewId: this.view.view_id,
                    model: this.view.screen && this.view.screen.model_name,
                    field: name,
                    widget: attributes.widget,
                    fieldAttributes: this.field_attrs[name],
                    attributes: attributes,
                    node: node.outerHTML,
                    availableWidgets: Object.keys(
                        Sao.View.FormXMLViewParser.WIDGETS).sort(),
                });
                attributes.widget = 'char';
                WidgetFactory = Sao.View.FormXMLViewParser.WIDGETS.char;
            }
            var widget = new WidgetFactory(this.view, attributes);
            if (!this.view.widgets[name]) {
                this.view.widgets[name] = [];
            }
            this.view.widgets[name].push(widget);
            widget.position = this.view.widget_id += 1;

            if (widget.expand) {
                if (attributes.yexpand === undefined) {
                    attributes.yexpand = true;
                }
                if (attributes.yfill === undefined) {
                    attributes.yfill = true;
                }
            }

            if (attributes.height !== undefined) {
                widget.el.css('min-height', attributes.height + 'px');
                if (widget.el.children().length == 1) {
                    widget.el.children().css('min-height', 'inherit');
                }
            }
            if (attributes.width !== undefined) {
                widget.el.css('min-width', attributes.width + 'px');
            }

            if (attributes.xalign === undefined) {
                if (attributes.xexpand) {
                    attributes.xalign = 0;
                } else {
                    attributes.xalign = 0.5;
                }
            }

            if (attributes.yalign === undefined) {
                if (attributes.yexpand) {
                    attributes.yalign = 0;
                } else {
                    attributes.yalign = 0.5;
                }
            }

            this.container.add(widget, attributes);

            if (this._mnemonics[name] && widget.labelled) {
                var label = this._mnemonics[name];
                var accesskey = Sao.common.accesskey(label.label_el.text());
                label.label_el.uniqueId();
                widget.labelled.uniqueId();
                widget.labelled.attr('aria-labelledby', label.el.attr('id'));
                widget.labelled.attr('accesskey', accesskey);
                if (~['INPUT', 'SELECT'].indexOf(
                    widget.labelled.get(0).tagName)) {
                    jQuery('<span/>', {
                        'data-accesskey': accesskey,
                    }).appendTo(widget.labelled.parent());
                }
                label.label_el.attr('for', widget.labelled.attr('id'));
            }
        },
        _parse_button: function(node, attributes) {
            var button = new Sao.common.Button(attributes);
            button.el.click(button, this.view.button_clicked.bind(this.view));
            this.view.state_widgets.push(button);
            this.container.add(button, attributes);
        },
        _parse_link: function(node, attributes) {
            var link = new Sao.View.Form.Link(attributes);
            this.view.state_widgets.push(link);
            this.container.add(link, attributes);
        },
        _parse_image: function(node, attributes) {
            var image = new Sao.View.Form.Image_(attributes);
            this.view.state_widgets.push(image);
            this.container.add(image, attributes);
        },
        _parse_separator: function(node, attributes) {
            var name = attributes.name;
            if (name && (name == this.exclude_field)) {
                this.container.add(null, attributes);
                return;
            }
            var text = attributes.string;
            var separator = new Sao.View.Form.Separator(text, attributes);
            if (text) {
                var xalign = attributes.xalign;
                if (xalign === undefined) {
                    xalign = 0;
                }
                if (xalign == 0.5) {
                    xalign = 'center';
                } else {
                    xalign = xalign <= 0.5? 'start' : 'end';
                }
                separator.label_el.css('text-align', xalign);
            }
            this.view.state_widgets.push(separator);
            this.container.add(separator, attributes);
            if (name) {
                this._mnemonics[name] = separator;
            }
        },
        _parse_label: function(node, attributes) {
            var name = attributes.name;
            if (name && (name == this.exclude_field)) {
                this.container.add(null, attributes);
                return;
            }
            if (attributes.xexpand === undefined) {
                attributes.xexpand = 0;
            }
            if (attributes.xalign === undefined) {
                attributes.xalign = 1.0;
            }
            if (attributes.yalign === undefined) {
                attributes.yalign = 0.5;
            }
            var label = new Sao.View.Form.Label(attributes.string, attributes);
            this.view.state_widgets.push(label);
            this.container.add(label, attributes);
            if (name) {
                this._mnemonics[name] = label;
            }
        },
        _parse_newline: function(node, attributes) {
            this.container.add_row();
        },
        _parse_notebook: function(node, attributes) {
            if (attributes.colspan === undefined) {
                attributes.colspan = 4;
            }
            var notebook = new Sao.View.Form.Notebook(attributes);
            if (attributes.height !== undefined) {
                notebook.el.css('min-height', attributes.height + 'px');
            }
            if (attributes.width !== undefined) {
                notebook.el.css('min-width', attributes.width + 'px');
            }
            this.view.state_widgets.push(notebook);
            this.view.notebooks.push(notebook);
            this.container.add(notebook, attributes);
            this.parse_child(node, notebook);
        },
        _parse_page: function(node, attributes) {
            if (attributes.name && (attributes.name == this.exclude_field)) {
                return;
            }
            var container = new Sao.View.Form.Container(
                Number(node.getAttribute('col') || 4),
                attributes.col_widths);
            this.view.containers.push(container);
            this.parse_child(node, container);
            var page = new Sao.View.Form.Page(
                this.container.add(
                    container.el, attributes.string, attributes.icon),
                attributes);
            this.view.state_widgets.push(page);
        },
        _parse_group: function(node, attributes) {
            var group = new Sao.View.Form.Container(
                Number(node.getAttribute('col') || 4),
                attributes.col_widths);
            if (attributes.panel == 'summary') {
                group.el.addClass('form-panel-summary-grid');
            } else if (attributes.panel == 'sidebar') {
                group.el.addClass('form-panel-sidebar-grid');
            } else if (attributes.panel == 'card') {
                group.el.addClass('form-panel-card-grid');
            }
            this.view.containers.push(group);
            this.parse_child(node, group);

            if (attributes.xalign === undefined) {
                attributes.xalign = 0.5;
            }
            if (attributes.yalign === undefined) {
                attributes.yalign = 0.5;
            }
            if (attributes.name && (attributes.name == this.exclude_field)) {
                this.container.add(null, attributes);
                return;
            }

            var widget;
            if (attributes.expandable !== undefined) {
                widget = new Sao.View.Form.Expander(attributes);
                widget.set_expanded(attributes.expandable === '1');
                this.view.expandables.push(widget);
            } else {
                widget = new Sao.View.Form.Group(attributes);
            }
            widget.add(group);

            this.view.state_widgets.push(widget);
            this.container.add(widget, attributes);
        },
        _parse_hpaned: function(node, attributes) {
            this._parse_paned(node, attributes, 'horizontal');
        },
        _parse_vpaned: function(node, attributes) {
            this._parse_paned(node, attributes, 'vertical');
        },
        _parse_paned: function(node, attributes, orientation) {
            var paned = new Sao.common.Paned(orientation);
            // TODO position
            this.container.add(paned, attributes);
            this.parse_child(node, paned);
        },
        _parse_child: function(node, attributes) {
            var paned = this.container;
            var container = new Sao.View.Form.Container(
                Number(node.getAttribute('col') || 4),
                attributes.col_widths);
            this.view.containers.push(container);
            this.parse_child(node, container);

            var child;
            if (!paned.get_child1().children().length) {
                child = paned.get_child1();
            } else {
                child = paned.get_child2();
            }
            child.append(container.el);
        },
    });

    Sao.View.Form = Sao.class_(Sao.View, {
        editable: true,
        creatable: true,
        view_type: 'form',
        xml_parser: Sao.View.FormXMLViewParser,
        init: function(view_id, screen, xml) {
            this.el = jQuery('<div/>', {
                'class': 'form'
            });
            this.notebooks = [];
            this.expandables = [];
            this.containers = [];
            this.widget_id = 0;
            Sao.View.Form._super.init.call(this, view_id, screen, xml);
            if (this.attributes.creatable) {
                this.creatable = Boolean(parseInt(this.attributes.creatable, 10));
            }
            if (this.attributes.scan_code) {
                this.scan_code_btn = new Sao.common.Button({
                    'string': Sao.i18n.gettext("Scan"),
                    'icon': 'tryton-barcode-scanner',
                    'states': this.attributes.scan_code_states,
                }, null, 'lg', 'btn-primary');
                this.scan_code_btn.el.click(() => {
                    new Sao.Window.CodeScanner(
                        this.on_scan_code.bind(this),
                        this.attributes.scan_code == 'loop');
                });
                this.el.append(jQuery('<div/>', {
                    'class': 'btn-code-scanner',
                }).append(this.scan_code_btn.el));
                this.state_widgets.push(this.scan_code_btn);
            }
        },
        get_fields: function() {
            return Object.keys(this.widgets);
        },
        get_buttons: function() {
            var buttons = [];
            for (var j in this.state_widgets) {
                var widget = this.state_widgets[j];
                if (widget instanceof Sao.common.Button) {
                    buttons.push(widget);
                }
            }
            return buttons;
        },
        display: function() {
            var record = this.record;
            var field;
            var promesses = [];
            if (this.scan_code_btn) {
                this.scan_code_btn.el.toggle(Boolean(record));
            }
            if (record) {
                // Force to set fields in record
                // Get first the lazy one from the view to reduce number of requests
                var field_names = new Set(this.get_fields());
                for (const name in record.model.fields) {
                    field = record.model.fields[name];
                    if (~field.views.has(this.view_id)) {
                        field_names.add(name);
                    }
                }

                var fields = [];
                for (const fname of field_names) {
                    field = record.model.fields[fname];
                    fields.push([
                        fname,
                        (field.description.loading || 'eager') == 'eager',
                        field.views.size,
                    ]);
                }
                fields.sort(function(a, b) {
                    if (!a[1] && b[1]) {
                        return -1;
                    } else if (a[1] && !b[1]) {
                        return 1;
                    } else {
                        return a[2] - b[2];
                    }
                });
                for (const e of fields) {
                    const name = e[0];
                    promesses.push(record.load(name));
                }
            }
            return jQuery.when.apply(jQuery,promesses)
                .done(() => {
                    var record = this.record;
                    for (const name in this.widgets) {
                        var widgets = this.widgets[name];
                        field = null;
                        if (record) {
                            field = record.model.fields[name];
                        }
                        if (field) {
                            field.set_state(record);
                        }
                        for (const widget of widgets) {
                            widget.display();
                        }
                    }
                    var promesses = [];
                    // We iterate in the reverse order so that the most nested
                    // widgets are computed first and set_state methods can rely
                    // on their children having their state set
                    for (const state_widget of this.state_widgets.toReversed()) {
                        var prm = state_widget.set_state(record);
                        if (prm) {
                            promesses.push(prm);
                        }
                    }
                    for (const container of this.containers) {
                        container.set_grid_template();
                    }
                    // re-set the grid templates for the StateWidget that are
                    // asynchronous
                    jQuery.when.apply(jQuery, promesses).done(() => {
                        for (const container of this.containers) {
                            container.set_grid_template();
                        }
                    });
                });
        },
        set_value: function() {
            var record = this.record;
            if (record) {
                for (var name in this.widgets) {
                    if (name in record.model.fields) {
                        var widgets = this.widgets[name];
                        var field = record.model.fields[name];
                        for (const widget of widgets) {
                            widget.set_value(record, field);
                        }
                    }
                }
            }
        },
        button_clicked: function(event) {
            var button = event.data;
            button.el.prop('disabled', true);  // state will be reset at display
            this.screen.button(button.attributes);
        },
        on_scan_code: function(code) {
            var record = this.record;
            if (record) {
                return record.on_scan_code(
                    code, this.attributes.scan_code_depends || []).done(() => {
                        if (this.attributes.scan_code == 'submit') {
                            this.el.parents('form').submit();
                        }
                    });
            } else {
                return jQuery.when();
            }
        },
        get selected_records() {
            if (this.record) {
                return [this.record];
            }
            return [];
        },
        get modified() {
            for (var name in this.widgets) {
                var widgets = this.widgets[name];
                for (const widget of widgets) {
                    if (widget.modified) {
                        return true;
                    }
                }
            }
            return false;
        },
        set_cursor: function(new_, reset_view) {
            var i, name, j;
            var focus_el, notebook, child, group;
            var widgets, error_el, pages, is_ancestor;

            var currently_focused = jQuery(document.activeElement);
            var has_focus = currently_focused.closest(this.el).length > 0;
            if (reset_view || !has_focus) {
                if (reset_view) {
                    for (i = 0; i < this.notebooks.length; i++) {
                        notebook = this.notebooks[i];
                        notebook.set_current_page();
                    }
                }
                if (this.attributes.cursor in this.widgets) {
                    focus_el = Sao.common.find_focusable_child(
                            this.widgets[this.attributes.cursor][0].el);
                } else {
                    child = Sao.common.find_focusable_child(this.el);
                    if (child) {
                        child.focus();
                    }
                }
            }

            var record = this.record;
            if (record) {
                var invalid_widgets = [];
                // We use the has-error class to find the invalid elements
                // because Sao.common.find_focusable_child use the :visible
                // selector which acts differently than GTK's get_visible
                var error_els = this.el.find('.has-error');
                var invalid_fields = record.invalid_fields();
                for (name in invalid_fields) {
                    widgets = this.widgets[name] || [];
                    for (i = 0; i < error_els.length; i++) {
                        error_el = jQuery(error_els[i]);
                        for (j = 0; j < widgets.length; j++) {
                            if (error_el.closest(widgets[j].el).length > 0) {
                                invalid_widgets.push(error_el);
                                break;
                            }
                        }
                    }
                }
                if (invalid_widgets.length > 0) {
                    focus_el = Sao.common.find_first_focus_widget(this.el,
                            invalid_widgets);
                }
            }

            if (focus_el) {
                for (i = 0; i < this.notebooks.length; i++) {
                    notebook = this.notebooks[i];
                    pages = notebook.get_n_pages();
                    for (j = 0; j < pages; j++) {
                        child = notebook.get_nth_page(j);
                        is_ancestor = (
                                jQuery(focus_el).closest(child).length > 0);
                        if (is_ancestor) {
                            notebook.set_current_page(j);
                            break;
                        }
                    }
                }
                for (i = 0; i < this.expandables.length; i++) {
                    group = this.expandables[i];
                    is_ancestor = (
                            jQuery(focus_el).closest(group.el).length > 0);
                    if (is_ancestor) {
                        group.set_expanded(true);
                    }
                }
                jQuery(focus_el).find('input,select,textarea')
                    .addBack(focus_el).focus();
            }
        }
    });

    Sao.View.Form.Container = Sao.class_(Object, {
        init: function(col=4, col_widths=null) {
            if (col < 0) col = 0;
            this.col = col;
            this.col_widths = this._parse_col_widths(col_widths);
            this.el = jQuery('<div/>', {
                'class': 'form-container'
            });
            this.el.data('sao-form-container', this);
            if (this.col <= 0) {
                this.el.addClass('form-hcontainer');
            } else if (this.col == 1) {
                this.el.addClass('form-vcontainer');
            }
            this._col = 1;
            this._row = 1;
            this._xexpand = new Set();
            this._colspans = [];
            this._yexpand = new Set();
            this._grid_cols = [];
            this._grid_rows = [];
        },
        _parse_col_widths: function(col_widths) {
            if (!col_widths) {
                return [];
            }
            return String(col_widths).split(',').map(width => {
                width = String(width || '').trim();
                if (!width) {
                    return null;
                }
                if (/^\d+(\.\d+)?$/.test(width)) {
                    return width + 'px';
                }
                return width;
            });
        },
        add_row: function() {
            this._col = 1;
            this._row += 1;
        },
        add: function(widget, attributes) {
            var colspan = attributes.colspan;
            if (colspan === undefined) colspan = 1;
            var xfill = attributes.xfill;
            if (xfill === undefined) xfill = 1;
            var xexpand = attributes.xexpand;
            if (xexpand === undefined) xexpand = 1;

            // CSS grid elements are 1-indexed
            if (this.col > 0) {
                if (colspan > this.col) {
                    colspan = this.col;
                }
                if ((this._col + colspan) > (this.col + 1)) {
                    this._col = 1;
                    this._row += 1;
                }
            }

            var el;
            if (widget) {
                el = widget.el;
            }
            var cell = jQuery('<div/>', {
                'class': 'form-item ' + (widget ? widget.class_ || '' : ''),
            }).append(el);
            cell.css('grid-column', `${this._col} / ${this._col + colspan}`);
            cell.css('grid-row', `${this._row} / ${this._row + 1}`);
            this.el.append(cell);

            if (!widget) {
                this._col += colspan;
                return;
            } else {
                if (xexpand && (colspan == 1)) {
                    this._xexpand.add(this._col);
                } else if (xexpand) {
                    var newspan = [];
                    for (var i=this._col; i < this._col + colspan; i++) {
                        newspan.push(i);
                    }
                    this._colspans.push(newspan);
                }
                if (attributes.yexpand) {
                    this._yexpand.add(this._row);
                }
                this._col += colspan;
            }

            if (attributes.xalign !== undefined) {
                var xalign;
                if (attributes.xalign == 0.5) {
                    xalign = 'center';
                } else {
                    xalign = attributes.xalign <= 0.5? 'start': 'end';
                }
                cell.addClass(`xalign-${xalign}`);
            } else {
                cell.addClass('xalign-start');
            }
            if (xexpand) {
                cell.addClass('xexpand');
            }
            if (xfill) {
                cell.addClass('xfill');
                if (xexpand) {
                    el.addClass('xexpand');
                }
            }

            if (attributes.yalign !== undefined) {
                var yalign;
                if (attributes.yalign == 0.5) {
                    yalign = 'center';
                } else {
                    yalign = attributes.yalign <= 0.5? 'start': 'end';
                }
                cell.addClass(`yalign-${yalign}`);
            }

            if (attributes.yfill) {
                cell.addClass('yfill');
                if (attributes.yexpand) {
                    el.addClass('yexpand');
                }
            }

            if (attributes.help) {
                widget.el.attr('title', attributes.help);
            }
        },
        setup_grid_template: function() {
            for (const span of this._colspans) {
                var found = false;
                for (const col of span) {
                    if (this._xexpand.has(col)) {
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    this._xexpand.add(
                        Math.round((span[0] + span[span.length - 1]) / 2));
                }
            }

            var i;
            var col = this.col <= 0 ? this._col : this.col;
            if (this.col_widths.length) {
                for (i = 1; i <= col; i++) {
                    this._grid_cols.push(
                        this.col_widths[i - 1] ||
                        (this._xexpand.has(i) ?
                            `minmax(min-content, ${col}fr)` :
                            'min-content'));
                }
            } else if (this._xexpand.size) {
                for (i = 1; i <= col; i++) {
                    if (this._xexpand.has(i)) {
                        this._grid_cols.push(`minmax(min-content, ${col}fr)`);
                    } else {
                        this._grid_cols.push('min-content');
                    }
                }
            } else {
                for (i = 1; i <= col; i++) {
                    this._grid_cols.push("min-content");
                }
            }

            if (this._yexpand.size) {
                for (i = 1; i <= this._row; i++) {
                    if (this._yexpand.has(i)) {
                        this._grid_rows.push(
                            `minmax(min-content, ${this._row}fr)`);
                    } else {
                        this._grid_rows.push('min-content');
                    }
                }
            } else {
                for (i = 1; i <= this._row; i++) {
                    this._grid_rows.push("min-content");
                }
            }
        },
        set_grid_template: function() {
            var i;
            var grid_cols = this._grid_cols.slice();
            var grid_rows = this._grid_rows.slice();
            var cols = [];
            var rows = [];
            for (i = 0; i < grid_cols.length; i++) {
                cols.push([]);
            }
            for (i = 0; i < grid_rows.length; i++) {
                rows.push([]);
            }
            var col_start, col_end, row_start, row_end;
            for (var child of this.el.children()) {
                child = jQuery(child);
                col_start = parseInt(
                    child.css('grid-column-start'), 10);
                col_end = parseInt(child.css('grid-column-end'), 10);
                row_start = parseInt(child.css('grid-row-start'), 10);
                row_end = parseInt(child.css('grid-row-end'), 10);

                for (i = col_start; i < col_end; i++) {
                    cols[i - 1].push(child);
                }
                for (i = row_start; i < row_end; i++) {
                    rows[i - 1].push(child);
                }
            }
            var row, col;
            var is_empty = function(e) {
                var empty = true;
                for (const child of e.children(':not(.tooltip)')) {
                    if (jQuery(child).css('display') != 'none') {
                        empty = false;
                        break;
                    }
                }
                e.toggleClass('form-empty', empty);
                return empty;
            };
            for (i = 0; i < grid_cols.length; i++) {
                col = cols[i];
                if (col.every(is_empty)) {
                    grid_cols[i] = "0px";
                }
            }
            for (i = 0; i < grid_rows.length; i++) {
                row = rows[i];
                if (row.every(is_empty)) {
                    grid_rows[i] = "0px";
                } else if (row.every(child =>
                        child.hasClass('htmlviewer-collapsed-item'))) {
                    grid_rows[i] = "min-content";
                }
            }
            this.el.css(
                'grid-template-columns', grid_cols.join(" "));
            this.el.css(
                'grid-template-rows', grid_rows.join(" "));
        }
    });

    Sao.View.Form.StateWidget = Sao.class_(Object, {
        init: function(attributes) {
            this.attributes = attributes;
        },
        set_state: function(record) {
            var state_changes;
            if (record) {
                state_changes = record.expr_eval(this.attributes.states || {});
            } else {
                state_changes = {};
            }
            var invisible = state_changes.invisible;
            if (invisible === undefined) {
                invisible = this.attributes.invisible;
            }
            if (invisible) {
                this.hide();
            } else {
                this.show();
            }
        },
        show: function() {
            this.el.show();
        },
        hide: function() {
            this.el.hide();
        }
    });

    Sao.View.Form.LabelMixin = Sao.class_(Sao.View.Form.StateWidget, {
        set_state: function(record) {
            Sao.View.Form.LabelMixin._super.set_state.call(this, record);
            var field;
            if (this.attributes.name && record) {
                field = record.model.fields[this.attributes.name];
            }
            if (!((this.attributes.string === undefined) ||
                this.attributes.string)) {
                var text = '';
                if (field && record) {
                    text = field.get_client(record) || '';
                }
                this.label_el.text(text);
            }
            var state_changes;
            if (record) {
                state_changes = record.expr_eval(this.attributes.states || {});
            } else {
                state_changes = {};
            }
            if (state_changes.readonly === undefined) {
                state_changes.readonly = !field;
            }
            Sao.common.apply_label_attributes(
                    this.label_el,
                    ((field && field.description.readonly) ||
                     state_changes.readonly),
                    ((field && field.description.required) ||
                     state_changes.required));
        }
    });

    Sao.View.Form.Separator = Sao.class_(Sao.View.Form.LabelMixin, {
        init: function(text, attributes) {
            Sao.View.Form.Separator._super.init.call(this, attributes);
            this.el = jQuery('<div/>', {
                'class': 'form-separator'
            });
            this.label_el = jQuery('<label/>');
            if (text) {
                this.label_el.text(text);
            }
            this.el.append(this.label_el);
            this.el.append(jQuery('<hr/>'));
        }
    });

    Sao.View.Form.Label = Sao.class_(Sao.View.Form.LabelMixin, {
        class_: 'form-label',
        init: function(text, attributes) {
            Sao.View.Form.Label._super.init.call(this, attributes);
            this.el = this.label_el = jQuery('<label/>', {
                text: text,
                'class': this.class_
            });
        }
    });

    Sao.View.Form.Notebook = Sao.class_(Sao.View.Form.StateWidget, {
        class_: 'form-notebook',
        init: function(attributes) {
            Sao.View.Form.Notebook._super.init.call(this, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.nav = jQuery('<ul/>', {
                'class': 'nav nav-tabs',
                role: 'tablist'
            }).appendTo(this.el);
            this.panes = jQuery('<div/>', {
                'class': 'tab-content'
            }).appendTo(this.el);
            this.selected = false;
        },
        add: function(tab, text, icon) {
            var pane = jQuery('<div/>', {
                'role': 'tabpanel',
                'class': 'tab-pane',
            }).uniqueId();
            var tab_id = pane.attr('id');
            var img = Sao.common.ICONFACTORY.get_icon_img(icon);
            var page = jQuery('<li/>', {
                'role': 'presentation'
            }).append(
                jQuery('<a/>', {
                    'aria-controls': tab_id,
                    'role': 'tab',
                    'data-toggle': 'tab',
                    'href': '#' + tab_id
                })
                .text(text)
                .prepend(img))
                .appendTo(this.nav);
            pane.append(tab).appendTo(this.panes);
            if (!this.selected) {
                // Can not use .tab('show')
                page.addClass('active');
                pane.addClass('active');
                this.selected = true;
            }
            return page;
        },
        set_current_page: function(page_index=null) {
            var selector;
            if (page_index === null) {
                selector = ':visible:first';
            } else {
                selector = ':eq(' + page_index + '):visible';
            }
            var tab = this.nav.find('li' + selector + ' a');
            tab.tab('show');
        },
        get_n_pages: function() {
            return this.nav.find("li[role='presentation']").length;
        },
        get_nth_page: function(page_index) {
            return jQuery(this.panes.find("div[role='tabpanel']")[page_index]);
        },
        set_state: function(record) {
            Sao.View.Form.Notebook._super.set_state.call(this, record);

            var n_pages = this.get_n_pages();
            if (n_pages > 0) {
                var to_collapse = true;
                for (let i = 0; i < n_pages; i++) {
                    var page = this.get_nth_page(i);
                    if (page.css('display') != 'none') {
                        to_collapse = false;
                        break;
                    }
                }
                if (to_collapse) {
                    this.hide();
                }
            } else {
                this.hide();
            }
        }
    });

    Sao.View.Form.Page = Sao.class_(Sao.View.Form.StateWidget, {
        init: function(el, attributes) {
            Sao.View.Form.Page._super.init.call(this, attributes);
            this.el = el;
        },
        hide: function() {
            Sao.View.Form.Page._super.hide.call(this);
            if (this.el.hasClass('active')) {
                window.setTimeout(() => {
                    if (this.el.hasClass('active') && this.el.is(':hidden')) {
                        this.el.siblings(':visible').first().find('a').tab('show');
                    }
                });
            }
        }
    });

    Sao.View.Form.Group = Sao.class_(Sao.View.Form.StateWidget, {
        class_: 'form-group_',
        init: function(attributes) {
            Sao.View.Form.Group._super.init.call(this, attributes);
            this.el = jQuery('<fieldset/>', {
                'class': this.class_
            });
            this._apply_panel_attributes(attributes);
            if (attributes.string) {
                var legend = jQuery('<legend/>');
                if (attributes.icon) {
                    legend.append(Sao.common.ICONFACTORY.get_icon_img(
                        attributes.icon, {
                        'class': 'form-panel-icon',
                        'aria-hidden': true,
                    }));
                }
                legend.append(jQuery('<span/>').text(attributes.string));
                this.el.append(legend);
            }
        },
        _apply_panel_attributes: function(attributes) {
            var panel = attributes.panel || null;
            if (!panel) {
                return;
            }
            if (['card', 'summary', 'sidebar', 'plain'].indexOf(panel) < 0) {
                panel = 'card';
            }
            this.el.addClass('form-panel form-panel-' + panel);
            if (attributes.sticky == '1' || panel == 'sidebar') {
                this.el.addClass('form-panel-sticky');
            }
        },
        add: function(widget) {
            this.el.append(widget.el);
        },
        set_state: function(record) {
            Sao.View.Form.Group._super.set_state.call(this, record);

            var to_collapse = false;
            if (!this.attributes.string) {
                to_collapse = true;
                var children = this.el
                    .find('> .form-container > .form-item')
                    .children(':not(.tooltip)');
                for (const child of children) {
                    if (jQuery(child).css('display') != 'none') {
                        to_collapse = false;
                        break;
                    }
                }
            }
            if (to_collapse) {
                this.hide();
            }
        }
    });

    Sao.View.Form.Expander = Sao.class_(Sao.View.Form.StateWidget, {
        class_: 'form-group-expandable',
        init: function(attributes) {
            Sao.View.Form.Expander._super.init.call(this, attributes);
            this.el = jQuery('<div/>', {
                'class': 'panel panel-default ' + this.class_
            });
            var heading = jQuery('<div/>', {
                'class': 'panel-heading',
            }).appendTo(this.el);
            heading.uniqueId();

            this.collapsible = jQuery('<div/>', {
                'class': 'panel-collapse collapse',
                'aria-labelledby': heading.attr('id'),
            }).appendTo(this.el);
            this.collapsible.uniqueId();
            this.body = jQuery('<div/>', {
                'class': 'panel-body',
            }).appendTo(this.collapsible);

            var title = jQuery('<label/>', {
                'class': 'panel-title',
            }).appendTo(heading);
            var link = jQuery('<a/>', {
                'role': 'button',
                'data-toggle': 'collapse',
                'href': '#' + this.collapsible.attr('id'),
                'aria-controls': this.collapsible.attr('id'),
                'aria-expanded': attributes.expandable == '1',
            }).appendTo(title);
            if (attributes.string) {
                link.text(attributes.string);
            }
            link.append(jQuery('<span/>', {
                'class': 'caret',
            }));
        },
        add: function(widget) {
            this.body.empty();
            this.body.append(widget.el);
        },
        set_expanded: function(expanded) {
            if (expanded) {
                this.collapsible.collapse('show');
            } else {
                this.collapsible.collapse('hide');
            }
        }
    });

    Sao.View.Form.Link = Sao.class_(Sao.View.Form.StateWidget, {
        class_: 'form-link',
        init: function(attributes) {
            Sao.View.Form.Link._super.init.call(this, attributes);
            this.el = jQuery('<button/>', {
                'class': this.class_ + ' btn btn-link',
                'name': attributes.name,
                'type': 'button',
            });
            if (attributes.icon) {
                var img = jQuery('<img/>', {
                    'class': 'icon',
                }).prependTo(this.el);
                Sao.common.ICONFACTORY.get_icon_url(attributes.icon)
                    .done(function(url) {
                        img.attr('src', url);
                    });
            }
            this.label = jQuery('<div/>').appendTo(this.el);
            this._current = null;
        },
        get action_id() {
            return parseInt(this.attributes.id, 10);
        },
        set_state: function(record) {
            Sao.View.Form.Link._super.set_state.call(this, record);
            if (this.el.css('display') == 'none') {
                return;
            }
            var data = {},
                context = {},
                pyson_ctx = {};
            if (record) {
                if (record.id < 0) {
                    this.hide();
                    return;
                }
                data = {
                    model: record.model.name,
                    id: record.id,
                    ids: [record.id],
                };
                context = record.get_context();
                pyson_ctx = {
                    active_model: record.model.name,
                    active_id: record.id,
                    active_ids: [record.id],
                };
                this._current = record.id;
            } else {
                this._current = null;
            }
            pyson_ctx.context = context;
            this.el.off('click');
            this.el.click([data, context], this.clicked.bind(this));
            var action = Sao.rpc({
                'method': 'model.ir.action.get_action_value',
                'params': [this.action_id, context],
            }, Sao.Session.current_session, false);
            this.label.text(action.name);
            this.el.attr('title', action.name);

            var decoder = new Sao.PYSON.Decoder(pyson_ctx);
            var domain = decoder.decode(action.pyson_domain);
            if (action.pyson_search_value) {
                domain = [domain, decoder.decode(action.pyson_search_value)];
            }
            var tab_domains = action.domains
                .filter(function(d) {
                    return d[2];
                }).map(function(d) {
                    var name = d[0],
                        domain = d[1];
                    return [name, decoder.decode(domain)];
                });
            const promesses = [];
            var counter;
            if (record && record.links_counts[this.action_id]) {
                counter = record.links_counts[this.action_id];
                this.set_label(action.name, tab_domains, counter);
            } else {
                if (tab_domains.length) {
                    counter = tab_domains.map(function() {
                        return 0;
                    });
                } else {
                    counter = [0];
                }
                if (record) {
                    record.links_counts[this.action_id] = counter;
                }
                var current = this._current;
                if (tab_domains.length) {
                    tab_domains.map(function(d, i) {
                        var tab_domain = d[1];
                        const prm = Sao.rpc({
                            'method': (
                                'model.' + action.res_model + '.search_count'),
                            'params': [
                                ['AND', domain, tab_domain], 0, 100, context],
                        }, Sao.Session.current_session, true, false).then(
                            value => {
                                this._set_count(
                                    value, i, current, counter,
                                    action.name, tab_domains);
                        });
                        promesses.push(prm);
                    }, this);
                } else {
                    const prm = Sao.rpc({
                        'method': (
                            'model.' + action.res_model + '.search_count'),
                        'params': [domain, 0, 100, context],
                    }, Sao.Session.current_session, true, false
                    ).then(value => {
                        this._set_count(
                            value, 0, current, counter,
                            action.name, tab_domains);
                    });
                    promesses.push(prm);
                }
            }
            return jQuery.when.apply(jQuery, promesses);
        },
        _set_count: function(value, idx, current, counter, name, domains) {
            if (current != this._current) {
                return;
            }
            if (value > 99) {
                value = '99+';
            }
            counter[idx] = value;
            this.set_label(name, domains, counter);
        },
        set_label: function(name, domains, counter) {
            this.label.text(name);
            this.el.attr('accesskey', Sao.common.accesskey(name));
            if (domains.length) {
                domains.map(function(d, i) {
                    var name = d[0];
                    this.label.append(jQuery('<br/>'));
                    this.label.append(name + ' ');
                    jQuery('<span/>', {
                        'class': 'badge',
                    }).text(counter[i]).appendTo(this.label);
                }, this);
            } else {
                this.label.append(' ');
                jQuery('<span/>', {
                    'class': 'badge',
                }).text(counter[0]).appendTo(this.label);
            }
            if (this.attributes.empty === 'hide') {
                var non_empty = counter.filter(function(number) {
                    return number != 0;
                });
                if (non_empty.length) {
                    this.el.show();
                } else {
                    this.el.hide();
                }
            }
        },
        clicked: function(evt) {
            Sao.Action.execute(this.action_id, evt.data[0], evt.data[1], true);
        },
    });

    Sao.View.Form.Image_ = Sao.class_(Sao.View.Form.StateWidget, {
        class_: 'form-image_',
        init: function(attributes) {
            Sao.View.Form.Image_._super.init.call(this, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.img = jQuery('<img/>', {
                'class': 'center-block',
                'width': (attributes.size || 48) + 'px',
                'height': (attributes.size || 48) + 'px',
            }).appendTo(this.el);
            switch (attributes.border) {
                case 'rounded':
                    this.img.addClass('img-rounded');
                    break;
                case 'circle':
                    this.img.addClass('img-circle');
                    break;
                default:
                    break;
            }
        },
        set_state: function(record) {
            Sao.View.Form.Image_._super.set_state.call(this, record);
            if (!record) {
                return;
            }
            var name = this.attributes.name;
            if (name in record.model.fields) {
                var field = record.model.fields[name];
                name = field.get(record);
            }
            if (this.attributes.type == 'url') {
                if (name) {
                    if (this.attributes.url_size) {
                        var url = new URL(name, window.location);
                        url.searchParams.set(
                            this.attributes.url_size,
                            this.attributes.size || 48);
                        name = url.href;
                    }
                    this.img.attr('src', name);
                } else {
                    this.img.removeAttr('src');
                }
            } else {
                Sao.common.ICONFACTORY.get_icon_url(name)
                    .done(url => {
                        if (url) {
                            this.img.attr('src', url);
                        } else {
                            this.img.removeAttr('src');
                        }
                    });
            }
        }
    });

    Sao.View.Form.Widget = Sao.class_(Object, {
        expand: false,
        init: function(view, attributes) {
            this.view = view;
            this.attributes = attributes;
            this.el = null;
            this.position = 0;
            this.visible = true;
            this.labelled = null;  // Element which received the labelledby
        },
        display: function() {
            var field = this.field;
            var record = this.record;
            var readonly = this.attributes.readonly;
            var invisible = this.attributes.invisible;
            var required = this.attributes.required;
            if (!field) {
                if (readonly === undefined) {
                    readonly = true;
                }
                if (invisible === undefined) {
                    invisible = false;
                }
                if (required === undefined) {
                    required = false;
                }
                this.set_readonly(readonly);
                this.set_invisible(invisible);
                this.set_required(required);
                return;
            }
            var state_attrs = field.get_state_attrs(record);
            if (readonly === undefined) {
                readonly = state_attrs.readonly;
                if (readonly === undefined) {
                    readonly = false;
                }
            }
            if (required === undefined) {
                required = state_attrs.required;
                if (required === undefined) {
                    required = false;
                }
            }
            if (this.view.screen.attributes.readonly) {
                readonly = true;
            }
            this.set_readonly(readonly);
            if (readonly) {
                this.el.addClass('readonly');
            } else {
                this.el.removeClass('readonly');
            }
            var required_el = this._required_el();
            this.set_required(required);
            if (!readonly && required) {
                required_el.addClass('required');
            } else {
                required_el.removeClass('required');
            }
            var invalid = state_attrs.invalid;
            var invalid_el = this._invalid_el();
            if (!readonly && invalid) {
                invalid_el.addClass('has-error');
            } else {
                invalid_el.removeClass('has-error');
            }
            if (invisible === undefined) {
                invisible = field.get_state_attrs(this.record).invisible;
                if (invisible === undefined) {
                    invisible = false;
                }
            }
            this.set_invisible(invisible);
        },
        _required_el: function () {
            return this.el;
        },
        _invalid_el: function() {
            return this.el;
        },
        get field_name() {
            return this.attributes.name;
        },
        get model_name() {
            return this.view.screen.model_name;
        },
        get model() {
            return this.view.screen.model;
        },
        get record() {
            return this.view.record;
        },
        get field() {
            var record = this.record;
            if (record) {
                return record.model.fields[this.field_name];
            } else {
                return null;
            }
        },
        focus_out: function() {
            if (!this.field) {
                return;
            }
            if (!this.visible) {
                return;
            }
            this.set_value();
        },
        get_value: function() {
        },
        set_value: function() {
        },
        set_readonly: function(readonly) {
            this._readonly = readonly;
        },
        set_required: function(required) {
        },
        get modified() {
            return false;
        },
        send_modified: function() {
            window.setTimeout(() => {
                var value = this.get_value();
                window.setTimeout(() => {
                    if (this.record &&
                        (this.get_value() == value) &&
                        this.modified) {
                        this.view.screen.record_modified(false);
                    }
                }, 300);
            });
        },
        set_invisible: function(invisible) {
            this.visible = !invisible;
            if (invisible) {
                this.el.hide();
            } else {
                this.el.show();
            }
        },
        focus: function() {
            this.el.focus();
        },
    });

    Sao.View.Form.TranslateDialog = Sao.class_(Object,  {
        class_: 'form',
        init: function(languages, widget) {
            var dialog = new Sao.Dialog(
                Sao.i18n.gettext('Translate'), this.class_, 'md');
            this.languages = languages;
            this.read(widget, dialog);
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.close(dialog);
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'button',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(this.write
                    .bind(this, widget, dialog))
                    .appendTo(dialog.footer);
            dialog.content.submit(function(evt) {
                evt.preventDefault();
                dialog.footer.find('button.btn-primary').first().click();
            });
            dialog.modal.modal('show');
            dialog.modal.on('shown.bs.modal', function() {
                dialog.modal.find('input,select')
                    .filter(':visible').first().focus();
            });
            dialog.modal.on('hide.bs.modal', function(){
                jQuery(this).remove();
            });
        },
        close: function(dialog) {
            dialog.modal.modal('hide');
        },
        read: function(widget, dialog) {
            function field_value(result) {
                return result[0][widget.field_name] || '';
            }
            dialog.content.addClass('form-horizontal');
            this.languages.forEach(lang => {
                var row = jQuery('<div/>', {
                    'class':'form-group'
                });
                var input = widget.translate_widget();
                input.attr('data-lang-id', lang.id);
                var edit = jQuery('<button/>', {
                    'type': 'button',
                    'class': 'btn btn-default',
                }).text(Sao.i18n.gettext('Edit'));
                if (widget._readonly) {
                    edit.attr('disabled', true);
                }
                var fuzzy_label = jQuery('<span>', {
                    'class': 'label',
                });
                var prm1 = Sao.rpc({
                    'method': 'model.' + widget.model.name  + '.read',
                    'params': [
                        [widget.record.id],
                        [widget.field_name],
                        {language: lang.code},
                    ],
                }, widget.model.session).then(field_value);
                var prm2 = Sao.rpc({
                    'method': 'model.' + widget.model.name  + '.read',
                    'params': [
                        [widget.record.id],
                        [widget.field_name],
                        {
                            language: lang.code,
                            fuzzy_translation: true,
                        },
                    ],
                }, widget.model.session).then(field_value);

                jQuery.when(prm1, prm2).done(function(value, fuzzy_value) {
                    widget.translate_widget_set(input, fuzzy_value);
                    widget.translate_widget_set_readonly( input, true);
                    if (value !== fuzzy_value) {
                        fuzzy_label.addClass('label-warning');
                        fuzzy_label.text(Sao.i18n.gettext("Fuzzy"));
                    }
                });
                edit.click(function() {
                    jQuery(this).toggleClass('active');
                    widget.translate_widget_set_readonly(
                        input, !jQuery(this).hasClass('active'));
                });
                dialog.body.append(row);
                input.uniqueId();
                row.append(jQuery('<label/>', {
                    'for': input.attr('id'),
                    'class': 'col-sm-3 control-label',
                }).append(' ' + lang.name));
                row.append(jQuery('<div/>', {
                    'class': 'col-sm-9 input-group',
                }).append(input)
                    .append(jQuery('<span/>', {
                        'class': 'input-group-addon',
                    }).append(edit).append(fuzzy_label)));
            });
        },
        write: function(widget, dialog) {
            for (const lang of this.languages) {
                var input = jQuery('[data-lang-id=' + lang.id + ']');
                if (!input.attr('readonly')) {
                    var context = {};
                    context.language = lang.code;
                    context.fuzzy_translation = false;
                    var values =  {};
                    values[widget.field_name] = widget.translate_widget_get(input);
                    var params = [
                        [widget.record.id],
                        values,
                        context
                    ];
                    var args = {
                        'method': 'model.' + widget.model.name  + '.write',
                        'params': params
                    };
                    Sao.rpc(args, widget.model.session, false);
                }
            }
            widget.record.cancel();
            widget.view.display();
            this.close(dialog);
        }
    });

    Sao.View.Form.TranslateMixin = {};
    Sao.View.Form.TranslateMixin.init = function() {
        if (!this.translate) {
            this.translate = Sao.View.Form.TranslateMixin.translate.bind(this);
        }
        if (!this.translate_dialog) {
            this.translate_dialog =
                Sao.View.Form.TranslateMixin.translate_dialog.bind(this);
        }
        if (!this.translate_widget_set_readonly) {
            this.translate_widget_set_readonly =
                Sao.View.Form.TranslateMixin.translate_widget_set_readonly
                    .bind(this);
        }
        if (!this.translate_widget_set) {
            this.translate_widget_set =
                Sao.View.Form.TranslateMixin.translate_widget_set.bind(this);
        }
        if (!this.translate_widget_get) {
            this.translate_widget_get =
                Sao.View.Form.TranslateMixin.translate_widget_get.bind(this);
        }
    };
    Sao.View.Form.TranslateMixin.translate = function() {
        if (this.record.id < 0 || this.record.modified) {
            var mg = Sao.i18n.gettext(
                'You need to save the record before adding translations.');
            Sao.common.message.run(mg);
            return;
        }
        var session = this.model.session;
        var params = [
            [['translatable', '=', true]]
        ];
        var args = {
            'method': 'model.ir.lang.search',
            'params': params.concat({})
        };
        Sao.rpc(args, session).then(lang_ids => {
            if (jQuery.isEmptyObject(lang_ids)) {
                Sao.common.message.run(Sao.i18n.gettext(
                        'No other language available.'));
                return;
            }
            var params = [
                lang_ids,
                ['code', 'name']
            ];
            var args = {
                'method': 'model.ir.lang.read',
                'params': params.concat({})
            };
            Sao.rpc(args, session).then(languages => {
                this.translate_dialog(languages);
            });
        });
    };
    Sao.View.Form.TranslateMixin.translate_dialog = function(languages) {
        new Sao.View.Form.TranslateDialog(languages, this);
    };
    Sao.View.Form.TranslateMixin.translate_widget_set_readonly =
            function(el, value) {
        el.prop('readonly', value);
    };
    Sao.View.Form.TranslateMixin.translate_widget_set = function(el, value) {
        el.val(value);
    };
    Sao.View.Form.TranslateMixin.translate_widget_get = function(el) {
        return el.val();
    };

    Sao.View.Form.Char = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-char',
        init: function(view, attributes) {
            Sao.View.Form.Char._super.init.call(this, view, attributes);
            Sao.View.Form.TranslateMixin.init.call(this);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(this.el);
            this.input = this.labelled = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(this.group);
            if (!jQuery.isEmptyObject(attributes.autocomplete)) {
                this.datalist = jQuery('<datalist/>').appendTo(this.el);
                this.datalist.uniqueId();
                this.input.attr('list', this.datalist.attr('id'));
                // workaround for
                // https://bugzilla.mozilla.org/show_bug.cgi?id=1474137
                this.input.attr('autocomplete', 'off');
            }
            this.el.change(this.focus_out.bind(this));
            this.el.on('keydown', this.send_modified.bind(this));

            if (!attributes.size) {
                this.group.css('width', '100%');
            }
            if (this.attributes.translate) {
                Sao.common.ICONFACTORY.get_icon_img('tryton-translate')
                    .appendTo(jQuery('<div/>', {
                        'class': 'icon-input icon-secondary',
                        'aria-label': Sao.i18n.gettext('Translate'),
                        'title': Sao.i18n.gettext('Translate'),
                    }).appendTo(
                        this.group.addClass('input-icon input-icon-secondary')))
                .click(this.translate.bind(this));
            }
        },
        get_client_value: function() {
            var field = this.field;
            var record = this.record;
            var value = '';
            if (field) {
                value = field.get_client(record);
            }
            return value;
        },
        display: function() {
            Sao.View.Form.Char._super.display.call(this);

            var record = this.record;
            if (this.datalist) {
                this.datalist.empty();
                var selection;
                if (record) {
                    if (!(this.field_name in record.autocompletion)) {
                        record.do_autocomplete(this.field_name);
                    }
                    selection = record.autocompletion[this.field_name] || [];
                } else {
                    selection = [];
                }
                for (const e of selection) {
                    jQuery('<option/>', {
                        'value': e
                    }).appendTo(this.datalist);
                }
            }

            // Set size
            var length = '';
            var width = '100%';
            if (record) {
                length = record.expr_eval(this.attributes.size);
                if (length > 0) {
                    width = (length + 5) + 'ch';
                }
            }
            this.input.val(this.get_client_value());
            this.input.attr('maxlength', length);
            this.input.attr('size', length);
            this.group.css('width', width);
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.get_client_value();
                return value != this.get_value();
            }
            return false;
        },
        set_value: function() {
            this.field.set_client(this.record, this.input.val());
        },
        get_value: function() {
            return this.input.val();
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Char._super.set_readonly.call(this, readonly);
            this.input.prop('readonly', readonly);
        },
        focus: function() {
            this.input.focus();
        },
        translate_widget: function() {
            return jQuery('<input/>', {
                'class': 'form-control',
                'readonly': 'readonly',
                'name': this.attributes.name,
            });
        }
    });

    Sao.View.Form.Password = Sao.class_(Sao.View.Form.Char, {
        class_: 'form-password',
        init: function(view, attributes) {
            Sao.View.Form.Password._super.init.call(this, view, attributes);
            this.input.prop('type', 'password');
            this.button = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm form-control',
                'type': 'button'
            }).appendTo(jQuery('<span/>', {
                'class': 'input-group-btn'
            }).appendTo(this.group));
            this._set_password_label();
            this.button.click(this.toggle_visibility.bind(this));

        },
        toggle_visibility: function() {
            if (this.input.prop('type') == 'password') {
                this.input.prop('type', 'text');
                this.input.attr('autocomplete', 'off');
            } else {
                this.input.prop('type', 'password');
                this.input.removeAttr('autocomplete');
            }
            this._set_password_label();
        },
        _set_password_label: function() {
            if (this.input.prop('type') == 'password') {
                this.button.text(Sao.i18n.gettext('Show'));
                this.button.attr('title', Sao.i18n.gettext("Show"));
            } else {
                this.button.text(Sao.i18n.gettext('Hide'));
                this.button.attr('title', Sao.i18n.gettext("Hide"));
            }
        }
    });

    Sao.View.Form.Date = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-date',
        _input: 'date',
        _input_format: '%Y-%m-%d',
        _format: Sao.common.format_date,
        _parse: Sao.common.parse_date,
        init: function(view, attributes) {
            Sao.View.Form.Date._super.init.call(this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var group = this.labelled = jQuery('<div/>', {
                'class': ('input-group input-group-sm ' +
                    'input-icon input-icon-secondary'),
            }).appendTo(this.el);
            this.date = this.labelled = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(group);
            this.date.uniqueId();
            this.date.on('keydown', this.send_modified.bind(this));
            this.input = jQuery('<input/>', {
                'type': this._input,
                'role': 'button',
                'tabindex': -1,
            });
            this.input.click(() => {
                var value = this.get_value();
                value = this._format(this._input_format, value);
                this.input.val(value);
            });
            this.input.change(() => {
                var value = this.input.val();
                if (value) {
                    value = this._parse(this._input_format, value);
                    value = this._format(this.get_format(), value);
                    this.date.val(value).change();
                    if (!~navigator.userAgent.indexOf("Firefox")) {
                        // Firefox triggers change when navigate by month/year
                        this.date.focus();
                    }
                }
                this.send_modified();
            });
            if (this.input[0].type == this._input) {
                this.icon = jQuery('<div/>', {
                    'class': 'icon-input icon-secondary',
                    'aria-label': Sao.i18n.gettext("Open the calendar"),
                    'title': Sao.i18n.gettext("Open the calendar"),
                    'tabindex': -1,
                }).appendTo(group);
                this.input.appendTo(this.icon);
                Sao.common.ICONFACTORY.get_icon_img('tryton-date')
                    .appendTo(this.icon);
                this.icon.on('click', (event_) => {
                    if (this.date.prop('readonly')) {
                        return;
                    }
                    if (event_.target == this.input[0]) {
                        return;
                    }
                    event_.preventDefault();
                    event_.stopPropagation();
                    if (this.input[0].showPicker) {
                        this.input[0].showPicker();
                    } else {
                        this.input.trigger('click');
                    }
                });
            }
            this.date.change(this.focus_out.bind(this));
            var mousetrap = new Mousetrap(this.date[0]);

            mousetrap.bind('enter', (e, combo) => {
                if (!this.date.prop('readonly')) {
                    this.focus_out();
                }
            });
            mousetrap.bind('=', (e, combo) => {
                if (!this.date.prop('readonly')) {
                    e.preventDefault();
                    this.date.val(this._format(this.get_format(), moment()))
                        .change();
                }
            });

            Sao.common.DATE_OPERATORS.forEach(operator => {
                mousetrap.bind(operator[0], (e, combo) => {
                    if (this.date.prop('readonly')) {
                        return;
                    }
                    e.preventDefault();
                    var date = this.get_value() || Sao.DateTime();
                    date.add(operator[1]);
                    this.date.val(this._format(this.get_format(), date))
                        .change();
                });
            });
        },
        get_format: function() {
            if (this.field && this.record) {
                return this.field.date_format(this.record);
            } else {
                return Sao.common.date_format(
                    this.view.screen.context.date_format);
            }
        },
        get_value: function() {
            return this._parse(this.get_format(), this.date.val());
        },
        display: function() {
            var record = this.record;
            var field = this.field;
            Sao.View.Form.Date._super.display.call(this);
            var value;
            if (record) {
                value = field.get_client(record);
            }
            this.date.val(this._format(this.get_format(), value));
        },
        focus: function() {
            this.date.focus();
        },
        get modified() {
            if (this.record && this.field) {
                var field_value = this.cast(
                    this.field.get_client(this.record));
                return (JSON.stringify(field_value) !=
                    JSON.stringify(this.get_value()));
            }
            return false;
        },
        set_value: function() {
            this.field.set_client(this.record, this.get_value());
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Date._super.set_readonly.call(this, readonly);
            this.el.find('input').prop('readonly', readonly);
            if (this.icon){
                if (readonly) {
                    this.icon.hide();
                } else {
                    this.icon.show();
                }
            }
        },
        cast: function(value){
            if (value && value.isDateTime) {
                value = value.todate();
            }
            return value;
        },
    });

    Sao.View.Form.DateTime = Sao.class_(Sao.View.Form.Date, {
        class_: 'form-datetime',
        _input: 'datetime-local',
        _input_format: '%Y-%m-%dT%H:%M',
        _format: Sao.common.format_datetime,
        _parse: Sao.common.parse_datetime,
        get_format: function() {
            if (this.field && this.record) {
                return (this.field.date_format(this.record) + ' ' +
                    this.field.time_format(this.record));
            } else {
                return (Sao.common.date_format(
                    this.view.screen.context.date_format) + ' %X');
            }
        },
        cast: function(value){
            return value;
        },
    });

    Sao.View.Form.Time = Sao.class_(Sao.View.Form.Date, {
        class_: 'form-time',
        _input: 'time',
        _input_format: '%H:%M:%S',
        _format: Sao.common.format_time,
        _parse: Sao.common.parse_time,
        init: function(view, attributes) {
            Sao.View.Form.Time._super.init.call(this, view, attributes);
            if (this.icon) {
                this.input.remove();
                this.icon.remove();
                delete this.icon;
            }
        },
        get_format: function() {
            if (this.field && this.record) {
                return this.field.time_format(this.record);
            } else {
                return '%X';
            }
        },
        cast: function(value){
            if (value && value.isDateTime) {
                value = value.totime();
            }
            return value;
        },
    });

    Sao.View.Form.TimeDelta = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-timedelta',
        init: function(view, attributes) {
            Sao.View.Form.TimeDelta._super.init.call(this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.input = this.labelled = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(this.el);
            this.el.change(this.focus_out.bind(this));
            this.el.on('keydown', this.send_modified.bind(this));
        },
        display: function() {
            Sao.View.Form.TimeDelta._super.display.call(this);
            var record = this.record;
            if (record) {
                var value = record.field_get_client(this.field_name);
                this.input.val(value || '');
            } else {
                this.input.val('');
            }
        },
        focus: function() {
            this.input.focus();
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.input.val();
                return this.field.get_client(this.record) != value;
            }
            return false;
        },
        set_value: function() {
            this.field.set_client(this.record, this.input.val());
        },
        set_readonly: function(readonly) {
            Sao.View.Form.TimeDelta._super.set_readonly.call(this, readonly);
            this.input.prop('readonly', readonly);
        }
    });

    var switch_id = function(a, b) {
        var a_id = a.attr('id');
        var a_labelledby = a.attr('aria-labelledby');
        var b_id = b.attr('id');
        var b_labelledby = b.attr('aria-labelledby');
        a.attr('id', b_id);
        a.attr('aria-labelledby', b_labelledby);
        b.attr('id', a_id);
        b.attr('aria-labelledby', a_labelledby);
    };

    var integer_input = function(input) {
        var input_text = input.clone().prependTo(input.parent());
        input_text.attr('type', 'text');
        input.attr('type', 'number');
        input.attr('step', 1);
        input.attr('lang', Sao.i18n.getlang());

        input.hide().on('focusout', function() {
            if (input[0].checkValidity()) {
                switch_id(input, input_text);
                input.hide();
                input_text.show();
            }
        });
        input_text.on('focusin', function() {
            if (!input.prop('readonly')) {
                switch_id(input, input_text);
                input_text.hide();
                input.show();
                window.setTimeout(function() {
                    input.focus();
                });
            }
        });
        return input_text;
    };

    Sao.View.Form.Integer = Sao.class_(Sao.View.Form.Char, {
        class_: 'form-integer',
        init: function(view, attributes) {
            Sao.View.Form.Integer._super.init.call(this, view, attributes);
            this.input_text = this.labelled = integer_input(this.input);
            if (this.attributes.symbol) {
                this.symbol_start = jQuery('<span/>', {
                    'class': 'input-group-addon symbol symbol-start'
                }).prependTo(this.group);
                this.symbol_end = jQuery('<span/>', {
                    'class': 'input-group-addon symbol symbol-end'
                }).appendTo(this.group);
            }
            this.group.css('width', '');
            this.factor = Number(attributes.factor || 1);
            this.grouping = Boolean(Number(attributes.grouping || 1));
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.get_client_value();
                return (JSON.stringify(this.field.convert(value)) !=
                    JSON.stringify(this.field.convert(this.get_value())));
            }
            return false;
        },
        set_value: function() {
            this.field.set_client(
                this.record, this.get_value(), undefined, this.factor);
        },
        get_value: function() {
            return this.input.val();
        },
        get_client_value: function() {
            var value = '';
            var field = this.field;
            if (field) {
                value = field.get(this.record);
                if (value !== null) {
                    value *= this.factor;
                    var digits = field.digits(this.record, this.factor);
                    if (digits) {
                        // Round to avoid float precision error
                        // after the multiplication
                        value = value.toFixed(digits[1]);
                    }
                } else {
                    value = '';
                }
            }
            return value;
        },
        get width() {
            return this.attributes.width || 8;
        },
        display: function() {
            var set_symbol = function(el, text) {
                if (text) {
                    el.text(text);
                    el.show();
                } else {
                    el.text('');
                    el.hide();
                }
            };
            Sao.View.Form.Integer._super.display.call(this);
            var field = this.field,
                record = this.record;
            var value = '';
            if (this.width !== null){
                this.input_text.css('width', this.width + 'ch');
                this.input.css('width', (this.width + 5) + 'ch');
                this.group.css('width', this.attributes.symbol ?
                    'calc(' + (this.width + 5) + 'ch + 5ch)' :
                    (this.width + 5) + 'ch');
            }
            if (field) {
                value = field.get_client(record, this.factor, this.grouping);
            }
            if (field && this.attributes.symbol) {
                var result = field.get_symbol(record, this.attributes.symbol);
                var symbol = result[0],
                    position = result[1];
                if (position < 0.5) {
                    set_symbol(this.symbol_start, symbol);
                    set_symbol(this.symbol_end, '');
                } else {
                    set_symbol(this.symbol_start, '');
                    set_symbol(this.symbol_end, symbol);
                }
            }
            this.input_text.val(value);
            this.input_text.attr('maxlength', this.input.attr('maxlength'));
            this.input_text.attr('size', this.input.attr('size'));
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Integer._super.set_readonly.call(this, readonly);
            this.input_text.prop('readonly', readonly);
        },
        focus: function() {
            if (!this.input.prop('readonly')) {
                this.input_text.hide();
                this.input.show().focus();
            } else {
                this.input_text.focus();
            }
        }
    });

    Sao.View.Form.Float = Sao.class_(Sao.View.Form.Integer, {
        class_: 'form-float',
        get digits() {
            var record = this.record,
                field = this.field;
            if (record && field) {
                return field.digits(record, this.factor);
            } else {
                return null;
            }
        },
        get width() {
            var digits = this.digits;
            if (digits) {
                return digits.reduce(function(acc, cur) {
                    return acc + cur;
                });
            } else {
                return this.attributes.width || 18;
            }
        },
        display: function() {
            var record = this.record;
            var step = 'any';
            if (record) {
                var digits = this.digits;
                if (digits) {
                    step = Math.pow(10, -digits[1]).toFixed(digits[1]);
                }
            }
            this.input.attr('step', step);
            Sao.View.Form.Float._super.display.call(this);
        }
    });

    Sao.View.Form.Selection = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-selection',
        init: function(view, attributes) {
            Sao.View.Form.Selection._super.init.call(this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.select = this.labelled = jQuery('<select/>', {
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            });
            this.el.append(this.select);
            this.select.change(this.focus_out.bind(this));
            Sao.common.selection_mixin.init.call(this);
            this.init_selection();
        },
        init_selection: function(key) {
            Sao.common.selection_mixin.init_selection.call(this, key,
                this.set_selection.bind(this));
        },
        update_selection: function(record, field, callbak) {
            Sao.common.selection_mixin.update_selection.call(this, record,
                field, (selection, help) => {
                    this.set_selection(selection, help);
                    if (callbak) {
                        callbak(help);
                    }
                });
        },
        set_selection: function(selection, help) {
            var select = this.select;
            select.empty();
            for (const e of selection) {
                select.append(jQuery('<option/>', {
                    'value': JSON.stringify(e[0]),
                    'text': e[1],
                    'title': help[e[0]],
                }));
            }
        },
        display_update_selection: function() {
            var record = this.record;
            var field = this.field;
            this.update_selection(record, field, help => {
                if (!field) {
                    this.select.val('');
                    return;
                }
                var value = field.get(record);
                var prm, found = false;
                for (const option of this.selection) {
                    if (option[0] === value) {
                        found = true;
                        break;
                    }
                }
                if (!found) {
                    prm = Sao.common.selection_mixin.get_inactive_selection
                        .call(this, value);
                    prm.done(inactive => {
                        this.select.append(jQuery('<option/>', {
                            value: JSON.stringify(inactive[0]),
                            text: inactive[1],
                            disabled: true
                        }));
                    });
                } else {
                    prm = jQuery.when();
                }
                prm.done(() => {
                    this.select.val(JSON.stringify(value));
                    var title = help[value] || null;
                    if (this.attributes.help && title) {
                        title = this.attributes.help + '\n' + title;
                    }
                    this.select.attr('title', title);
                });
            });
        },
        display: function() {
            Sao.View.Form.Selection._super.display.call(this);
            this.display_update_selection();
        },
        focus: function() {
            this.select.focus();
        },
        get_value: function() {
            return JSON.parse(this.select.val());
        },
        get modified() {
            if (this.record && this.field) {
                return this.field.get(this.record) != this.get_value();
            }
            return false;
        },
        set_value: function() {
            var value = this.get_value();
            this.field.set_client(this.record, value);
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Selection._super.set_readonly.call(this, readonly);
            this.select.prop('disabled', readonly);
        }
    });

    Sao.View.Form.Boolean = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-boolean',
        init: function(view, attributes) {
            Sao.View.Form.Boolean._super.init.call(this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.input = this.labelled = jQuery('<input/>', {
                'type': 'checkbox',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(this.el);
            this.input.change(this.focus_out.bind(this));
            this.input.click(function() {
                // Dont trigger click if field is readonly as readonly has no
                // effect on checkbox
                return !jQuery(this).prop('readonly');
            });
        },
        display: function() {
            Sao.View.Form.Boolean._super.display.call(this);
            var record = this.record;
            if (record) {
                this.input.prop('checked', record.field_get(this.field_name));
            } else {
                this.input.prop('checked', false);
            }
        },
        focus: function() {
            this.input.focus();
        },
        set_value: function() {
            var value = this.input.prop('checked');
            this.field.set_client(this.record, value);
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Boolean._super.set_readonly.call(this, readonly);
            this.input.prop('readonly', readonly);
        }
    });

    Sao.View.Form.Text = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-text',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.Text._super.init.call(this, view, attributes);
            Sao.View.Form.TranslateMixin.init.call(this);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            this.group = jQuery('<div/>', {
                'class': 'input-group',
            }).appendTo(this.el);
            this.input = this.labelled = jQuery('<textarea/>', {
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(this.group);
            this.input.change(this.focus_out.bind(this));
            this.input.on('keydown', this.send_modified.bind(this));
            if (this.attributes.translate) {
                var button  = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm form-control',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext('Translate'),
                    'title': Sao.i18n.gettext("Translate"),
                }).appendTo(jQuery('<span/>', {
                    'class': 'input-group-btn'
                }).appendTo(this.group));
                button.append(
                    Sao.common.ICONFACTORY.get_icon_img('tryton-translate'));
                button.click(this.translate.bind(this));
            }
        },
        display: function() {
            Sao.View.Form.Text._super.display.call(this);
            var record = this.record;
            if (record) {
                var value = record.field_get_client(this.field_name);
                this.input.val(value);
                if(this.attributes.spell) {
                    this.input.attr('lang',
                        Sao.i18n.BC47(record.expr_eval(this.attributes.spell)));
                    this.input.attr('spellcheck', 'true');
                }
            } else {
                this.input.val('');
            }
        },
        focus: function() {
            this.input.focus();
        },
        get modified() {
            if (this.record && this.field) {
                var value = this._normalize_newline(
                    this.field.get_client(this.record));
                return value != this.get_value();
            }
            return false;
        },
        get_value: function() {
            return this._normalize_newline(this.input.val() || '');
        },
        set_value: function() {
            // avoid modification of not normalized value
            var value = this.get_value();
            var prev_value = this.field.get_client(this.record);
            if (value == this._normalize_newline(prev_value)) {
                value = prev_value;
            }
            this.field.set_client(this.record, value);
        },
        _normalize_newline: function(content) {
            return content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Text._super.set_readonly.call(this, readonly);
            this.input.prop('readonly', readonly);
        },
        translate_widget: function() {
            var widget = jQuery('<textarea/>', {
                    'class': 'form-control',
                    'readonly': 'readonly',
                });
            widget.css('min-height', this.el.height());
            return widget;
        }
    });

    Sao.View.Form.RichText = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-richtext',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.RichText._super.init.call(this, view, attributes);
            Sao.View.Form.TranslateMixin.init.call(this);
            this.el = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default'
            });
            if (parseInt(attributes.toolbar || '1', 10)) {
                this.toolbar = Sao.common.richtext_toolbar().appendTo(
                    jQuery('<div/>', {
                        'class': 'panel-heading',
                    }).appendTo(this.el));
            }
            this.group = jQuery('<div/>', {
                'class': 'input-group',
            }).appendTo(jQuery('<div/>', {
                'class': 'panel-body',
            }).appendTo(this.el));
            this.input = this.labelled = jQuery('<div/>', {
                'class': 'richtext mousetrap',
                'contenteditable': true,
            }).appendTo(this.group);
            this.group.focusout(this.focus_out.bind(this));
            if (this.attributes.translate) {
                var button = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm form-control',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Translate"),
                    'title': Sao.i18n.gettext("Translate"),
                }).appendTo(jQuery('<span/>', {
                    'class': 'input-group-btn',
                }).appendTo(this.group));
                button.append(
                    Sao.common.ICONFACTORY.get_icon_img('tryton-translate'));
                button.click(this.translate.bind(this));
            }
        },
        focus_out: function() {
            // Let browser set the next focus before testing
            // if it moved out of the widget
            window.setTimeout(() => {
                if (this.el.find(':focus').length === 0) {
                    Sao.View.Form.RichText._super.focus_out.call(this);
                }
            }, 0);
        },
        display: function() {
            Sao.View.Form.RichText._super.display.call(this);
            var value = '';
            var record = this.record;
            if (record) {
                value = record.field_get_client(this.field_name);
                if(this.attributes.spell) {
                    this.input.attr('lang',
                        Sao.i18n.BC47(record.expr_eval(this.attributes.spell)));
                    this.input.attr('spellcheck', 'true');
                }
            }
            this.input.html(Sao.HtmlSanitizer.sanitize(value || ''));
        },
        focus: function() {
            this.input.focus();
        },
        get_value: function() {
            return this._normalize_markup(this.input.html());
        },
        set_value: function() {
            // avoid modification of not normalized value
            var value = this.get_value();
            var prev_value  = this.field.get_client(this.record);
            if (value == this._normalize_markup(prev_value)) {
                value = prev_value;
            }
            this.field.set_client(this.record, value);
        },
        _normalize_markup: function(content) {
            return Sao.common.richtext_normalize(
                Sao.HtmlSanitizer.sanitize(content || ''));
        },
        get modified() {
            if (this.record && this.field) {
                var value = this._normalize_markup(
                    this.field.get_client(this.record));
                return value != this.get_value();
            }
            return false;
        },
        set_readonly: function(readonly) {
            Sao.View.Form.RichText._super.set_readonly.call(this, readonly);
            this.input.prop('contenteditable', !readonly);
            if (this.toolbar) {
                this.toolbar.find('button,input,select')
                    .prop('disabled', readonly);
            }
        },
        translate_widget: function() {
            var widget = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default',
            });
            if (parseInt(this.attributes.toolbar || '1', 10)) {
                Sao.common.richtext_toolbar().appendTo(
                    jQuery('<div/>', {
                        'class': 'panel-heading',
                    }).appendTo(widget));
            }
            jQuery('<div/>', {
                'class': 'richtext mousetrap',
                'contenteditable': true
            }).appendTo(jQuery('<div/>', {
                'class': 'panel-body'
            }).appendTo(widget));
            return widget;
        },
        translate_widget_set_readonly: function(el, value) {
            Sao.View.Form.TranslateMixin.translate_widget_set_readonly.call(
                this, el, value);
            el.find('button,input,select').prop('disabled', value);
            el.find('div[contenteditable]').prop('contenteditable', !value);
        },
        translate_widget_set: function(el, value) {
            el.find('div[contenteditable]').html(
                Sao.HtmlSanitizer.sanitize(value || ''));
        },
        translate_widget_get: function(el) {
            return this._normalize_markup(
                el.find('div[contenteditable]').html());
        }
    });

    Sao.View.Form.Many2One = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-many2one',
        init: function(view, attributes) {
            Sao.View.Form.Many2One._super.init.call(this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm input-icon'
            }).appendTo(this.el);
            this.entry = this.labelled = jQuery('<input/>', {
                'type': 'input',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(group);
            this.but_primary = jQuery('<img/>', {
                'class': 'icon',
            }).appendTo(jQuery('<div/>', {
                'class': 'icon-input icon-primary',
            }).appendTo(group));
            this.but_secondary = jQuery('<img/>', {
                'class': 'icon',
            }).appendTo(jQuery('<div/>', {
                'class': 'icon-input icon-secondary',
            }).appendTo(group));
            this.but_primary.click('primary', this.edit.bind(this));
            this.but_secondary.click('secondary', this.edit.bind(this));

            // Use keydown to not receive focus-in TAB
            this.entry.on('keydown', this.send_modified.bind(this));
            this.entry.on('keydown', this.key_press.bind(this));

            if (!attributes.completion || attributes.completion == "1") {
                this.wid_completion = Sao.common.get_completion(
                    group,
                    this._update_completion.bind(this),
                    this._completion_match_selected.bind(this));
                this.entry.completion = this.wid_completion;
            }
            this.el.change(this.focus_out.bind(this));
            this._readonly = false;
            this._popup = false;
        },
        get_screen: function(search) {
            var domain = this.field.get_domain(this.record);
            var context;
            if (search) {
                context = this.field.get_search_context(this.record);
            } else {
                context = this.field.get_context(this.record);
            }
            var view_ids = (this.attributes.view_ids || '').split(',');
            if (!jQuery.isEmptyObject(view_ids)) {
                // Remove the first tree view as mode is form only
                view_ids.shift();
            }
            var model = this.get_model();
            var breadcrumb = jQuery.extend([], this.view.screen.breadcrumb);
            breadcrumb.push(
                this.attributes.string || Sao.common.MODELNAME.get(model));
            return new Sao.Screen(this.get_model(), {
                'context': context,
                'domain': domain,
                'mode': ['form'],
                'view_ids': view_ids,
                'views_preload': this.attributes.views,
                'readonly': this._readonly,
                exclude_field: this.attributes.relation_field,
                breadcrumb: breadcrumb,
            });
        },
        set_text: function(value) {
            if (jQuery.isEmptyObject(value)) {
                value = '';
            }
            this.entry.val(value);
        },
        get_text: function() {
            var record = this.record;
            if (record) {
                return record.field_get_client(this.field_name);
            }
            return '';
        },
        focus_out: function() {
            if (!this.attributes.completion ||
                    this.attributes.completion == "1") {
                if (this.el.find('.dropdown').hasClass('open')) {
                    return;
                }
            }
            Sao.View.Form.Many2One._super.focus_out.call(this);
        },
        set_value: function() {
            var record = this.record;
            var field = this.field;
            if (field.get_client(record) != this.entry.val()) {
                field.set_client(record, this.value_from_id(null, ''));
                this.entry.val('');
            }
        },
        display: function() {
            var record = this.record;
            var field = this.field;
            var value;
            Sao.View.Form.Many2One._super.display.call(this);

            this._set_button_sensitive();
            this._set_completion();

            if (!record) {
                this.entry.val('');
                return;
            }
            this.set_text(field.get_client(record));
            var primary, tooltip1, secondary, tooltip2;
            value = field.get(record);
            if (this.has_target(value)) {
                primary = 'tryton-open';
                tooltip1 = Sao.i18n.gettext("Open the record");
                secondary = 'tryton-clear';
                tooltip2 = Sao.i18n.gettext("Clear the field");
            } else {
                primary = null;
                tooltip1 = '';
                secondary = 'tryton-search';
                tooltip2 = Sao.i18n.gettext("Search a record");
            }
            if (this.entry.prop('readonly')) {
                secondary = null;
            }
            [
                [primary, tooltip1, this.but_primary, 'primary'],
                [secondary, tooltip2, this.but_secondary, 'secondary']
            ].forEach(function(items) {
                var icon_name = items[0];
                var tooltip = items[1];
                var button = items[2];
                var icon_input = button.parent();
                var type = 'input-icon-' + items[3];
                // don't use .hide/.show because the display value is not
                // correctly restored on modal.
                if (!icon_name) {
                    icon_input.hide();
                    icon_input.parent().removeClass(type);
                } else {
                    icon_input.show();
                    icon_input.parent().addClass(type);
                    Sao.common.ICONFACTORY.get_icon_url(icon_name).then(function(url) {
                        button.attr('src', url);
                    });
                }
                button.attr('aria-label', tooltip);
                button.attr('title', tooltip);
            });
            this._sync_icon_padding();
        },
        _sync_icon_padding: function() {
            var rtl = jQuery('html').attr('dir') == 'rtl';
            var compact_density = document.documentElement.getAttribute(
                'data-density') == 'compact';
            var icon_padding = compact_density ? '25px' : '33px';
            var empty_padding = compact_density ? '6px' : '12px';
            var primary_visible = this.but_primary.parent().css('display') !=
                'none';
            var secondary_visible = this.but_secondary.parent().css('display') !=
                'none';
            var start_padding = primary_visible ? icon_padding : empty_padding;
            var end_padding = secondary_visible ? icon_padding : empty_padding;
            this.entry.css({
                'padding-left': rtl ? end_padding : start_padding,
                'padding-right': rtl ? start_padding : end_padding,
            });
        },
        focus: function() {
            this.entry.focus();
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Many2One._super.set_readonly.call(this, readonly);
            this._readonly = readonly;
            this._set_button_sensitive();
        },
        _set_button_sensitive: function() {
            this.entry.prop('readonly', this._readonly);
            this.but_primary.prop('disabled', !this.read_access);
            this.but_secondary.prop('disabled', this._readonly);
        },
        get_access: function(type) {
            var model = this.get_model();
            if (model) {
                return Sao.common.MODELACCESS.get(model)[type];
            }
            return true;
        },
        get read_access() {
            return this.get_access('read');
        },
        get create_access() {
            var create = this.attributes.create;
            if (create === undefined) {
                create = true;
            } else if (typeof create == 'string') {
                create = Boolean(parseInt(create, 10));
            }
            return create && this.get_access('create');
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.entry.val();
                return this.field.get_client(this.record) != value;
            }
            return false;
        },
        id_from_value: function(value) {
            return value;
        },
        value_from_id: function(id, str='') {
            return [id, str];
        },
        get_model: function() {
            return this.attributes.relation;
        },
        has_target: function(value=null) {
            return value !== null;
        },
        edit: function(evt) {
            var model = this.get_model();
            if (!model || !Sao.common.MODELACCESS.get(model).read) {
                return;
            }
            var record = this.record;
            var value = record.field_get(this.field_name);

            if ((evt && evt.data == 'secondary') &&
                    !this._readonly &&
                    this.has_target(value)) {
                this.record.field_set_client(this.field_name,
                        this.value_from_id(null, ''));
                this.entry.val('');
                this.focus();
                return;
            }
            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            if (this.has_target(value)) {
                var m2o_id =
                    this.id_from_value(record.field_get(this.field_name));
                if (evt && (evt.ctrlKey || evt.metaKey)) {
                    var params = {};
                    params.model = this.get_model();
                    params.res_id = m2o_id;
                    params.mode = ['form'];
                    params.name = this.attributes.string;
                    params.context = this.field.get_context(this.record);
                    Sao.Tab.create(params);
                    this._popup = false;
                    return;
                }
                var screen = this.get_screen();
                let callback = result => {
                    if (result) {
                        var rec_name_prm = screen.current_record.rec_name();
                        rec_name_prm.done(name => {
                            var value = this.value_from_id(
                                screen.current_record.id, name);
                            this.record.field_set_client(this.field_name,
                                value, true);
                        });
                    }
                    this._popup = false;
                };
                screen.switch_view().done(() => {
                    screen.load([m2o_id]);
                    screen.current_record = screen.group.get(m2o_id);
                    new Sao.Window.Form(screen, callback, {
                        save_current: true,
                    });
                });
                return;
            }
            if (model) {
                var domain = this.field.get_domain(record);
                var context = this.field.get_search_context(record);
                var order = this.field.get_search_order(record);
                var text = this.entry.val();
                let callback = result => {
                    if (!jQuery.isEmptyObject(result)) {
                        var value = this.value_from_id(result[0][0],
                                result[0][1]);
                        this.record.field_set_client(this.field_name,
                                value, true);
                    }
                    this._popup = false;
                };
                var parser = new Sao.common.DomainParser();
                new Sao.Window.Search(
                    model, callback, {
                            sel_multi: false,
                            context: context,
                            domain: domain,
                            order: order,
                            view_ids: (this.attributes.view_ids ||
                                '').split(','),
                            views_preload: (this.attributes.views || {}),
                            new_: this.create_access,
                            search_filter: parser.quote(text),
                            title: this.attributes.string,
                            exclude_field: this.attributes.relation_field,
                        });
                return;
            }
            this._popup = false;
        },
        new_: function(defaults=null) {
            var model = this.get_model();
            if (!model || ! Sao.common.MODELACCESS.get(model).create) {
                return;
            }
            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            var screen = this.get_screen(true);
            if (defaults) {
                defaults = jQuery.extend({}, defaults);
            } else {
                defaults = {};
            }
            defaults.rec_name = this.entry.val();

            const callback = result => {
                if (result) {
                    var rec_name_prm = screen.current_record.rec_name();
                    rec_name_prm.done(name => {
                        var value = this.value_from_id(
                            screen.current_record.id, name);
                        this.record.field_set_client(this.field_name, value);
                    });
                }
                this._popup = false;
            };
            screen.switch_view().done(() => {
                new Sao.Window.Form(screen, callback, {
                    new_: true,
                    save_current: true,
                    defaults: defaults,
                });
            });
        },
        key_press: function(event_) {
            var editable = !this.entry.prop('readonly');
            var activate_keys = [Sao.common.TAB_KEYCODE];
            var delete_keys = [Sao.common.BACKSPACE_KEYCODE,
                Sao.common.DELETE_KEYCODE];
            if (!this.wid_completion) {
                activate_keys.push(Sao.common.RETURN_KEYCODE);
            }

            if (event_.which == Sao.common.F3_KEYCODE &&
                    editable &&
                    this.create_access) {
                event_.preventDefault();
                this.new_();
            } else if (event_.which == Sao.common.F2_KEYCODE &&
                    this.read_access) {
                event_.preventDefault();
                this.edit();
            } else if (~activate_keys.indexOf(event_.which) && editable) {
                if (!this.attributes.completion ||
                        this.attributes.completion == "1") {
                    if (this.el.find('.dropdown').hasClass('open')) {
                        return;
                    }
                }
                this.activate();
            } else if (this.has_target(this.record.field_get(
                            this.field_name)) && editable) {
                var value = this.get_text();
                if ((value != this.entry.val()) ||
                        ~delete_keys.indexOf(event_.which)) {
                    this.entry.val('');
                    this.record.field_set_client(this.field_name,
                        this.value_from_id(null, ''));
                }
            }
        },
        activate: function() {
            var model = this.get_model();
            if (!model || !Sao.common.MODELACCESS.get(model).read) {
                return;
            }
            var record = this.record;
            var value = record.field_get(this.field_name);

            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            if (model && !this.has_target(value)) {
                var text = this.entry.val();
                if (!this._readonly && (text ||
                            this.field.get_state_attrs(this.record)
                            .required)) {
                    var domain = this.field.get_domain(record);
                    var context = this.field.get_search_context(record);
                    var order = this.field.get_search_order(record);

                    const callback = result => {
                        if (!jQuery.isEmptyObject(result)) {
                            var value = this.value_from_id(result[0][0],
                                result[0][1]);
                            this.record.field_set_client(this.field_name,
                                value, true);
                        } else {
                            this.entry.val('');
                        }
                        this._popup = false;
                    };
                    var parser = new Sao.common.DomainParser();
                    new Sao.Window.Search(
                        model, callback, {
                                sel_multi: false,
                                context: context,
                                domain: domain,
                                order: order,
                                view_ids: (this.attributes.view_ids ||
                                    '').split(','),
                                views_preload: (this.attributes.views ||
                                    {}),
                                new_: this.create_access,
                                search_filter: parser.quote(text),
                                title: this.attributes.string,
                                exclude_field: this.attributes.relation_field,
                            });
                    return;
                }
            }
            this._popup = false;
        },
        _set_completion: function() {
            if (this.wid_completion) {
                this.wid_completion.set_actions(
                    this._completion_action_activated.bind(this),
                    this.read_access, this.create_access);
            }
        },
        _update_completion: function(text) {
            var record = this.record;
            if (!record) {
                return jQuery.when();
            }
            var field = this.field;
            var value = field.get(record);
            if (this.has_target(value)) {
                var id = this.id_from_value(value);
                if ((id !== undefined) && (id >= 0)) {
                    return jQuery.when();
                }
            }
            var model = this.get_model();

            return Sao.common.update_completion(
                    this.entry, record, field, model);
        },
        _completion_match_selected: function(value) {
            if (value.id !== null) {
                this.record.field_set_client(this.field_name,
                    this.value_from_id(
                        value.id, value.rec_name), true);
            } else {
                this.new_(value.defaults);
            }
        },
        _completion_action_activated: function(action) {
            if (action == 'search') {
                this.edit();
            } else if (action == 'create') {
                this.new_();
            }
        }
    });

    Sao.View.Form.One2One = Sao.class_(Sao.View.Form.Many2One, {
        class_: 'form-one2one'
    });

    Sao.View.Form.Reference = Sao.class_(Sao.View.Form.Many2One, {
        class_: 'form-reference',
        init: function(view, attributes) {
            Sao.View.Form.Reference._super.init.call(this, view, attributes);
            this.el.addClass('form-inline');
            this.select = jQuery('<select/>', {
                'class': 'form-control input-sm',
                'aria-label': attributes.string,
                'title': attributes.string,
            });
            this.el.prepend(this.select);
            this.select.change(this.select_changed.bind(this));
            Sao.common.selection_mixin.init.call(this);
            this.init_selection();
        },
        init_selection: function(key) {
            Sao.common.selection_mixin.init_selection.call(this, key,
                this.set_selection.bind(this));
        },
        update_selection: function(record, field, callback) {
            Sao.common.selection_mixin.update_selection.call(this, record,
                field, (selection, help) => {
                    this.set_selection(selection, help);
                    if (callback) {
                        callback();
                    }
                });
        },
        set_selection: function(selection, help) {
            var select = this.select;
            select.empty();
            for (const e of selection) {
                select.append(jQuery('<option/>', {
                    'value': e[0],
                    'text': e[1],
                    'title': help[e[0]],
                }));
            }
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.field.get_client(this.record);
                var model = '',
                    name = '';
                if (value) {
                    model = value[0];
                    name = value[1];
                }
                return ((model != this.get_model()) ||
                    (name != this.entry.val()));
            }
            return false;
        },
        id_from_value: function(value) {
            return parseInt(value.split(',')[1], 10);
        },
        value_from_id: function(id, str) {
            if (!str) {
                str = '';
            }
            return [this.get_model(), [id, str]];
        },
        get_text: function() {
            var record = this.record;
            if (record) {
                return record.field_get_client(this.field_name)[1];
            }
            return '';
        },
        get_model: function() {
            return this.select.val();
        },
        has_target: function(value) {
            if (value === null) {
                return false;
            }
            var model = value.split(',')[0];
            value = value.split(',')[1];
            if (jQuery.isEmptyObject(value)) {
                value = null;
            } else {
                value = parseInt(value, 10);
                if (isNaN(value)) {
                    value = null;
                }
            }
            return (model == this.get_model()) && (value >= 0);
        },
        _set_button_sensitive: function() {
            Sao.View.Form.Reference._super._set_button_sensitive.call(this);
            this.select.prop('disabled', this.entry.prop('readonly'));
        },
        select_changed: function() {
            this.entry.val('');
            var model = this.get_model();
            var value;
            if (model) {
                value = [model, [-1, '']];
            } else {
                value = ['', ''];
            }
            this.record.field_set_client(this.field_name, value);
        },
        set_value: function() {
            var value;
            var record = this.record;
            var field = this.field;
            if (!this.get_model()) {
                value = this.entry.val();
                if (jQuery.isEmptyObject(value)) {
                    field.set_client(record, null);
                } else {
                    field.set_client(record, ['', value]);
                }
            } else {
                value = field.get_client(record, this.field_name);
                var model, name;
                if (value instanceof Array) {
                    model = value[0];
                    name = value[1];
                } else {
                    model = '';
                    name = '';
                }
                if ((model != this.get_model()) ||
                        (name != this.entry.val())) {
                    field.set_client(record, null);
                    this.entry.val('');
                }
            }
        },
        set_text: function(value) {
            var model;
            if (value) {
                model = value[0];
                value = value[1];
            } else {
                model = null;
                value = null;
            }
            Sao.View.Form.Reference._super.set_text.call(this, value);
            if (model) {
                this.select.val(model);
            } else {
                this.select.val('');
            }
        },
        display: function() {
            this.update_selection(this.record, this.field, () => {
                Sao.View.Form.Reference._super.display.call(this);
            });
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Reference._super.set_readonly.call(this, readonly);
            this.select.prop('disabled', readonly);
        }
    });

    Sao.View.Form.One2Many = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-one2many',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.One2Many._super.init.call(this, view, attributes);

            this._readonly = true;
            this._required = false;
            this._position = 0;
            this._length = 0;

            this.el = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default'
            });
            this.menu = jQuery('<div/>', {
                'class': this.class_ + '-menu panel-heading'
            });
            this.el.append(this.menu);

            this.title = jQuery('<label/>', {
                'class': this.class_ + '-string',
                text: attributes.string
            });
            this.menu.append(this.title);

            this.title.uniqueId();
            this.el.uniqueId();
            this.el.attr('aria-labelledby', this.title.attr('id'));
            this.title.attr('for', this.el.attr('id'));

            var toolbar = jQuery('<div/>', {
                'class': this.class_ + '-toolbar'
            });
            this.menu.append(toolbar);
            this.line_chips = jQuery('<div/>', {
                'class': this.class_ + '-line-chips',
                'role': 'list'
            }).hide();
            this.menu.append(this.line_chips);
            this._new_feedback_chip = null;
            this._collapsed = false;
            this.collapse_button = jQuery('<button/>', {
                'type': 'button',
                'class': this.class_ + '-collapse-toggle htmlviewer-collapse-toggle',
                'aria-label': Sao.i18n.gettext('Hide lines'),
                'title': Sao.i18n.gettext('Hide lines'),
            }).appendTo(this.menu);
            this.collapse_button.on('click', evt => {
                evt.preventDefault();
                evt.stopPropagation();
                this._collapsed = !this._collapsed;
                this._sync_collapse_state();
            });

            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(toolbar);

            var buttons = jQuery('<div/>', {
                'class': 'input-group-btn'
            }).appendTo(group);

            var disable_during = function(callback) {
                return function(evt) {
                    var button = jQuery(evt.currentTarget);
                    button.prop('disabled', true);
                    (callback(evt) || jQuery.when())
                        .always(function() {
                            button.prop('disabled', false);
                        });
                };
            };

            
            this.but_switch = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Switch"),
                'title': Sao.i18n.gettext("Switch"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-switch')
            ).appendTo(buttons);
            this.but_switch.click(disable_during(this.switch_.bind(this)));

            
            this.but_previous = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Previous"),
                'title': Sao.i18n.gettext("Previous"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-back')
            ).appendTo(buttons);
            this.but_previous.click(disable_during(this.previous.bind(this)));

            this.label = jQuery('<span/>', {
                'class': 'badge',
            }).text('_ / 0'
            ).appendTo(jQuery('<span/>', {
                'class': 'btn hidden-xs',
            }).appendTo(buttons));

            this.but_next = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Next"),
                'title': Sao.i18n.gettext("Next"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-forward')
            ).appendTo(buttons);
            this.but_next.click(disable_during(this.next.bind(this)));

            if (attributes.add_remove) {
                this.wid_text = jQuery('<input/>', {
                    type: 'text',
                    'class': 'form-control input-sm',
                    'name': attributes.name,
                }).appendTo(group);

                if (!attributes.completion || attributes.completion == '1') {
                    this.wid_completion = Sao.common.get_completion(
                        this.wid_text,
                        this._update_completion.bind(this),
                        this._completion_match_selected.bind(this),
                        this._completion_action_activated.bind(this),
                        this.read_access, this.create_access);
                    this.wid_text.completion = this.wid_completion;
                }

                buttons =  jQuery('<div/>', {
                    'class': 'input-group-btn',
                }).appendTo(group);

                this.but_add = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'tabindex': -1,
                    'aria-label': Sao.i18n.gettext("Add"),
                    'title': Sao.i18n.gettext("Add"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-add')
                ).appendTo(buttons);
                this.but_add.click(disable_during(this.add.bind(this)));

                this.but_remove = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'tabindex': -1,
                    'aria-label': Sao.i18n.gettext("Remove"),
                    'title': Sao.i18n.gettext("Remove"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
                ).appendTo(buttons);
                this.but_remove.click(disable_during(this.remove.bind(this)));
            }

            this.but_new = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("New"),
                'title': Sao.i18n.gettext("New"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-create')
            ).appendTo(buttons);
            this.but_new.click(disable_during(() => this.new_()));

            this.but_open = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Open"),
                'title': Sao.i18n.gettext("Open"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-open')
            ).appendTo(buttons);
            this.but_open.click(disable_during(this.open.bind(this)));
            this.but_del = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Delete"),
                'title': Sao.i18n.gettext("Delete"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-delete')
            ).appendTo(buttons);
            this.but_del.click(disable_during(this.delete_.bind(this)));

            this.but_undel = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Undelete"),
                'title': Sao.i18n.gettext("Undelete"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-undo')
            ).appendTo(buttons);
            this.but_undel.click(disable_during(this.undelete.bind(this)));
            
            this.content = jQuery('<div/>', {
                'class': this.class_ + '-content panel-body'
            });
            this.el.append(this.content);

            var modes = (attributes.mode || 'tree,form').split(',');
            var model = attributes.relation;
            var breadcrumb = jQuery.extend([], this.view.screen.breadcrumb);
            breadcrumb.push(
                attributes.string || Sao.common.MODELNAME.get(model));
            this.screen = new Sao.Screen(model, {
                mode: modes,
                view_ids: (attributes.view_ids || '').split(','),
                views_preload: attributes.views || {},
                order: attributes.order,
                one2many: true,
                row_activate: this.activate.bind(this),
                exclude_field: attributes.relation_field || null,
                limit: null,
                context: this.view.screen.context,
                pre_validate: attributes.pre_validate,
                breadcrumb: breadcrumb,
            });
            this.screen.pre_validate = attributes.pre_validate == 1;

            this.screen.windows.push(this);
            this.prm = this.screen.switch_view().done(() => {
                this.content.append(this.screen.screen_container.el);
            });

            if (attributes.add_remove) {
                // Use keydown to not receive focus-in TAB
                this.wid_text.on('keydown', this.key_press.bind(this));
            }

            this._popup = false;

            if (attributes.mode === 'graph'|| this.title.text() === 'VESSEL IN TRANSIT' ) {
                this.menu.find('.' + this.class_ + '-toolbar').hide();
                this.menu.css({
                    'background-color': 'transparent',
                    'border': 'none',
                    'box-shadow': 'none'
                });    
                if (this.title.text() === 'VESSEL IN TRANSIT'){                           
                    this.title.css({
                        'color': 'black',
                        'font-weight': 'bold',
                        'text-align': 'left',
                        'display': 'block',
                        'width': '100%',
                        'background-color': 'transparent',
                        'padding': '10px 0',
                        'font-size': '16px'
                        });
                }
                else {
                    this.title.css({
                        'color': 'black',
                        'font-weight': 'bold',
                        'text-align': 'center',
                        'display': 'block',
                        'width': '100%',
                        'background-color': 'transparent',
                        'padding': '10px 0',
                        'font-size': '16px'
                        });
                }
    
                //this.menu.hide();
            }
            if (this.title.text() === 'Bought' || this.title.text() === 'Sold'){
                this.menu.find('.' + this.class_ + '-toolbar').hide();
            }
            // if (this.screen.model_name === 'back.to.back' ) {
                
            // }
        },
        get_access: function(type) {
            var model = this.attributes.relation;
            if (model) {
                return Sao.common.MODELACCESS.get(model)[type];
            }
            return true;
        },
        get read_access() {
            return this.get_access('read');
        },
        get create_access() {
            var create = this.attributes.create;
            if (create === undefined) {
                create = true;
            } else if (typeof create == 'string') {
                create = Boolean(parseInt(create, 10));
            }
            return create && this.get_access('create');
        },
        get write_access() {
            return this.get_access('write');
        },
        get delete_access() {
            var delete_ = this.attributes.delete;
            if (delete_ === undefined) {
                delete_ = true;
            } else if (typeof delete_ == 'string') {
                delete_ = Boolean(parseInt(delete_, 10));
            }
            return delete_ && this.get_access('delete');
        },
        get modified() {
            return this.screen.current_view.modified;
        },
        set_readonly: function(readonly) {
            Sao.View.Form.One2Many._super.set_readonly.call(this, readonly);
            this.prm.done(() => this._set_button_sensitive());
            this._set_label_state();
        },
        set_required: function(required) {
            this._required = required;
            this._set_label_state();
        },
        _set_label_state: function() {
            Sao.common.apply_label_attributes(this.title, this._readonly,
                    this._required);
        },
        _set_button_sensitive: function() {
            var size_limit, o2m_size;
            var record = this.record;
            var field = this.field;
            if (record && field) {
                var field_size = record.expr_eval(this.attributes.size);
                o2m_size = field.get_eval(record).length;
                size_limit = (((field_size !== undefined) &&
                            (field_size !== null)) &&
                        (o2m_size >= field_size) && (field_size >= 0));
            } else {
                o2m_size = null;
                size_limit = false;
            }
            var deletable = this.screen.deletable;
            const view_type = this.screen.current_view.view_type;
            const has_views = this.screen.number_of_views > 1;

            this.but_switch.prop(
                'disabled',
                !((this._position || (view_type == 'form')) && has_views));
            this.but_new.prop(
                'disabled',
                this._readonly ||
                !this.create_access ||
                size_limit);
            this.but_del.prop(
                'disabled',
                this._readonly ||
                !this.delete_access ||
                !this._position ||
                !deletable);
            this.but_undel.prop(
                'disabled',
                this._readonly ||
                size_limit ||
                !this._position);
            this.but_open.prop(
                'disabled',
                !this._position ||
                !this.read_access);
            this.but_next.prop(
                'disabled',
                (this.position > 0) &&
                ( this._position >= this._length));
            this.but_previous.prop(
                'disabled',
                this._position <= 1);
            if (this.attributes.add_remove) {
                this.but_add.prop(
                    'disabled',
                    this._readonly ||
                    size_limit ||
                    !this.write_access ||
                    !this.read_access);
                this.wid_text.prop('disabled', this.but_add.prop('disabled'));
                this.but_remove.prop(
                    'disabled',
                    this._readonly ||
                    !this.position ||
                    !this.write_access ||
                    !this.read_access);
            }
            this._update_line_chips();
        },
        _resolve_collapse_context: function() {
            var panel = this.el.closest('fieldset.form-panel');
            var legend = panel.children('legend').first();
            if (panel.length && legend.length) {
                return {
                    target: panel,
                    item: panel.closest('.form-item'),
                    parent: legend,
                    panel: true,
                };
            }
            return {
                target: this.el,
                item: this.el.closest('.form-item'),
                parent: this.menu,
                panel: false,
            };
        },
        _sync_collapse_state: function() {
            if (!this.collapse_button) {
                return;
            }
            var context = this._resolve_collapse_context();
            if (context.parent.length &&
                    this.collapse_button.parent()[0] != context.parent[0]) {
                this.collapse_button.appendTo(context.parent);
            }
            this.collapse_button.toggleClass(
                'htmlviewer-collapse-toggle-heading', context.panel);
            this.el.toggleClass('form-one2many-collapsed',
                this._collapsed && !context.panel);
            context.target.toggleClass('form-one2many-panel-collapsed',
                this._collapsed && context.panel);
            var item = context.item;
            item.toggleClass('form-one2many-collapsed-item', this._collapsed);
            if (this.content) {
                this.content
                    .prop('hidden', this._collapsed && !context.panel)
                    .toggle(!this._collapsed || context.panel);
            }
            if (this._collapsed && this.screen && this.screen.current_view) {
                this.screen.current_view.el
                    .find('.treeview,.list-form,.screen-container').first()
                    .css('min-height', '0')
                    .css('max-height', 'none')
                    .css('height', 'auto');
            }
            this.collapse_button
                .toggleClass('htmlviewer-collapse-toggle-collapsed',
                    this._collapsed)
                .attr('aria-expanded', this._collapsed ? 'false' : 'true')
                .attr('aria-label', Sao.i18n.gettext(
                    this._collapsed ? 'Show lines' : 'Hide lines'))
                .attr('title', Sao.i18n.gettext(
                    this._collapsed ? 'Show lines' : 'Hide lines'));
            if (this.line_chips) {
                this.line_chips.toggle(!this._collapsed &&
                    !context.panel &&
                    this.el.hasClass('form-one2many-has-line-chips'));
            }
            var form_container = item.parent('.form-container');
            var container = form_container.data('sao-form-container');
            if (container) {
                container.set_grid_template();
            }
        },
        _line_chip_fields: function() {
            var fields = this.attributes.line_chips ||
                this.attributes.line_chips_fields || '';
            return String(fields || '').split(',').map(function(field) {
                return field.trim();
            }).filter(Boolean);
        },
        _line_chip_value: function(record, field_name) {
            if (!record || !field_name) {
                return '';
            }
            var value;
            try {
                value = record.field_get_client(field_name);
            } catch (error) {
                try {
                    value = record.field_get(field_name);
                } catch (_) {
                    value = '';
                }
            }
            if (value === undefined || value === null) {
                return '';
            }
            if (value instanceof Array) {
                return value.length > 1 ? value[1] : value[0];
            }
            if (typeof value == 'object') {
                if ('rec_name' in value) {
                    return value.rec_name;
                }
                if ('name' in value) {
                    return value.name;
                }
                return '';
            }
            return String(value);
        },
        _line_chip_text: function(record, fields, position) {
            var template = this.attributes.line_chips_label ||
                this.attributes.line_chip_template || '';
            if (template) {
                return template.replace(/\{([^}]+)\}/g, (match, field) => {
                    field = String(field || '').trim();
                    if (field == 'index' || field == 'position') {
                        return position;
                    }
                    return this._line_chip_value(record, field);
                });
            }
            return fields.map(field => this._line_chip_value(record, field))
                .filter(Boolean).join(' ');
        },
        _line_chip_parts: function(text, fields, record) {
            text = String(text || '').replace(/\\n/g, '\n');
            var parts = text.split(/\n/);
            if (parts.length > 1) {
                return [parts[0], parts.slice(1).join(' ')];
            }
            if (fields.length > 1) {
                var values = fields.map(field =>
                    this._line_chip_value(record, field)).filter(Boolean);
                return [
                    values.slice(0, Math.max(1, values.length - 1)).join(' '),
                    values.slice(Math.max(1, values.length - 1)).join(' ')
                ];
            }
            return [text, ''];
        },
        _line_chip_activate: function(record) {
            if (!record || this.screen.current_record == record) {
                return;
            }
            this.validate().then(() => {
                if (this.screen.current_view) {
                    this.screen.current_view.set_value();
                }
                this.screen.current_record = record;
                this.screen.set_cursor(false, false);
                (this.screen.current_view ?
                    this.screen.current_view.display() : jQuery.when())
                    .then(() => this._update_line_chips());
            });
        },
        _start_new_feedback: function() {
            this.el.addClass('form-one2many-new-pending');
            if (this.but_new) {
                this.but_new.addClass('form-one2many-new-pending-button');
            }
            if (!this.line_chips || this._collapsed ||
                    !this._line_chip_fields().length ||
                    !this.screen || !this.screen.current_view ||
                    this.screen.current_view.view_type != 'form') {
                return;
            }
            if (this._new_feedback_chip) {
                return;
            }
            var position = (this.screen.group ? this.screen.group.length : 0) + 1;
            this.el.addClass('form-one2many-has-line-chips');
            this.line_chips.show();
            this._new_feedback_chip = jQuery('<span/>', {
                'class': 'form-one2many-line-chip active pending',
                'role': 'status',
                'aria-live': 'polite',
                'title': Sao.i18n.gettext('Creating line'),
            }).append(jQuery('<span/>', {
                'class': 'form-one2many-line-chip-index',
                text: position,
            })).append(jQuery('<span/>', {
                'class': 'form-one2many-line-chip-copy',
            }).append(jQuery('<strong/>', {
                text: Sao.i18n.gettext('New line'),
            })).append(jQuery('<small/>', {
                text: Sao.i18n.gettext('Creating...'),
            })));
            this.line_chips.append(this._new_feedback_chip);
        },
        _stop_new_feedback: function() {
            this.el.removeClass('form-one2many-new-pending');
            if (this.but_new) {
                this.but_new.removeClass('form-one2many-new-pending-button');
            }
            if (this._new_feedback_chip) {
                this._new_feedback_chip.remove();
                this._new_feedback_chip = null;
            }
        },
        _update_line_chips: function() {
            if (!this.line_chips) {
                return;
            }
            var fields = this._line_chip_fields();
            var current_view = this.screen && this.screen.current_view;
            var show = fields.length && current_view &&
                current_view.view_type == 'form' && this.screen.group.length;
            this.el.toggleClass('form-one2many-has-line-chips', Boolean(show));
            if (!show) {
                this.line_chips.hide().empty();
                return;
            }
            if (this._collapsed) {
                this.line_chips.hide().empty();
                return;
            }
            this.line_chips.empty().show();
            Array.prototype.slice.call(this.screen.group, 0, 60)
                .forEach((record, index) => {
                var text = this._line_chip_text(record, fields, index + 1);
                var parts = this._line_chip_parts(text, fields, record);
                var chip = jQuery('<button/>', {
                    'type': 'button',
                    'class': 'form-one2many-line-chip',
                    'role': 'listitem',
                    'title': String(text || '').replace(/\n/g, ' '),
                }).toggleClass(
                    'active', record == this.screen.current_record);
                chip.append(jQuery('<span/>', {
                    'class': 'form-one2many-line-chip-index',
                    text: index + 1,
                }));
                chip.append(jQuery('<span/>', {
                    'class': 'form-one2many-line-chip-copy',
                }).append(jQuery('<strong/>', {
                    text: parts[0] || ('Line ' + (index + 1)),
                })).append(jQuery('<small/>', {
                    text: parts[1] || '',
                })));
                chip.on('click', evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._line_chip_activate(record);
                });
                this.line_chips.append(chip);
            });
        },
        _sequence: function() {
            for (const view of this.screen.views) {
                if (view.view_type == 'tree') {
                    const sequence = view.attributes.sequence;
                    if (sequence) {
                        return sequence;
                    }
                }
            }
        },
        display: function() {
            Sao.View.Form.One2Many._super.display.call(this);

            this.prm.done(() => {
                this._set_button_sensitive();

                var record = this.record;
                var field = this.field;

                if (!field) {
                    this.screen.new_group();
                    this.screen.current_record = null;
                    this.screen.group.parent = null;
                    this.screen.display();
                    return;
                }

                var new_group = record.field_get_client(this.field_name);
                if (new_group != this.screen.group) {
                    this.screen.set_group(new_group);
                    if ((this.screen.current_view.view_type == 'form') &&
                        this.screen.group.length) {
                        this.screen.current_record = this.screen.group[0];
                    }
                }
                var domain = [];
                var size_limit = null;
                if (record) {
                    domain = field.get_domain(record);
                    size_limit = record.expr_eval(this.attributes.size);
                }
                if (this._readonly || !this.create_access) {
                    if ((size_limit === null) || (size_limit === undefined)) {
                        size_limit = this.screen.group.length;
                    } else {
                        size_limit = Math.min(
                                size_limit, this.screen.group.length);
                    }
                }
                if (!Sao.common.compare(this.screen.domain, domain)) {
                    this.screen.domain = domain;
                }
                this.screen.size_limit = size_limit;
                if (this.attributes.height !== undefined &&
                        this.screen.current_view &&
                        this.screen.current_view.set_embedded_height) {
                    this.screen.current_view.set_embedded_height(
                        this.attributes.height);
                }
                this.screen.display();
                this._update_line_chips();
                this._sync_collapse_state();
                if (this.attributes.height !== undefined) {
                    this.screen.current_view.el
                        .find('.treeview,.list-form').first()
                        .css('min-height', this.attributes.height + 'px')
                        .css('max-height', this.attributes.height + 'px');
                    if (this.screen.current_view.set_embedded_height) {
                        this.screen.current_view.set_embedded_height(
                            this.attributes.height);
                    }
                }
            });
        },
        focus: function() {
            if (this.attributes.add_remove) {
                this.wid_text.focus();
            }
        },
        activate: function(event_) {
            this.edit();
        },
        add: function(event_) {
            if (!this.write_access || !this.read_access) {
                return;
            }
            this.view.set_value();
            var domain = this.field.get_domain(this.record);
            var context = this.field.get_search_context(this.record);
            domain = [domain,
                this.record.expr_eval(this.attributes.add_remove)];
            var removed_ids = this.field.get_removed_ids(this.record);
            domain = ['OR', domain, ['id', 'in', removed_ids]];
            var text = this.wid_text.val();

            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }

            var sequence = this._sequence();

            const callback = result => {
                var prm = jQuery.when();
                if (!jQuery.isEmptyObject(result)) {
                    var ids = [];
                    var i, len;
                    for (i = 0, len = result.length; i < len; i++) {
                        ids.push(result[i][0]);
                    }
                    this.screen.group.load(ids, true, -1, null);
                    prm = this.screen.display();
                    if (sequence) {
                        this.screen.group.set_sequence(
                            sequence, this.screen.new_position);
                    }
                }
                prm.done(() => {
                    this.screen.set_cursor();
                });
                this.wid_text.val('');
                this._popup = false;
            };
            var parser = new Sao.common.DomainParser();
            var order = this.field.get_search_order(this.record);
            new Sao.Window.Search(this.attributes.relation,
                    callback, {
                        sel_multi: true,
                        context: context,
                        domain: domain,
                        order: order,
                        view_ids: (this.attributes.view_ids ||
                                '').split(','),
                        views_preload: this.attributes.views || {},
                        new_: !this.but_new.prop('disabled'),
                        search_filter: parser.quote(text),
                        title: this.attributes.string,
                        exclude_field: this.attributes.relation_field,
                    });
        },
        remove: function(event_) {
            var writable = !this.screen.readonly;
            if (!this.write_access || !this.read_access || !writable) {
                return;
            }
            this.screen.remove(false, true, false);
        },
        new_: function(defaults=null) {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name).create) {
                return;
            }
            if (this.attributes.add_remove) {
                if (defaults) {
                    defaults = jQuery.extend({}, defaults);
                } else {
                    defaults = {};
                }
                defaults.rec_name = this.wid_text.val();
            }
            this._start_new_feedback();
            return this.validate().then(() => {
                if (this.attributes.product) {
                    return this.new_product(defaults);
                } else {
                    return this.new_single(defaults);
                }
            }).always(() => this._stop_new_feedback());
        },
        new_single: function(defaults=null) {
            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            var sequence = this._sequence();
            const update_sequence = () => {
                if (sequence) {
                    this.screen.group.set_sequence(
                        sequence, this.screen.new_position);
                }
                this._popup = false;
                this._update_line_chips();
            };
            if (this.screen.current_view.creatable) {
                return this.screen.new_(true, defaults).then(record => {
                    update_sequence();
                    return record;
                });
            } else {
                var record = this.record;
                var field_size = record.expr_eval(
                    this.attributes.size) || -1;
                field_size -= this.field.get_eval(record).length;
                new Sao.Window.Form(this.screen, update_sequence, {
                    new_: true,
                    defaults: defaults,
                    many: field_size,
                });
                return jQuery.when();
            }
        },
        new_product: function(defaults=null) {
            var fields = this.attributes.product.split(',');
            var product = {};
            var screen = this.screen;

            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }

            screen.new_(false).then(first => {
                first.default_get(defaults).then(default_ => {
                    first.set_default(default_);

                    const search_set = () => {
                        if (jQuery.isEmptyObject(fields)) {
                            return make_product();
                        }
                        var field = screen.model.fields[fields.pop()];
                        var relation = field.description.relation;
                        if (!relation) {
                            search_set();
                        }

                        var domain = field.get_domain(first);
                        var context = field.get_search_context(first);
                        var order = field.get_search_order(first);

                        var callback = function(result) {
                            if (!jQuery.isEmptyObject(result)) {
                                product[field.name] = result;
                            }
                            search_set();
                        };
                        new Sao.Window.Search(relation,
                                callback, {
                                    sel_multi: true,
                                    context: context,
                                    domain: domain,
                                    order: order,
                                    search_filter: '',
                                    title: this.attributes.string

                        });
                    };

                    const make_product = () => {
                        this._popup = false;
                        screen.group.remove(first, true);
                        if (jQuery.isEmptyObject(product)) {
                            return;
                        }

                        var fields = Object.keys(product);
                        var values = fields.map(function(field) {
                            return product[field];
                        });
                        Sao.common.product(values).forEach(function(values) {
                            screen.new_(false).then(function(record) {
                                var default_value = jQuery.extend({}, default_);
                                fields.forEach(function(field, i) {
                                    default_value[field] = values[i][0];
                                    default_value[field + '.rec_name'] = values[i][1];
                                });
                                record.set_default(default_value);
                            });
                        });
                        var sequence = this._sequence();
                        if (sequence) {
                            screen.group.set_sequence(
                                sequence, screen.new_position);
                        }
                    };

                    search_set();
                });
            });
        },
        open: function(event_) {
            return this.edit();
        },
        delete_: function(event_) {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name)['delete'] ||
                !this.screen.deletable) {
                return;
            }
            this.screen.remove(false, false, false);
        },
        undelete: function(event_) {
            this.screen.unremove();
        },
        previous: function(event_) {
            return this.validate().then(() => this.screen.display_previous());
        },
        next: function(event_) {
            return this.validate().then(() => this.screen.display_next());
        },
        switch_: function(event_) {
            return this.screen.switch_view().then(() => {
                this._set_button_sensitive();
                this._update_line_chips();
            });
        },
        edit: function() {
            if (!Sao.common.MODELACCESS.get(this.screen.model_name).read) {
                return;
            }
            return this.validate().then(() => {
                var record = this.screen.current_record;
                if (record) {
                    if (this._popup) {
                        return;
                    } else {
                        this._popup = true;
                    }
                    new Sao.Window.Form(this.screen, () => {
                        this._popup = false;
                    });
                }
            });
        },
        key_press: function(event_) {
            if (event_.which == Sao.common.F3_KEYCODE) {
                event_.preventDefault();
                this.new_();
            } else if (event_.which ==  Sao.common.F2_KEYCODE) {
                event_.preventDefault();
                this.add(event_);
            }
            if (this.attributes.add_remove) {
                var activate_keys = [Sao.common.TAB_KEYCODE];
                if (!this.wid_completion) {
                    activate_keys.push(Sao.common.RETURN_KEYCODE);
                }
                if (~activate_keys.indexOf(event_.which) && this.wid_text.val()) {
                    this.add(event_);
                }
            }
        },
        record_message: function(position, size) {
            this._position = position;
            this._length = size;
            var name = "_";
            if (position) {
                var selected = this.screen.selected_records.length;
                name = ' ' + position;
                if (selected > 1) {
                    name += '#' + selected;
                }
            }
            var message = name + ' / ' + Sao.common.humanize(size);
            this.label.text(message).attr('title', message);
            this.prm.done(() => this._set_button_sensitive());
            this.prm.done(() => this._update_line_chips());
        },
        validate: function() {
            var prm = jQuery.Deferred();
            this.view.set_value();
            var record = this.screen.current_record;
            if (record) {
                var fields = this.screen.current_view.get_fields();
                record.validate(fields).then(validate => {
                    if (!validate) {
                        this.screen.display(true);
                        prm.reject();
                        return;
                    }
                    if (this.screen.pre_validate) {
                        return record.pre_validate().then(
                            prm.resolve, prm.reject);
                    }
                    prm.resolve();
                });
            } else {
                prm.resolve();
            }
            return prm;
        },
        set_value: function() {
            this.screen.save_tree_state();
            if (this.screen.modified()) {  // TODO check if required
                this.view.screen.record_modified(false);
            }
        },
        _update_completion: function(text) {
            if (!this.record) {
                return jQuery.when();
            }
            var model = this.attributes.relation;
            var domain = this.field.get_domain(this.record);
            domain = [domain,
                this.record.expr_eval(this.attributes.add_remove)];
            var removed_ids = this.field.get_removed_ids(this.record);
            domain = ['OR', domain, ['id', 'in', removed_ids]];
            return Sao.common.update_completion(
                this.wid_text, this.record, this.field, model, domain);
        },
        _completion_match_selected: function(value) {
            if (value.id !== null) {
                this.screen.group.load([value.id], true);
                this.wid_text.val('');
            } else {
                this.new_(value.defaults);
            }
        },
        _completion_action_activated: function(action) {
            if (action == 'search') {
                this.add();
            } else if (action == 'create') {
                this.new_();
            }
        },
    });

    Sao.View.Form.Many2Many = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-many2many',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.Many2Many._super.init.call(this, view, attributes);

            this._readonly = true;
            this._required = false;
            this._position = 0;

            this.el = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default'
            });
            this.menu = jQuery('<div/>', {
                'class': this.class_ + '-menu panel-heading'
            });
            this.el.append(this.menu);

            this.title = jQuery('<label/>', {
                'class': this.class_ + '-string',
                text: attributes.string
            });
            this.menu.append(this.title);

            this.title.uniqueId();
            this.el.uniqueId();
            this.el.attr('aria-labelledby', this.title.attr('id'));
            this.title.attr('for', this.el.attr('id'));

            var toolbar = jQuery('<div/>', {
                'class': this.class_ + '-toolbar'
            });
            this.menu.append(toolbar);

            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(toolbar);
            this.entry = jQuery('<input/>', {
                type: 'text',
                'class': 'form-control input-sm mousetrap',
                'name': attributes.name,
            }).appendTo(group);
            // Use keydown to not receive focus-in TAB
            this.entry.on('keydown', this.key_press.bind(this));

            if (!attributes.completion || attributes.completion == '1') {
                this.wid_completion = Sao.common.get_completion(
                    group,
                    this._update_completion.bind(this),
                    this._completion_match_selected.bind(this),
                    this._completion_action_activated.bind(this),
                    this.read_access, this.create_access);
                this.entry.completion = this.wid_completion;
            }

            var buttons = jQuery('<div/>', {
                'class': 'input-group-btn'
            }).appendTo(group);
            this.but_add = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Add"),
                'title': Sao.i18n.gettext("Add"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-add')
            ).appendTo(buttons);
            this.but_add.click(this.add.bind(this));

            this.label = jQuery('<span/>', {
                'class': 'badge',
            }).text('_ / 0'
            ).appendTo(jQuery('<span/>', {
                'class': 'btn hidden-xs',
            }).appendTo(buttons));

            this.but_remove = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'tabindex': -1,
                'aria-label': Sao.i18n.gettext("Remove"),
                'title': Sao.i18n.gettext("Remove"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
            ).appendTo(buttons);
            this.but_remove.click(this.remove.bind(this));

            this.content = jQuery('<div/>', {
                'class': this.class_ + '-content panel-body'
            });
            this.el.append(this.content);
            var model = attributes.relation;
            var breadcrumb = jQuery.extend([], this.view.screen.breadcrumb);
            breadcrumb.push(attributes.string || Sao.common.MODELNAME.get(model));
            this.screen = new Sao.Screen(attributes.relation, {
                mode: ['tree'],
                view_ids: (attributes.view_ids || '').split(','),
                views_preload: attributes.views || {},
                order: attributes.order,
                row_activate: this.activate.bind(this),
                readonly: true,
                relation_widget: 'many2many',
                limit: null,
                context: this.view.screen.context,
                breadcrumb: breadcrumb,
            });
            this.screen.windows.push(this);
            this.prm = this.screen.switch_view('tree').done(() => {
                this.content.append(this.screen.screen_container.el);
            });
            this._popup = false;
        },
        get_access: function(type) {
            var model = this.attributes.relation;
            if (model) {
                return Sao.common.MODELACCESS.get(model)[type];
            }
            return true;
        },
        get read_access() {
            return this.get_access('read');
        },
        get create_access() {
            var create = this.attributes.create;
            if (create === undefined) {
                create = true;
            } else if (typeof create == 'string') {
                create = Boolean(parseInt(create, 10));
            }
            return create && this.get_access('create');
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Many2Many._super.set_readonly.call(this, readonly);
            this._set_button_sensitive();
            this._set_label_state();
        },
        set_required: function(required) {
            this._required = required;
            this._set_label_state();
        },
        _set_label_state: function() {
            Sao.common.apply_label_attributes(this.title, this._readonly,
                    this._required);
        },
        _set_button_sensitive: function() {
            var size_limit = false,
                record = this.record,
                field = this.field;
            if (record && field) {
                var field_size = record.expr_eval(this.attributes.size);
                var m2m_size = field.get_eval(record).length;
                size_limit = (((field_size !== undefined) &&
                            (field_size !== null)) &&
                        (m2m_size >= field_size) && (field_size >= 0));
            }

            this.entry.prop('disabled', this._readonly);
            this.but_add.prop('disabled', this._readonly || size_limit);
            this.but_remove.prop('disabled', this._readonly ||
                this._position === 0);
        },
        record_message: function(position, size) {
            this._position = position;
            var name = "_";
            if (position) {
                var selected = this.screen.selected_records.length;
                name = ' ' + position;
                if (selected > 1) {
                    name += '#' + selected;
                }
            }
            var message = name + ' / ' + Sao.common.humanize(size);
            this.label.text(message).attr('title', message);
            this._set_button_sensitive();
        },
        display: function() {
            Sao.View.Form.Many2Many._super.display.call(this);

            this.prm.done(() => {
                var record = this.record;
                var field = this.field;

                if (!field) {
                    this.screen.new_group();
                    this.screen.current_record = null;
                    this.screen.group.parent = null;
                    this.screen.display();
                    return;
                }
                var new_group = record.field_get_client(this.field_name);
                if (new_group != this.screen.group) {
                    this.screen.set_group(new_group);
                }
                this.screen.display();
                if (this.attributes.height !== undefined) {
                    this.screen.current_view.el
                        .find('.treeview,.list-form').first()
                        .css('min-height', this.attributes.height + 'px')
                        .css('max-height', this.attributes.height + 'px');
                }
            });
        },
        focus: function() {
            this.entry.focus();
        },
        activate: function() {
            this.edit();
        },
        add: function() {
            var domain = this.field.get_domain(this.record);
            var add_remove = this.record.expr_eval(
                this.attributes.add_remove);
            if (!jQuery.isEmptyObject(add_remove)) {
                domain = [domain, add_remove];
            }
            var context = this.field.get_search_context(this.record);
            var order = this.field.get_search_order(this.record);
            var value = this.entry.val();

            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }

            const callback = result => {
                if (!jQuery.isEmptyObject(result)) {
                    var ids = [];
                    var i, len;
                    for (i = 0, len = result.length; i < len; i++) {
                        ids.push(result[i][0]);
                    }
                    this.screen.group.load(ids, true);
                    this.screen.display();
                }
                this.entry.val('');
                this._popup = false;
            };
            var parser = new Sao.common.DomainParser();
            new Sao.Window.Search(this.attributes.relation,
                    callback, {
                        sel_multi: true,
                        context: context,
                        domain: domain,
                        order: order,
                        view_ids: (this.attributes.view_ids ||
                            '').split(','),
                        views_preload: this.attributes.views || {},
                        new_: this.create_access,
                        search_filter: parser.quote(value),
                        title: this.attributes.string
                    });
        },
        remove: function() {
            //TRADON MODIF
            this.screen.remove(false, true, false);
        },
        key_press: function(event_) {
            var activate_keys = [Sao.common.TAB_KEYCODE];
            if (!this.wid_completion) {
                activate_keys.push(Sao.common.RETURN_KEYCODE);
            }

            if (event_.which == Sao.common.F3_KEYCODE) {
                event_.preventDefault();
                this.new_();
            } else if (event_.which == Sao.common.F2_KEYCODE) {
                event_.preventDefault();
                this.add();
            } else if (~activate_keys.indexOf(event_.which) && this.entry.val()) {
                this.add();
            }
        },
        _get_screen_form: function() {
            var domain = this.field.get_domain(this.record);
            var add_remove = this.record.expr_eval(
                    this.attributes.add_remove);
            if (!jQuery.isEmptyObject(add_remove)) {
                domain = [domain, add_remove];
            }
            var context = this.field.get_context(this.record);
            var view_ids = (this.attributes.view_ids || '').split(',');
            if (!jQuery.isEmptyObject(view_ids)) {
                // Remove the first tree view as mode is form only
                view_ids.shift();
            }
            var model = this.attributes.relation;
            var breadcrumb = jQuery.extend([], this.view.screen.breadcrumb);
            breadcrumb.push(this.attributes.string || Sao.common.MODELNAME.get(model));
            return new Sao.Screen(model, {
                'domain': domain,
                'view_ids': view_ids,
                'mode': ['form'],
                'views_preload': this.attributes.views,
                'context': context,
                'breadcrumb': breadcrumb,
            });
        },
        edit: function() {
            if (jQuery.isEmptyObject(this.screen.current_record)) {
                return;
            }
            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            // Create a new screen that is not linked to the parent otherwise
            // on the save of the record will trigger the save of the parent
            var screen = this._get_screen_form();
            const callback = result => {
                if (result) {
                    screen.current_record.save().done(() => {
                        var added = 'id' in this.screen.current_record.modified_fields;
                        // Force a reload on next display
                        this.screen.current_record.cancel();
                        if (added) {
                            this.screen.current_record.modified_fields.id = true;
                        }
                    });
                }
                this._popup = false;
            };
            screen.switch_view().done(() => {
                screen.load([this.screen.current_record.id]);
                screen.current_record = screen.group.get(
                    this.screen.current_record.id);
                new Sao.Window.Form(screen, callback);
            });
        },
        new_: function(defaults=null) {
            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }
            var screen = this._get_screen_form();
            if (defaults) {
                defaults = jQuery.extend({}, defaults);
            } else {
                defaults = {};
            }
            defaults.rec_name = this.entry.val();

            const callback = result => {
                if (result) {
                    var record = screen.current_record;
                    this.screen.group.load([record.id], true);
                }
                this.entry.val('');
                this._popup = false;
            };
            screen.switch_view().done(() => {
                new Sao.Window.Form(screen, callback, {
                    'new_': true,
                    'save_current': true,
                    'defaults': defaults,
                });
            });
        },
        _update_completion: function(text) {
            if (!this.record) {
                return jQuery.when();
            }
            var model = this.attributes.relation;
            var domain = this.field.get_domain(this.record);
            var add_remove = this.record.expr_eval(
                this.attributes.add_remove);
            if (!jQuery.isEmptyObject(add_remove)) {
                domain = [domain, add_remove];
            }
            return Sao.common.update_completion(
                this.entry, this.record, this.field, model, domain);
        },
        _completion_match_selected: function(value) {
            if (value.id !== null) {
                this.screen.group.load([value.id], true);
                this.entry.val('');
            } else {
                this.new_(value.defaults);
            }
        },
        _completion_action_activated: function(action) {
            if (action == 'search') {
                this.add();
            } else if (action == 'create') {
                this.new_();
            }
        },
    });

    Sao.View.Form.BinaryMixin = Sao.class_(Sao.View.Form.Widget, {
        init: function(view, attributes) {
            Sao.View.Form.BinaryMixin._super.init.call(
                this, view, attributes);
            this.filename = attributes.filename || null;
        },
        toolbar: function(class_) {
            var group = jQuery('<div/>', {
                'class': class_,
                'role': 'group'
            });

            this.but_save_as = jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'aria-label': Sao.i18n.gettext("Save As"),
                'title': Sao.i18n.gettext("Save As..."),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-download')
            ).appendTo(group);
            this.but_save_as.click(this.save_as.bind(this));

            this.input_select = jQuery('<input/>', {
                'type': 'file',
            }).change(this.select.bind(this));
            this.but_select = jQuery('<div/>', {
                'class': 'btn btn-default input-file',
                'type': 'button',
                'aria-label': Sao.i18n.gettext("Select"),
                'title': Sao.i18n.gettext("Select..."),
            }).append(this.input_select
            ).append(Sao.common.ICONFACTORY.get_icon_img('tryton-search')
            ).appendTo(group);
            this.but_select
                .on('dragover', false)
                .on('drop', this.select_drop.bind(this));

            this.but_clear = jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'aria-label': Sao.i18n.gettext("Clear"),
                'title': Sao.i18n.gettext("Clear"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-clear')
            ).appendTo(group);
            this.but_clear.click(this.clear.bind(this));

            return group;
        },
        get filename_field() {
            if (this.filename) {
                var record = this.record;
                if (record) {
                    return record.model.fields[this.filename];
                }
            }
            return null;
        },
        update_buttons: function(value) {
            if (value) {
                this.but_save_as.show();
                this.but_select.hide();
                this.but_clear.show();
            } else {
                this.but_save_as.hide();
                this.but_select.show();
                this.but_clear.hide();
            }
        },
        select: function() {
            var record = this.record,
                field = this.field,
                filename_field = this.filename_field;

            Sao.common.get_input_data(this.input_select, function(data, filename) {
                field.set_client(record, data);
                if (filename_field) {
                    filename_field.set_client(record, filename);
                }
            }, !field.get_size);
        },
        select_drop: function(evt) {
            evt.preventDefault();
            evt.stopPropagation();
            evt = evt.originalEvent;
            var files = [];
            if (evt.dataTransfer.items) {
                Sao.Logger.debug("Select drop items:", evt.dataTransfer.items);
                for (let i=0; i < evt.dataTransfer.items.length; i++) {
                    let file = evt.dataTransfer.items[i].getAsFile();
                    if (file) {
                        files.push(file);
                    }
                }
            } else {
                for (let i=0; i < evt.dataTransfer.files.length; i++) {
                    let file = evt.dataTransfer.files[i];
                    if (file) {
                        files.push(file);
                    }
                }
            }
            for (const file of files) {
                Sao.common.get_file_data(file, (data, filename) => {
                    this.field.set_client(this.record, data);
                    if (this.filename_field) {
                        this.filename_field.set_client(this.record, filename);
                    }
                });
            }
        },
        open: function() {
            this.save_as();
        },
        save_as: function() {
            var field = this.field;
            var record = this.record;
            var prm;
            if (field.get_data) {
                prm = field.get_data(record);
            } else {
                prm = jQuery.when(field.get(record));
            }
            prm.done(data => {
                var name;
                var field = this.filename_field;
                if (field) {
                    name = field.get(this.record);
                }
                Sao.common.download_file(data, name);
            });
        },
        clear: function() {
            this.input_select.val(null);
            var filename_field = this.filename_field;
            if (filename_field) {
                filename_field.set_client(this.record, null);
            }
            this.field.set_client(this.record, null);
        }
    });

    Sao.View.Form.Binary = Sao.class_(Sao.View.Form.BinaryMixin, {
        class_: 'form-binary',
        blob_url: '',
        init: function(view, attributes) {
            Sao.View.Form.Binary._super.init.call(this, view, attributes);

            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(this.el);

            this.size = jQuery('<input/>', {
                type: 'input',
                'class': 'form-control input-sm',
                'readonly': true,
                'name': attributes.name,
            }).appendTo(group);

            if (this.filename && attributes.filename_visible) {
                this.text = jQuery('<input/>', {
                    type: 'input',
                    'class': 'form-control input-sm'
                }).prependTo(group);
                this.text.change(this.focus_out.bind(this));
                // Use keydown to not receive focus-in TAB
                this.text.on('keydown', this.key_press.bind(this));
                this.text.css('width', '50%');
                this.size.css('width', '50%');

                this.but_open = jQuery('<button/>', {
                    'class': 'btn btn-default',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Open..."),
                    'title': Sao.i18n.gettext("Open..."),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-open')
                ).appendTo(jQuery('<span/>', {
                    'class': 'input-group-btn',
                }).prependTo(group));
                this.but_open.click(this.open.bind(this));
            }

            this.toolbar('input-group-btn').appendTo(group);
        },
        display: function() {
            Sao.View.Form.Binary._super.display.call(this);

            var record = this.record, field = this.field;
            if (!field) {
                if (this.text) {
                    this.text.val('');
                }
                this.size.val('');
                this.but_save_as.hide();
                return;
            }
            var size;
            if (field.get_size) {
                size = field.get_size(record);
            } else {
                size = field.get(record).length;
            }
            this.size.val(Sao.common.humanize(size, 'B'));

            if (this.text) {
                this.text.val(this.filename_field.get(record) || '');
                if (size) {
                    this.but_open.parent().show();
                } else {
                    this.but_open.parent().hide();
                }
            }
            this.update_buttons(Boolean(size));
        },
        key_press: function(evt) {
            var editable = !this.text.prop('readonly');
            if (evt.which == Sao.common.F3_KEYCODE && editable) {
                evt.preventDefault();
                this.new_();
            } else if (evt.which == Sao.common.F2_KEYCODE) {
                evt.preventDefault();
                this.open();
            }
        },
        set_value: function() {
            if (this.text) {
                this.filename_field.set_client(this.record,
                        this.text.val() || '');
            }
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Binary._super.set_readonly.call(this, readonly);
            this.but_select.prop('disabled', readonly);
            this.but_clear.prop('disabled', readonly);
            if (this.text) {
                this.text.prop('readonly', readonly);
            }
        }
    });

    Sao.View.Form.MultiSelection = Sao.class_(Sao.View.Form.Selection, {
        class_: 'form-multiselection',
        expand: true,
        init: function(view, attributes) {
            this.nullable_widget = false;
            Sao.View.Form.MultiSelection._super.init.call(
                this, view, attributes);
            this.select.prop('multiple', true);
        },
        set_selection: function(selection, help) {
            Sao.View.Form.MultiSelection._super.set_selection.call(
                this, selection, help);
            var widget_help = this.attributes.help;
            if (widget_help) {
                this.select.children().each(function() {
                    var option = jQuery(this);
                    var help = option.attr('title');
                    if (help) {
                        help = widget_help + '\n' + help;
                        option.attr('title', help);
                    }
                });
            }
        },
        get modified() {
            if (this.record && this.field) {
                var group = new Set(this.field.get_eval(this.record));
                var value = new Set(this.get_value());
                return !Sao.common.compare(value, group);
            }
            return false;
        },
        display_update_selection: function() {
            var record = this.record;
            var field = this.field;
            this.update_selection(record, field, () => {
                var yexpand = this.attributes.yexpand;
                if (yexpand === undefined) {
                    yexpand = this.expand;
                }
                if (!yexpand) {
                    this.select.prop('size', this.select.children().length);
                }
                if (!field) {
                    return;
                }
                var value = field.get_eval(record);
                value = value.map(function(e) { return JSON.stringify(e); });
                this.select.val(value);
            });
        },
        get_value: function() {
            var value = this.select.val();
            if (value) {
                return value.map(function(e) { return JSON.parse(e); });
            }
            return [];
        },
    });

    Sao.View.Form.Image = Sao.class_(Sao.View.Form.BinaryMixin, {
        class_: 'form-image',
        init: function(view, attributes) {
            Sao.View.Form.Image._super.init.call(this, view, attributes);
            this.height = parseInt(attributes.height || 100, 10);
            this.width = parseInt(attributes.width || 300, 10);

            this.el = jQuery('<div/>', {
                'class': this.class_ + ' thumbnail',
            });
            this.image = jQuery('<img/>', {
                'class': 'center-block'
            }).appendTo(this.el);
            this.el
                .on('dragover', false)
                .on('drop', this.select_drop.bind(this));
            this.image.css('max-height', this.height);
            this.image.css('max-width', this.width);
            this.image.css('height', 'auto');
            this.image.css('width', 'auto');
            switch (attributes.border) {
                case 'rounded':
                    this.image.addClass('img-rounded');
                    break;
                case 'circle':
                    this.image.addClass('img-circle');
                    break;
                default:
                    break;
            }
            var group = this.toolbar('btn-group');
            if (!attributes.readonly) {
                jQuery('<div/>', {
                    'class': 'text-center caption',
                }).append(group).appendTo(this.el);
            }
            this._readonly = false;
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Image._super.set_readonly.call(this, readonly);
            this._readonly = readonly;
            this.but_select.prop('disabled', readonly);
            this.but_clear.prop('disabled', readonly);
        },
        select_drop: function(evt) {
            if (this._readonly) {
                return;
            }
            Sao.View.Form.Image._super.select_drop.call(this, evt);
            this.update_img();
        },
        clear: function() {
            Sao.View.Form.Image._super.clear.call(this);
            this.update_img();
        },
        update_img: function() {
            var value;
            var record = this.record;
            if (record) {
                value = record.field_get_client(this.field_name);
            }
            if (value) {
                if (value > Sao.config.image_max_size) {
                    value = jQuery.when(null);
                } else {
                    value = record.model.fields[this.field_name]
                        .get_data(record);
                }
            } else {
                value = jQuery.when(null);
            }
            value.done(data => {
                if (record !== this.record) {
                    return;
                }
                this.image.attr('src', Sao.common.image_url(data));
                this.update_buttons(Boolean(data));
            });
        },
        display: function() {
            Sao.View.Form.Image._super.display.call(this);
            this.update_img();
        }
    });

    Sao.View.Form.Document = Sao.class_(Sao.View.Form.BinaryMixin, {
        class_: 'form-document',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.Document._super.init.call(this, view, attributes);

            this._blob_url = null;
            this.el = jQuery('<div/>', {
                'class': this.class_,
            });

            this.object = jQuery('<object/>', {
                'class': 'center-block',
            }).appendTo(this.el);
            if (attributes.height) {
                this.object.css('height', parseInt(attributes.height, 10));
            }
            if (attributes.width) {
                this.object.css('width', parseInt(attributes.width, 10));
            }
        },
        display: function() {
            Sao.View.Form.Document._super.display.call(this);
            var data, filename;
            var record = this.record;
            if (record) {
                data = record.model.fields[this.field_name].get_data(record);
            } else {
                data = jQuery.when(null);
            }
            var filename_field = this.filename_field;
            if (filename_field) {
                filename = filename_field.get_client(record);
            }
            data.done(data => {
                var url, blob;
                if (record !== this.record) {
                    return;
                }
                // in case onload was not yet triggered
                window.URL.revokeObjectURL(this.object.attr('data'));
                if (!data) {
                    url = null;
                } else {
                    var mimetype = Sao.common.guess_mimetype(filename);
                    if (mimetype == 'application/octet-binary') {
                        mimetype = null;
                    }
                    blob = new Blob([data], {
                        'type': mimetype,
                    });
                    url = window.URL.createObjectURL(blob);
                }
                // duplicate object to force refresh on buggy browsers
                const object = this.object.clone();
                // set onload before data to be always called
                object.get(0).onload = function() {
                    this.onload = null;
                    window.URL.revokeObjectURL(url);
                };
                object.attr('data', url);
                this.object.replaceWith(object);
                this.object = object;
            });
        },
    });

    Sao.View.Form.URL = Sao.class_(Sao.View.Form.Char, {
        class_: 'form-url',
        _type: 'url',
        init: function(view, attributes) {
            Sao.View.Form.URL._super.init.call(this, view, attributes);
            this.input.attr('type', this._type);
            this.button = this.labelled = jQuery('<a/>', {
                'class': 'btn btn-default',
                'target': '_blank',
                'rel': 'noreferrer noopener',
            }).appendTo(jQuery('<span/>', {
                'class': 'input-group-btn'
            }).appendTo(this.group));
            this.icon = jQuery('<img/>').appendTo(this.button);
            this.set_icon();
        },
        display: function() {
            Sao.View.Form.URL._super.display.call(this);
            var url = '';
            var record = this.record;
            if (record) {
                url = record.field_get_client(this.field_name);
            }
            this.set_url(url);
            if (record & this.attributes.icon) {
                var icon = this.attributes.icon;
                var value;
                if (icon in record.model.fields) {
                    value = record.field_get_client(icon);
                } else {
                    value = icon;
                }
                this.set_icon(value);
            }
        },
        set_icon: function(value) {
            value = value || 'tryton-public';
            Sao.common.ICONFACTORY.get_icon_url(value).done(url => {
                this.icon.attr('src', url);
            });
        },
        set_url: function(value) {
            this.button.attr('href', value);
            this.button.toggle(Boolean(value));
        },
        set_invisible: function(invisible) {
            Sao.View.Form.URL._super.set_invisible.call(this, invisible);
            if (invisible) {
                this.input.attr('type', '');
            } else {
                this.input.attr('type', this._type);
            }
        },
    });

    Sao.View.Form.Email = Sao.class_(Sao.View.Form.URL, {
        class_: 'form-email',
        _type: 'email',
        set_url: function(value) {
            Sao.View.Form.Email._super.set_url.call(this, 'mailto:' + value);
        }
    });

    Sao.View.Form.CallTo = Sao.class_(Sao.View.Form.URL, {
        class_: 'form-callto',
        set_url: function(value) {
            Sao.View.Form.CallTo._super.set_url.call(this, 'callto:' + value);
        }
    });

    Sao.View.Form.SIP = Sao.class_(Sao.View.Form.URL, {
        class_: 'form-sip',
        set_url: function(value) {
            Sao.View.Form.SIP._super.set_url.call(this, 'sip:' + value);
        }
    });

    Sao.View.Form.HTML = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-html',
        init: function(view, attributes) {
            Sao.View.Form.HTML._super.init.call(this, view, attributes);
            Sao.View.Form.TranslateMixin.init.call(this);
            this.el = jQuery('<div/>', {
                'class': this.class_,
            });
            this.button = jQuery('<a/>', {
                'class': 'btn btn-lnk',
                'target': '_blank',
                'rel': 'noreferrer noopener',
            }).text(attributes.string).appendTo(this.el);
            if (attributes.translate) {
                var button = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext('Translate'),
                    'title': Sao.i18n.gettext("Translate"),
                }).appendTo(this.el);
                button.append(
                    Sao.common.ICONFACTORY.get_icon_img('tryton-translate'));
                button.click(this.translate.bind(this));
            }
        },
        uri: function(language) {
            var record = this.record,
                uri;
            if (!record || (record.id < 0)) {
                uri = '';
            } else {
                uri = '/' + record.model.session.database +
                    '/ir/html/' + record.model.name + '/' + record.id + '/' +
                    this.field_name;
                uri += '?language=' + encodeURIComponent(
                    language || Sao.i18n.getlang());
                uri += '&title=' + encodeURIComponent(Sao.config.title);
            }
            return uri;
        },
        display: function() {
            Sao.View.Form.HTML._super.display.call(this);
            this.button.attr('href', this.uri());
        },
        set_readonly: function(readonly) {
            Sao.View.Form.HTML._super.set_readonly.call(this, readonly);
            this.el.find('button').prop('disabled', readonly);
            if (readonly) {
                this.el.find('a').hide();
            } else {
                this.el.find('a').show();
            }
        },
        translate_dialog: function(languages) {
            var options = {};
            for (const language of languages) {
                options[language.name] = language.code;
            }
            Sao.common.selection(Sao.i18n.gettext("Choose a language"), options)
            .done(language => {
                window.open(this.uri(language), '_blank', 'noreferrer,noopener');
            });
        },
    });

    Sao.View.Form.ProgressBar = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-char',
        init: function(view, attributes) {
            Sao.View.Form.ProgressBar._super.init.call(
                this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_ + ' progress'
            });
            this.progressbar = jQuery('<div/>', {
                'class': 'progress-bar',
                'role': 'progressbar',
                'aria-valuemin': 0,
                'aria-valuemax': 100
            }).appendTo(this.el);
            this.progressbar.css('min-width: 2em');
        },
        display: function() {
            Sao.View.Form.ProgressBar._super.display.call(this);
            var value, text;
            var record = this.record;
            var field = this.field;
            if (!field) {
                value = 0;
                text = '';
            } else {
                value = field.get(record);
                text = field.get_client(record, 100);
                if (text) {
                    text = Sao.i18n.gettext('%1%', text);
                }
            }
            this.progressbar.attr('aria-valuenow', value * 100);
            this.progressbar.css('width', value * 100 + '%');
            this.progressbar.text(text);
        }
    });

    Sao.View.Form.RichGraph = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-rich-graph',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.RichGraph._super.init.call(this, view, attributes);
            this.series_visibility = {};
            this.active_range = attributes.default_range || 'ALL';
            this.range_options = ['5D', '1M', '3M', '6M', '1Y', '5Y', 'ALL'];
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var header = jQuery('<div/>', {
                'class': this.class_ + '-header'
            }).appendTo(this.el);
            this.title = jQuery('<div/>', {
                'class': this.class_ + '-title'
            }).appendTo(header);
            this.meta = jQuery('<div/>', {
                'class': this.class_ + '-meta'
            }).appendTo(header);
            this.ranges = jQuery('<div/>', {
                'class': this.class_ + '-ranges'
            }).appendTo(header);
            for (const range of this.range_options) {
                jQuery('<button/>', {
                    type: 'button',
                    'class': this.class_ + '-range',
                    text: range
                }).toggleClass('active', range == this.active_range)
                    .on('click', () => {
                        this.active_range = range;
                        this.ranges.children().removeClass('active');
                        this.ranges.children().filter(function() {
                            return jQuery(this).text() == range;
                        }).addClass('active');
                        this._render(this._records());
                    }).appendTo(this.ranges);
            }
            this.stage = jQuery('<div/>', {
                'class': this.class_ + '-stage'
            }).appendTo(this.el);
            this.svg = jQuery(document.createElementNS(
                'http://www.w3.org/2000/svg', 'svg'));
            this.svg.attr({
                'class': this.class_ + '-svg',
                'role': 'img'
            }).appendTo(this.stage);
            this.tooltip = jQuery('<div/>', {
                'class': this.class_ + '-tooltip'
            }).appendTo(this.stage).hide();
            this.legend = jQuery('<div/>', {
                'class': this.class_ + '-legend'
            }).appendTo(this.el);
        },
        display: function() {
            Sao.View.Form.RichGraph._super.display.call(this);
            var source_field = this._source_field();
            var source_prm = jQuery.when();
            if (source_field && this.record &&
                    this.record.model.fields[source_field]) {
                source_prm = this.record.load(source_field);
            }
            source_prm.always(() => {
                var records = this._records();
                var fields = this._fields_to_load();
                var prms = [];
                for (const record of records) {
                    for (const field of fields) {
                        if (record.model.fields[field]) {
                            prms.push(record.load(field));
                        }
                    }
                }
                jQuery.when.apply(jQuery, prms).always(() => {
                    this._render(records);
                });
            });
        },
        _split: function(value) {
            if (!value) {
                return [];
            }
            return String(value).split(',').map(function(item) {
                return item.trim();
            }).filter(Boolean);
        },
        _map: function(value) {
            var result = {};
            for (const item of this._split(value)) {
                var pos = item.indexOf(':');
                if (pos > -1) {
                    result[item.slice(0, pos).trim()] = item.slice(pos + 1).trim();
                }
            }
            return result;
        },
        _records: function() {
            var source_field = this._source_field();
            if (source_field && this.record &&
                    this.record.model.fields[source_field]) {
                var related = this.record.field_get_client(source_field) || [];
                var related_records = [];
                for (const record of related) {
                    related_records.push(record);
                }
                return related_records;
            }
            var records = [];
            var group = this.view.screen.group || [];
            for (const record of group) {
                records.push(record);
            }
            if (!records.length && this.record) {
                records.push(this.record);
            }
            return records;
        },
        _source_field: function() {
            return this.attributes.records_field || this.attributes.data_field ||
                this.attributes.source_field;
        },
        _series_names: function() {
            var names = [this.attributes.name].concat(
                this._split(this.attributes.series));
            var seen = {};
            return names.filter(function(name) {
                if (!name || seen[name]) {
                    return false;
                }
                seen[name] = true;
                return true;
            });
        },
        _date_field: function() {
            return this.attributes.date_field || this.attributes.x_field ||
                this.attributes.date || 'date';
        },
        _fields_to_load: function() {
            return [this._date_field()].concat(this._series_names());
        },
        _number: function(value) {
            if ((value === null) || (value === undefined) || (value === '')) {
                return null;
            }
            value = Number(value);
            if (isNaN(value)) {
                return null;
            }
            return value;
        },
        _svg_el: function(tag, attrs) {
            var el = document.createElementNS('http://www.w3.org/2000/svg', tag);
            for (const name in attrs) {
                if (attrs[name] === null || attrs[name] === undefined) {
                    continue;
                }
                if (name == 'text') {
                    el.textContent = attrs[name];
                } else {
                    el.setAttribute(name, attrs[name]);
                }
            }
            return jQuery(el);
        },
        _x_value: function(record, field_name, index) {
            var field = record.model.fields[field_name];
            if (!field) {
                return index;
            }
            var value = field.get(record);
            if (value && (value.isDate || value.isDateTime)) {
                value = value.valueOf ? value.valueOf() : Date.parse(value.toString());
            } else if (value instanceof Date) {
                value = value.getTime();
            } else if (typeof value == 'string') {
                var parsed = Date.parse(value);
                value = isNaN(parsed) ? index : parsed;
            }
            return this._number(value) === null ? index : Number(value);
        },
        _is_time_x: function(value) {
            var date_field = this._date_field();
            var model = this.view.screen && this.view.screen.model;
            var field = model && model.fields[date_field];
            var type = field && field.description.type;
            return (type == 'date') || (type == 'datetime') ||
                Math.abs(Number(value)) > 100000000000;
        },
        _label_for: function(name, labels, records) {
            if (labels[name]) {
                return labels[name];
            }
            var model = records && records.length ? records[0].model :
                this.view.screen.model;
            var field = model.fields[name];
            return field && field.description.string || name;
        },
        _color_for: function(name, index, colors) {
            var palette = ['#00b894', '#8b5cf6', '#f59f00', '#e03131',
                '#228be6', '#15aabf', '#f06595', '#868e96'];
            return colors[name] || palette[index % palette.length];
        },
        _collect: function(records) {
            var date_field = this._date_field();
            var labels = this._map(this.attributes.labels);
            var colors = this._map(this.attributes.colors);
            var hidden = this._split(this.attributes.hidden_series);
            var dashed = this._split(this.attributes.dashed_series);
            var series = [];
            var names = this._series_names();
            for (var s = 0; s < names.length; s++) {
                var name = names[s];
                if (!(name in this.series_visibility)) {
                    this.series_visibility[name] = hidden.indexOf(name) < 0;
                }
                series.push({
                    name: name,
                    label: this._label_for(name, labels, records),
                    color: this._color_for(name, s, colors),
                    dashed: dashed.indexOf(name) >= 0,
                    visible: this.series_visibility[name],
                    values: []
                });
            }
            for (var i = 0; i < records.length; i++) {
                var record = records[i];
                var x = this._x_value(record, date_field, i);
                for (const serie of series) {
                    var field = record.model.fields[serie.name];
                    var value = field ? this._number(field.get(record)) : null;
                    if (value !== null) {
                        serie.values.push({x: x, y: value, record: record});
                    }
                }
            }
            for (const serie of series) {
                serie.values.sort(function(a, b) {
                    return a.x - b.x;
                });
            }
            return series;
        },
        _format_x: function(value) {
            var date_field = this._date_field();
            var field = this.view.screen.model.fields[date_field];
            var type = field && field.description.type;
            if ((type == 'date') || (type == 'datetime') || this._is_time_x(value)) {
                var format = Sao.common.date_format(
                    this.view.screen.context.date_format);
                if (type == 'datetime') {
                    return Sao.common.format_datetime(format + ' %X',
                        moment(value));
                }
                return Sao.common.format_date(format, moment(value));
            }
            return String(value);
        },
        _format_x_tick: function(value, min_x, max_x) {
            if (!this._is_time_x(value)) {
                return String(Math.round(value));
            }
            var span = max_x - min_x;
            var date = moment(value);
            if (span <= 1000 * 60 * 60 * 24 * 2) {
                return date.format('DD/MM HH:mm');
            }
            if (span <= 1000 * 60 * 60 * 24 * 120) {
                return date.format('DD/MM/YY');
            }
            return date.format('MMM YY');
        },
        _range_cutoff: function(max_x) {
            if (!this._is_time_x(max_x) || this.active_range == 'ALL') {
                return null;
            }
            var day = 1000 * 60 * 60 * 24;
            var spans = {
                '5D': 5 * day,
                '1M': 31 * day,
                '3M': 93 * day,
                '6M': 186 * day,
                '1Y': 366 * day,
                '5Y': 366 * 5 * day
            };
            return spans[this.active_range] ? max_x - spans[this.active_range] : null;
        },
        _filter_visible_series: function(series) {
            var visible = series.filter(function(serie) {
                return serie.visible && serie.values.length;
            });
            if (!visible.length) {
                return visible;
            }
            var xs = [];
            for (const serie of visible) {
                for (const point of serie.values) {
                    xs.push(point.x);
                }
            }
            var cutoff = this._range_cutoff(Math.max.apply(null, xs));
            if (cutoff === null) {
                return visible;
            }
            return visible.map(function(serie) {
                var copy = Object.assign({}, serie);
                copy.values = serie.values.filter(function(point) {
                    return point.x >= cutoff;
                });
                return copy;
            }).filter(function(serie) {
                return serie.values.length;
            });
        },
        _format_y: function(value) {
            return Number(value).toLocaleString(
                Sao.i18n.BC47(Sao.i18n.getlang()), {
                    maximumFractionDigits: Number(this.attributes.digits || 6)
                });
        },
        _render: function(records) {
            var height = parseInt(this.attributes.height || 320, 10);
            var width = Math.max(320, this.stage.width() || this.el.width() || 640);
            this.stage.css('height', height + 'px');
            this.svg.attr('viewBox', '0 0 ' + width + ' ' + height);
            this.svg.empty();
            this.tooltip.hide();
            this.title.text(this.attributes.string || this.attributes.label ||
                this._label_for(this.attributes.name, {}));

            var series = this._collect(records);
            this.legend.empty();
            var visible = this._filter_visible_series(series);
            if (!visible.length) {
                this.meta.text(Sao.i18n.gettext('No data'));
                return;
            }
            this.meta.text(records.length + ' ' + Sao.i18n.gettext('records'));

            var margin = {top: 42, right: 16, bottom: 34, left: 52};
            var plot_width = width - margin.left - margin.right;
            var plot_height = height - margin.top - margin.bottom;
            var xs = [], ys = [];
            for (const serie of visible) {
                for (const point of serie.values) {
                    xs.push(point.x);
                    ys.push(point.y);
                }
            }
            var min_x = Math.min.apply(null, xs);
            var max_x = Math.max.apply(null, xs);
            var min_y = Math.min.apply(null, ys);
            var max_y = Math.max.apply(null, ys);
            if (min_x == max_x) {
                min_x -= 1;
                max_x += 1;
            }
            if (min_y == max_y) {
                min_y -= 1;
                max_y += 1;
            }
            var y_pad = (max_y - min_y) * 0.08;
            min_y -= y_pad;
            max_y += y_pad;
            var xscale = x => margin.left + ((x - min_x) / (max_x - min_x)) *
                plot_width;
            var yscale = y => margin.top + plot_height -
                ((y - min_y) / (max_y - min_y)) * plot_height;
            var svg = this.svg;
            var line = function(points) {
                return points.map(function(point, index) {
                    return (index ? 'L' : 'M') + xscale(point.x) + ' ' +
                        yscale(point.y);
                }).join(' ');
            };
            for (var g = 0; g <= 4; g++) {
                var y = margin.top + (plot_height / 4) * g;
                this._svg_el('line', {
                    x1: margin.left,
                    x2: width - margin.right,
                    y1: y,
                    y2: y,
                    'class': this.class_ + '-grid-line'
                }).appendTo(svg);
                var value = max_y - ((max_y - min_y) / 4) * g;
                this._svg_el('text', {
                    x: margin.left - 8,
                    y: y + 4,
                    'class': this.class_ + '-axis-label',
                    text: this._format_y(value)
                }).appendTo(svg);
            }
            for (var t = 0; t <= 5; t++) {
                var xvalue = min_x + ((max_x - min_x) / 5) * t;
                var x = xscale(xvalue);
                this._svg_el('line', {
                    x1: x,
                    x2: x,
                    y1: margin.top,
                    y2: height - margin.bottom,
                    'class': this.class_ + '-grid-line'
                }).appendTo(svg);
                this._svg_el('text', {
                    x: x,
                    y: height - 10,
                    'class': this.class_ + '-x-label',
                    text: this._format_x_tick(xvalue, min_x, max_x)
                }).appendTo(svg);
            }
            this._render_svg_legend(series, margin.left, 18);
            for (const serie of visible) {
                this._svg_el('path', {
                    d: line(serie.values),
                    'class': this.class_ + '-line',
                    stroke: serie.color,
                    'stroke-dasharray': serie.dashed ? '7 5' : null
                }).appendTo(svg);
                if (this.attributes.points != '0') {
                    for (const point of serie.values) {
                        this._svg_el('circle', {
                            cx: xscale(point.x),
                            cy: yscale(point.y),
                            r: 3.2,
                            fill: serie.color,
                            'class': this.class_ + '-point'
                        }).data('point', {
                            serie: serie,
                            point: point
                        }).appendTo(svg);
                        this._svg_el('circle', {
                            cx: xscale(point.x),
                            cy: yscale(point.y),
                            r: 10,
                            fill: 'transparent',
                            'class': this.class_ + '-hit'
                        }).data('point', {
                            serie: serie,
                            point: point
                        }).appendTo(svg);
                    }
                }
            }
            this._bind_tooltip();
        },
        _render_svg_legend: function(series, x, y) {
            var cursor = x;
            for (const serie of series) {
                var group = this._svg_el('g', {
                    'class': this.class_ + '-legend-svg-item',
                    transform: 'translate(' + cursor + ',' + y + ')',
                    opacity: serie.visible ? 1 : 0.36
                }).appendTo(this.svg);
                this._svg_el('line', {
                    x1: 0,
                    x2: 18,
                    y1: 0,
                    y2: 0,
                    stroke: serie.color,
                    'stroke-width': 3,
                    'stroke-linecap': 'round'
                }).appendTo(group);
                this._svg_el('text', {
                    x: 24,
                    y: 4,
                    fill: serie.color,
                    text: serie.label
                }).appendTo(group);
                group.css('cursor', 'pointer').on('click', () => {
                    this.series_visibility[serie.name] = !serie.visible;
                    this._render(this._records());
                });
                cursor += Math.min(180, 44 + (serie.label || '').length * 7);
            }
        },
        _bind_tooltip: function() {
            var tooltip = this.tooltip;
            var format_x = this._format_x.bind(this);
            var format_y = this._format_y.bind(this);
            this.svg.find('.' + this.class_ + '-point, .' + this.class_ + '-hit')
                .on('mouseenter', function() {
                    var data = jQuery(this).data('point');
                    tooltip.html(
                        '<strong>' + data.serie.label + '</strong><br>' +
                        format_x(data.point.x) + '<br>' +
                        format_y(data.point.y));
                    tooltip.show();
                })
                .on('mousemove', function(event) {
                    var stage = tooltip.parent().offset();
                    tooltip.css({
                        left: event.pageX - stage.left + 12,
                        top: event.pageY - stage.top + 12
                    });
                })
                .on('mouseleave', function() {
                    tooltip.hide();
                });
        }
    });

    Sao.View.ToleranceGaugeMixin = {
        _number: function(value) {
            if ((value === null) || (value === undefined) || (value === '')) {
                return null;
            }
            value = Number(value);
            if (isNaN(value)) {
                return null;
            }
            return value;
        },
        _field_value: function(record, name) {
            if (!name || !record || !record.model.fields[name]) {
                return null;
            }
            return this._number(record.model.fields[name].get(record));
        },
        _bound_value: function(record, literal, field_name, fallback) {
            var value = this._field_value(record, field_name);
            if (value === null) {
                value = this._number(literal);
            }
            if (value === null) {
                value = fallback;
            }
            return value;
        },
        _bounds: function(record) {
            var min = this._bound_value(
                record, this.attributes.min, this.attributes.min_field, null);
            var max = this._bound_value(
                record, this.attributes.max, this.attributes.max_field, null);
            if ((min === null) && (max !== null)) {
                min = -Math.abs(max);
            }
            if ((max === null) && (min !== null)) {
                max = Math.abs(min);
            }
            if (min === null) {
                min = -1;
            }
            if (max === null) {
                max = 1;
            }
            if (min == max) {
                min -= 1;
                max += 1;
            }
            if (min > max) {
                var swap = min;
                min = max;
                max = swap;
            }
            return [min, max];
        },
        _position: function(value, min, max) {
            return Math.max(0, Math.min(100, ((value - min) / (max - min)) * 100));
        },
        _format_number: function(value) {
            if ((value === null) || (value === undefined) || isNaN(value)) {
                return '';
            }
            return (value * Number(this.attributes.factor || 1)).toLocaleString(
                Sao.i18n.BC47(Sao.i18n.getlang()), {
                    maximumFractionDigits: Number(
                        this.attributes.digits || 6),
                    useGrouping: true
                });
        },
        _set_gauge: function(record, value, text) {
            var bounds = this._bounds(record);
            var min = bounds[0];
            var max = bounds[1];
            var center = this._number(this.attributes.center);
            if (center === null) {
                center = 0;
            }
            var current = this._number(value);
            if (current === null) {
                current = center;
            }

            var current_position = this._position(current, min, max);
            var center_position = this._position(center, min, max);
            var fill_left = Math.min(current_position, center_position);
            var fill_width = Math.abs(current_position - center_position);
            var outside = (current < min) || (current > max);
            var title = text || this._format_number(current);

            this.el.toggleClass('outside', outside);
            this.el.toggleClass('under', current < min);
            this.el.toggleClass('over', current > max);
            this.el.css('--tolerance-gauge-value', current_position + '%');
            this.el.css('--tolerance-gauge-center', center_position + '%');
            this.el.css('--tolerance-gauge-fill-left', fill_left + '%');
            this.el.css('--tolerance-gauge-fill-width', fill_width + '%');
            this.min_label.text(this._format_number(min));
            this.center_label.text(this._format_number(center));
            this.max_label.text(this._format_number(max));
            this.value_label.text(title);
            this.track.attr('aria-valuemin', min);
            this.track.attr('aria-valuemax', max);
            this.track.attr('aria-valuenow', current);
            this.track.attr('aria-valuetext', title);
            this.el.attr('title', title);
        }
    };

    Sao.View.Form.ToleranceGauge = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-tolerance-gauge',
        init: function(view, attributes) {
            Sao.View.Form.ToleranceGauge._super.init.call(
                this, view, attributes);
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var labels = jQuery('<div/>', {
                'class': this.class_ + '-labels'
            }).appendTo(this.el);
            this.min_label = jQuery('<span/>', {
                'class': this.class_ + '-label ' + this.class_ + '-label-min'
            }).appendTo(labels);
            this.center_label = jQuery('<span/>', {
                'class': this.class_ + '-label ' + this.class_ + '-label-center'
            }).appendTo(labels);
            this.max_label = jQuery('<span/>', {
                'class': this.class_ + '-label ' + this.class_ + '-label-max'
            }).appendTo(labels);
            this.track = jQuery('<div/>', {
                'class': this.class_ + '-track',
                'role': 'meter',
                'tabindex': 0
            }).appendTo(this.el);
            jQuery('<div/>', {
                'class': this.class_ + '-fill'
            }).appendTo(this.track);
            jQuery('<div/>', {
                'class': this.class_ + '-center-tick'
            }).appendTo(this.track);
            jQuery('<div/>', {
                'class': this.class_ + '-marker'
            }).appendTo(this.track);
            this.value_label = jQuery('<div/>', {
                'class': this.class_ + '-value'
            }).appendTo(this.el);
        },
        display: function() {
            Sao.View.Form.ToleranceGauge._super.display.call(this);
            var record = this.record;
            var field = this.field;
            var value = 0;
            var text = '';
            if (field) {
                value = field.get(record);
                text = field.get_client(record);
            }
            this._set_gauge(record, value, text);
        }
    });
    jQuery.extend(
        Sao.View.Form.ToleranceGauge.prototype,
        Sao.View.ToleranceGaugeMixin);

    Sao.View.Form.Dict = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-dict',
        expand: true,
        init: function(view, attributes) {
            Sao.View.Form.Dict._super.init.call(this, view, attributes);

            this.schema_model = attributes.schema_model;
            this.fields = {};
            this.rows = {};

            this.el = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default'
            });
            var heading = jQuery('<div/>', {
                'class': this.class_ + '-heading panel-heading'
            }).appendTo(this.el);
            var label = jQuery('<label/>', {
                'class': this.class_ + '-string',
                'text': attributes.string
            }).appendTo(heading);

            label.uniqueId();
            this.el.uniqueId();
            this.el.attr('aria-labelledby', label.attr('id'));
            label.attr('for', this.el.attr('id'));

            var body = jQuery('<div/>', {
                'class': this.class_ + '-body panel-body form-horizontal'
            }).appendTo(this.el);
            this.container = jQuery('<div/>', {
                'class': this.class_ + '-container'
            }).appendTo(body);

            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(jQuery('<div>', {
                'class': 'col-sm-10 col-sm-offset-2'
            }).appendTo(jQuery('<div/>', {
                'class': 'form-group'
            }).appendTo(body)));
            this.wid_text = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm',
                'placeholder': Sao.i18n.gettext('Search'),
                'name': attributes.name,
            }).appendTo(group);

            if (!attributes.completion || attributes.completion == '1') {
                this.wid_completion = Sao.common.get_completion(
                    group,
                    this._update_completion.bind(this),
                    this._completion_match_selected.bind(this));
                this.wid_text.completion = this.wid_completion;
            }

            this.but_add = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'aria-label': Sao.i18n.gettext("Add"),
                'title': Sao.i18n.gettext("Add"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-add')
            ).appendTo(jQuery('<div/>', {
                'class': 'input-group-btn'
            }).appendTo(group));
            this.but_add.click(this.add.bind(this));

            this._readonly = false;
            this._record_id = null;
            this._popup = false;
        },
        _required_el: function() {
            return this.wid_text;
        },
        _invalid_el: function() {
            return this.wid_text;
        },
        add: function() {
            var context = this.field.get_context(this.record);
            var value = this.wid_text.val();
            var domain = this.field.get_domain(this.record);

            if (this._popup) {
                return;
            } else {
                this._popup = true;
            }

            const callback = result => {
                if (!jQuery.isEmptyObject(result)) {
                    var ids = result.map(function(e) {
                        return e[0];
                    });
                    this.add_new_keys(ids);
                }
                this.wid_text.val('');
                this._popup = false;
            };

            var parser = new Sao.common.DomainParser();
            new Sao.Window.Search(this.schema_model,
                    callback, {
                        sel_multi: true,
                        context: context,
                        domain: domain,
                        new_: false,
                        search_filter: parser.quote(value),
                        title: this.attributes.string
                    });
        },
        add_new_keys: function(ids) {
            var field = this.field;
            field.add_new_keys(ids, this.record)
                .then(new_names => {
                    this.send_modified();
                    var value = this.field.get_client(this.record);
                    for (const key of new_names) {
                        value[key] = null;
                    }
                    this.field.set_client(this.record, value);
                    this._display().then(() => {
                        this.fields[new_names[0]].input.focus();
                    });
                });
        },
        remove: function(key, modified=true) {
            delete this.fields[key];
            this.rows[key].remove();
            delete this.rows[key];
            if (modified) {
                this.send_modified();
                this.set_value(this.record, this.field);
            }
        },
        set_value: function() {
            this.field.set_client(this.record, this.get_value());
        },
        get_value: function() {
            var value = {};
            for (var key in this.fields) {
                var widget = this.fields[key];
                value[key] = widget.get_value();
            }
            return value;
        },
        get modified() {
            if (this.record && this.field) {
                var value = this.field.get_client(this.record);
                for (var key in this.fields) {
                    var widget = this.fields[key];
                    if (widget.modified(value)) {
                        return true;
                    }
                }
            }
            return false;
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Dict._super.set_readonly.call(this, readonly);
            this._set_button_sensitive();
            for (var key in this.fields) {
                var widget = this.fields[key];
                widget.set_readonly(readonly);
            }
            this.wid_text.prop('disabled', readonly);
        },
        _set_button_sensitive: function() {
            var create = this.attributes.create;
            if (create === undefined) {
                create = 1;
            } else if (typeof create == 'string') {
                create = Boolean(parseInt(create, 10));
            }
            var delete_ = this.attributes['delete'];
            if (delete_ === undefined) {
                delete_ = 1;
            } else if (typeof delete_ == 'string') {
                delete_ = Boolean(parseInt(delete_, 10));
            }
            this.but_add.prop('disabled', this._readonly || !create);
            for (var key in this.fields) {
                var button = this.fields[key].button;
                button.prop('disabled', this._readonly || !delete_);
            }
        },
        add_line: function(key, position) {
            var field, row;
            var key_schema = this.field.keys[key];
            this.fields[key] = field = new (
                this.get_entries(key_schema.type))(key, this);
            this.rows[key] = row = jQuery('<div/>', {
                'class': 'form-group'
            });
            var text = key_schema.string + Sao.i18n.gettext(':');
            var label = jQuery('<label/>', {
                'text': text
            }).appendTo(jQuery('<div/>', {
                'class': 'dict-label col-sm-2 control-label'
            }).appendTo(row));

            field.el.addClass('col-sm-10').appendTo(row);

            label.uniqueId();
            field.labelled.uniqueId();
            field.labelled.attr('aria-labelledby', label.attr('id'));
            label.attr('for', field.labelled.attr('id'));

            field.button.click(() => {
                this.remove(key, true);
            });

            var previous = null;
            if (position > 0) {
                previous = this.container.children().eq(position - 1);
            }
            if (previous) {
                previous.after(row);
            } else {
                this.container.prepend(row);
            }
        },
        display: function() {
            this._display();
        },
        _display: function() {
            Sao.View.Form.Dict._super.display.call(this);

            var record = this.record;
            var field = this.field;
            if (!field) {
                return;
            }

            var record_id = record ? record.id : null;
            var key;

            if (record_id != this._record_id) {
                for (key in this.fields) {
                    this.remove(key, false);
                }
                this._record_id = record_id;
            }

            var value = field.get_client(record);
            var new_key_names = Object.keys(value).filter(
                e => !this.fields[e]);

            var prm;
            if (!jQuery.isEmptyObject(new_key_names)) {
                prm = field.add_keys(new_key_names, record);
            } else {
                prm = jQuery.when();
            }
            prm.then(() => {
                var i, len, key;
                var keys = Object.keys(value)
                    .filter(function(key) {
                        return field.keys[key];
                    })
                    .sort(function(key1, key2) {
                        var seq1 = field.keys[key1].sequence;
                        var seq2 = field.keys[key2].sequence;
                        if (seq1 < seq2) {
                            return -1;
                        } else if (seq1 > seq2) {
                            return 1;
                        } else {
                            return 0;
                        }
                    });
                // We remove first the old keys in order to keep the order
                // inserting the new ones
                var removed_key_names = Object.keys(this.fields).filter(
                        function(e) {
                            return !(e in value);
                        });
                for (i = 0, len = removed_key_names.length; i < len; i++) {
                    key = removed_key_names[i];
                    this.remove(key, false);
                }
                var decoder = new Sao.PYSON.Decoder();
                var inversion = new Sao.common.DomainInversion();
                for (i = 0, len = keys.length; i < len; i++) {
                    key = keys[i];
                    var val = value[key];
                    if (!this.fields[key]) {
                        this.add_line(key, i);
                    }
                    var widget = this.fields[key];
                    widget.set_value(val);
                    widget.set_readonly(this._readonly);
                    var key_domain = (decoder.decode(field.keys[key].domain ||
                        'null'));
                    if (key_domain !== null) {
                        if (!inversion.eval_domain(key_domain, value)) {
                            widget.el.addClass('has-error');
                        } else {
                            widget.el.removeClass('has-error');
                        }
                    }
                }
            });
            this._set_button_sensitive();
            return prm;
        },
        _update_completion: function(text) {
            if (this.wid_text.prop('disabled')) {
                return jQuery.when();
            }
            if (!this.record) {
                return jQuery.when();
            }
            return Sao.common.update_completion(
                this.wid_text, this.record, this.field, this.schema_model);
        },
        _completion_match_selected: function(value) {
            this.add_new_keys([value.id]);
            this.wid_text.val('');
        },
        get_entries: function(type) {
            switch (type) {
                case 'char':
                    return Sao.View.Form.Dict.Entry;
                case 'boolean':
                    return Sao.View.Form.Dict.Boolean;
                case 'selection':
                    return Sao.View.Form.Dict.Selection;
                case 'multiselection':
                    return Sao.View.Form.Dict.MultiSelection;
                case 'integer':
                    return Sao.View.Form.Dict.Integer;
                case 'float':
                    return Sao.View.Form.Dict.Float;
                case 'numeric':
                    return Sao.View.Form.Dict.Numeric;
                case 'date':
                    return Sao.View.Form.Dict.Date;
                case 'datetime':
                    return Sao.View.Form.Dict.DateTime;
            }
        }
    });

    Sao.View.Form.Dict.Entry = Sao.class_(Object, {
        init: function(name, parent_widget) {
            this.name = name;
            this.definition = parent_widget.field.keys[name];
            this.parent_widget = parent_widget;
            this.create_widget();
            if (this.definition.help) {
                this.el.attr('title', this.definition.help);
            }
        },
        create_widget: function() {
            this.el = jQuery('<div/>', {
                'class': this.class_
            });
            var group = jQuery('<div/>', {
                'class': 'input-group input-group-sm'
            }).appendTo(this.el);
            this.input = this.labelled = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm mousetrap',
                'name': this.name,
            }).appendTo(group);
            this.button = jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'arial-label': Sao.i18n.gettext("Remove"),
                'title': Sao.i18n.gettext("Remove"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
            ).appendTo(jQuery('<div/>', {
                'class': 'input-group-btn'
            }).appendTo(group));

            this.el.on('keydown',
                this.parent_widget.send_modified.bind(this.parent_widget));
            this.el.change(
                this.parent_widget.focus_out.bind(this.parent_widget));
        },
        modified: function(value) {
            return (JSON.stringify(this.get_value()) !=
                JSON.stringify(value[this.name]));
        },
        get_value: function() {
            return this.input.val();
        },
        set_value: function(value) {
            this.input.val(value || '');
        },
        set_readonly: function(readonly) {
            this._readonly = readonly;
            this.input.prop('readonly', readonly);
        }
    });

    Sao.View.Form.Dict.Char = Sao.class_(Sao.View.Form.Dict.Entry, {
        class_: 'dict-char',
        modified: function(value) {
            return (JSON.stringify(this.get_value()) !=
                JSON.stringify(value[this.name] || ""));
        }
    });

    Sao.View.Form.Dict.Boolean = Sao.class_(Sao.View.Form.Dict.Entry, {
        class_: 'dict-boolean',
        create_widget: function() {
            Sao.View.Form.Dict.Boolean._super.create_widget.call(this);
            this.input.attr('type', 'checkbox');
            this.input.change(
                    this.parent_widget.focus_out.bind(this.parent_widget));
        },
        get_value: function() {
            return this.input.prop('checked');
        },
        set_readonly: function(readonly) {
            this._readonly = readonly;
            this.input.prop('disabled', readonly);
        },
        set_value: function(value) {
            this.input.prop('checked', value);
        }
    });

    Sao.View.Form.Dict.SelectionEntry = Sao.class_(Sao.View.Form.Dict.Entry, {
        create_widget: function() {
            Sao.View.Form.Dict.SelectionEntry._super.create_widget.call(this);
            var select = jQuery('<select/>', {
                'class': 'form-control input-sm mousetrap',
                'name': this.name,
            });
            select.change(
                    this.parent_widget.focus_out.bind(this.parent_widget));
            this.input.replaceWith(select);
            this.input = this.labelled = select;
            var selection = jQuery.extend([], this.definition.selection);
            if (this.definition.sort === undefined || this.definition.sort) {
                selection.sort(function(a, b) {
                    return a[1].localeCompare(b[1]);
                });
            }
            for (const e of selection) {
                select.append(jQuery('<option/>', {
                    'value': JSON.stringify(e[0]),
                    'text': e[1],
                    'title': this.definition.help_selection[e[0]],
                }));
            }
        },
        set_readonly: function(readonly) {
            this._readonly = readonly;
            this.input.prop('disabled', readonly);
        }
    });

    Sao.View.Form.Dict.Selection = Sao.class_(
        Sao.View.Form.Dict.SelectionEntry, {
            class_: 'dict-selection',
            create_widget: function() {
                Sao.View.Form.Dict.Selection._super.create_widget.call(this);
                this.input.prepend(jQuery('<option/>', {
                    'value': JSON.stringify(null),
                    'text': '',
                }));
            },
            get_value: function() {
                return JSON.parse(this.input.val());
            },
            set_value: function(value) {
                this.input.val(JSON.stringify(value));
                var title = this.definition.help_selection[value] || null;
                if (this.definition.help && title) {
                    title = this.definition.help + '\n' + title;
                }
                this.input.attr('title', title);
            },
        });

    Sao.View.Form.Dict.MultiSelection = Sao.class_(
        Sao.View.Form.Dict.SelectionEntry, {
            class_: 'dict-multiselection',
            create_widget: function() {
                Sao.View.Form.Dict.MultiSelection._super
                    .create_widget.call(this);
                this.input.prop('multiple', true);
                var widget_help = this.definition.help;
                if (widget_help) {
                    this.input.children().each(function() {
                        var option = jQuery(this);
                        var help = option.attr('title');
                        if (help) {
                            help = widget_help + '\n' + help;
                            option.attr('title', help);
                        }
                    });
                }
            },
            get_value: function() {
                var value = this.input.val();
                return value.map(function(e) { return JSON.parse(e); });
            },
            set_value: function(value) {
                if (value) {
                    value = value.map(function(e) { return JSON.stringify(e); });
                }
                this.input.val(value);
            }
        });

    Sao.View.Form.Dict.Integer = Sao.class_(Sao.View.Form.Dict.Entry, {
        class_: 'dict-integer',
        create_widget: function() {
            Sao.View.Form.Dict.Integer._super.create_widget.call(this);
            this.input_text = this.labelled = integer_input(this.input);
        },
        get_value: function() {
            var value = parseInt(this.input.val(), 10);
            if (isNaN(value)) {
                return null;
            }
            return value;
        },
        set_value: function(value, options) {
            if (value !== null) {
                this.input.val(value);
                this.input_text.val(value.toLocaleString(
                    Sao.i18n.BC47(Sao.i18n.getlang()), options));
            } else {
                this.input.val('');
                this.input_text.val('');
            }
        },
        set_readonly: function(readonly) {
            Sao.View.Form.Dict.Integer._super.set_readonly.call(this, readonly);
            this.input_text.prop('readonly', readonly);
        },
    });

    Sao.View.Form.Dict.Float = Sao.class_(Sao.View.Form.Dict.Integer, {
        class_: 'dict-float',
        get digits() {
            var record = this.parent_widget.record;
            if (record) {
                var digits = record.expr_eval(this.definition.digits);
                if (!digits || !digits.every(function(e) {
                    return e !== null;
                })) {
                    return null;
                }
                return digits;
            } else {
                return null;
            }
        },
        get_value: function() {
            var value = this.input.val();
            if (!value && (value !== 0)) {
                return null;
            }
            value = Number(value);
            if (isNaN(value)) {
                return null;
            }
            return value;
        },
        set_value: function(value) {
            var step = 'any',
                options = {};
            var digits = this.digits;
            if (digits) {
                step = Math.pow(10, -digits[1]).toFixed(digits[1]);
                options.minimumFractionDigits = digits[1];
                options.maximumFractionDigits = digits[1];
            }
            this.input.attr('step', step);
            Sao.View.Form.Dict.Float._super.set_value.call(this, value, options);
        },
    });

    Sao.View.Form.Dict.Numeric = Sao.class_(Sao.View.Form.Dict.Float, {
        class_: 'dict-numeric',
        get_value: function() {
            var value = this.input.val();
            if (!value && (value !== 0)) {
                return null;
            }
            value = new Sao.Decimal(value);
            if (isNaN(value.valueOf())) {
                return null;
            }
            return value;
        }
    });

    Sao.View.Form.Dict.Date = Sao.class_(Sao.View.Form.Dict.Entry, {
        class_: 'dict-date',
        format: '%x',
        _input: 'date',
        _input_format: '%Y-%m-%d',
        _format: Sao.common.format_date,
        _parse: Sao.common.parse_date,
        create_widget: function() {
            Sao.View.Form.Dict.Date._super.create_widget.call(this);
            var group = this.input.parent().find('.input-group-btn');
            this.input_date = jQuery('<input/>', {
                'type': this._input,
                'role': 'button',
                'tabindex': -1,
            });
            this.input_date.click(() => {
                var value = this.get_value();
                value = this._format(this._input_format, value);
                this.input_date.val(value);
            });
            this.input_date.change(() => {
                var value = this.input_date.val();
                if (value) {
                    value = this._parse(this._input_format, value);
                    value = this._format(this.format, value);
                    this.input.val(value).change();
                    this.input.focus();
                }
            });
            if (this.input_date[0].type == this._input) {
                var icon = jQuery('<div/>', {
                    'class': 'btn btn-default',
                    'aria-label': Sao.i18n.gettext("Open the calendar"),
                    'title': Sao.i18n.gettext("Open the calendar"),
                }).prependTo(group);
                this.input_date.appendTo(icon);
                Sao.common.ICONFACTORY.get_icon_img('tryton-date')
                    .appendTo(icon);
            }
            var mousetrap = new Mousetrap(this.el[0]);

            mousetrap.bind('enter', (e, combo) => {
                var value = this._parse(this.format, this.input.val());
                value = this._format(this.format, value);
                this.input.val(value).change();
            });
            mousetrap.bind('=', (e, combo) => {
                e.preventDefault();
                this.input.val(this._format(this.format, moment())).change();
            });

            Sao.common.DATE_OPERATORS.forEach(operator => {
                mousetrap.bind(operator[0], (e, combo) => {
                    e.preventDefault();
                    var date = this.get_value() || Sao.DateTime();
                    date.add(operator[1]);
                    this.input.val(this._format(this.format, date)).change();
                });
            });
        },
        get_value: function() {
            return this._parse(this.format, this.input.val());
        },
        set_value: function(value) {
            this.input.val(this._format(this.format, value));
        },
    });

    Sao.View.Form.Dict.DateTime = Sao.class_(Sao.View.Form.Dict.Date, {
        class_: 'dict-datetime',
        format: '%x %X',
        _input: 'datetime-local',
        _input_format: '%Y-%m-%dT%H:%M:%S',
        _format: Sao.common.format_datetime,
        _parse: Sao.common.parse_datetime,
    });

    Sao.View.Form.PYSON = Sao.class_(Sao.View.Form.Char, {
        class_: 'form-pyson',
        init: function(view, attributes) {
            Sao.View.Form.PYSON._super.init.call(this, view, attributes);
            this.encoder = new Sao.PYSON.Encoder({});
            this.decoder = new Sao.PYSON.Decoder({}, true);
            this.el.keyup(this.validate_pyson.bind(this));
            this.icon = jQuery('<img/>', {
                'class': 'icon form-control-feedback',
            }).appendTo(this.group);
            this.group.addClass('has-feedback');
        },
        display: function() {
            Sao.View.Form.PYSON._super.display.call(this);
            this.validate_pyson();
        },
        get_encoded_value: function() {
            var value = this.input.val();
            if (!value) {
                return value;
            }
            try {
                return this.encoder.encode(eval_pyson(value));
            }
            catch (err) {
                return null;
            }
        },
        set_value: function() {
            // avoid modification because different encoding
            var value = this.get_encoded_value();
            var record = this.record;
            var field = this.field;
            var previous = field.get_client(record);
            if (value && previous && Sao.common.compare(
                value, this.encoder.encode(this.decoder.decode(previous)))) {
                value = previous;
            }
            field.set_client(record, value);
        },
        get_client_value: function() {
            var value = Sao.View.Form.PYSON._super.get_client_value.call(this);
            if (value) {
                value = Sao.PYSON.toString(this.decoder.decode(value));
            }
            return value;
        },
        validate_pyson: function() {
            var icon = 'ok';
            if (this.get_encoded_value() === null) {
                icon = 'error';
            }
            Sao.common.ICONFACTORY.get_icon_url('tryton-' + icon)
                .then(url => {
                    this.icon.attr('src', url);
                });
        },
        focus_out: function() {
            this.validate_pyson();
            Sao.View.Form.PYSON._super.focus_out.call(this);
        }
    });

    let botuiInstance = null;
    let previousValue = null;
    Sao.View.Form.HTMLViewer = Sao.class_(Sao.View.Form.Widget, {
        class_: 'form-htmlviewer',
        expand: true,

        init: function(view, attributes) {
            Sao.View.Form.HTMLViewer._super.init.call(this, view, attributes);
            this._collapsed = false;
            this._collapse_button = null;
            this._collapse_item = null;
            this._collapse_target = null;
            this._expanded_min_height = null;
            this._expanded_child_min_height = null;
            this.el = jQuery('<div/>', {
                'class': this.class_ + ' panel panel-default',
            });

            this.container = jQuery('<div/>', {
                'class': 'panel-body htmlviewer-content',
            }).appendTo(this.el);
            if (window.MutationObserver) {
                this._theme_observer = new MutationObserver(mutations => {
                    var changed = mutations.some(mutation =>
                        mutation.attributeName == 'data-theme-mode');
                    if (!changed) {
                        return;
                    }
                    this._sync_theme_classes();
                    if (!document.documentElement.contains(this.el[0])) {
                        this._theme_observer.disconnect();
                        this._theme_observer = null;
                        return;
                    }
                    this.container.find('iframe[data-htmlviewer-dashboard="1"]')
                        .each(function() {
                            Sao.applyHTMLViewerThemeToIFrame(this);
                        });
                    if (!String(this._last_value || '').startsWith('d3:')) {
                        return;
                    }
                    window.requestAnimationFrame(() => this.display());
                });
                this._theme_observer.observe(document.documentElement, {
                    attributes: true,
                    attributeFilter: ['data-theme-mode'],
                });
            }
        },

        _ensure_collapse_control: function() {
            var resolve_context = () => {
                var target = this.el;
                var button_parent = this.el;
                var in_panel_heading = false;
                var panel = this.el.closest('fieldset.form-panel');
                if (panel.length) {
                    var legend = panel.children('legend').first();
                    var items = panel.find('> .form-container > .form-item');
                    if (legend.length) {
                        button_parent = legend;
                        in_panel_heading = true;
                        var other_items = items.filter(function() {
                            return !jQuery(this).hasClass('form-empty') &&
                                !jQuery(this).find(
                                    '.form-htmlviewer, .form-purchase-summary')
                                    .length;
                        });
                        if (!other_items.length) {
                            target = panel;
                        }
                    }
                }
                return {
                    target: target,
                    button_parent: button_parent,
                    in_panel_heading: in_panel_heading,
                };
            };
            var context = resolve_context();
            if (this._collapse_button) {
                if (context.button_parent.length &&
                        this._collapse_button.parent()[0] !=
                        context.button_parent[0]) {
                    this._collapse_button.appendTo(context.button_parent);
                }
                this._collapse_target = context.target;
                this._collapse_item = context.target.closest('.form-item');
                this._collapse_button.toggleClass(
                    'htmlviewer-collapse-toggle-heading',
                    context.in_panel_heading);
                this.el.toggleClass(
                    'htmlviewer-self-collapsible',
                    !context.in_panel_heading);
                this._sync_collapse_state();
                return;
            }
            this._collapse_target = context.target;
            this._collapse_item = context.target.closest('.form-item');
            this._collapse_button = jQuery('<button/>', {
                'type': 'button',
                'class': 'htmlviewer-collapse-toggle',
                'aria-label': Sao.i18n.gettext('Hide viewer'),
                'title': Sao.i18n.gettext('Hide viewer'),
            }).appendTo(context.button_parent);
            this._collapse_button.on('click', evt => {
                evt.preventDefault();
                evt.stopPropagation();
                this._collapsed = !this._collapsed;
                this._sync_collapse_state();
            });
            if (context.in_panel_heading) {
                this._collapse_button.addClass(
                    'htmlviewer-collapse-toggle-heading');
            } else {
                this.el.addClass('htmlviewer-self-collapsible');
            }
            this._sync_collapse_state();
        },

        _sync_collapse_state: function() {
            if (!this._collapse_button || !this._collapse_target) {
                return;
            }
            this._collapse_target.toggleClass(
                'htmlviewer-collapsed', this._collapsed);
            if (this._collapse_item) {
                this._collapse_item.toggleClass(
                    'htmlviewer-collapsed-item', this._collapsed);
            }
            if (this._collapsed) {
                this._expanded_min_height = this.el[0].style.minHeight;
                this._expanded_child_min_height = this.container[0].style.minHeight;
                this.el[0].style.setProperty('min-height', '0', 'important');
                this.container[0].style.setProperty('min-height', '0', 'important');
            } else {
                this.el[0].style.removeProperty('min-height');
                this.container[0].style.removeProperty('min-height');
                if (this._expanded_min_height) {
                    this.el[0].style.minHeight = this._expanded_min_height;
                }
                if (this._expanded_child_min_height) {
                    this.container[0].style.minHeight =
                        this._expanded_child_min_height;
                }
            }
            if (this._collapse_item) {
                var form_container = this._collapse_item.parent('.form-container');
                var container = form_container.data('sao-form-container');
                if (container) {
                    container.set_grid_template();
                }
            }
            this._collapse_button
                .toggleClass('htmlviewer-collapse-toggle-collapsed',
                    this._collapsed)
                .attr('aria-expanded', this._collapsed ? 'false' : 'true')
                .attr('aria-label', Sao.i18n.gettext(
                    this._collapsed ? 'Show viewer' : 'Hide viewer'))
                .attr('title', Sao.i18n.gettext(
                    this._collapsed ? 'Show viewer' : 'Hide viewer'));
        },

        _sync_theme_classes: function() {
            var theme_mode = document.documentElement.getAttribute(
                'data-theme-mode') || '';
            var is_dark = theme_mode == 'dark';
            this.container
                .toggleClass('htmlviewer-theme-dark', is_dark)
                .toggleClass('htmlviewer-theme-light', !is_dark);
            this.el
                .toggleClass('htmlviewer-theme-dark', is_dark)
                .toggleClass('htmlviewer-theme-light', !is_dark);
        },

        display: function() {
            Sao.View.Form.HTMLViewer._super.display.call(this);
            window.setTimeout(() => this._ensure_collapse_control());
            window.setTimeout(() => this._ensure_collapse_control(), 50);
            var value = '';
            var record = this.record;

            if (record) {
                value = record.field_get_client(this.field_name);
            }
            this._sync_theme_classes();
            var theme_mode = document.documentElement.getAttribute(
                'data-theme-mode') || '';
            var render_key = String(value || '').startsWith('d3:') ?
                value + '|theme:' + theme_mode : value;
            // Si la valeur n'a pas changé, ne rien faire
            if (this._last_render_key === render_key) {
                return;
            }

            this._last_value = value;
            this._last_render_key = render_key;
            this.container.empty();

            // if (!value) {
            //     return;
            // }
            
            let height = 300
            let width = 400
            if (this.attributes.height) {
                height = parseInt(this.attributes.height);
            }
            if (this.attributes.width) {
                width = parseInt(this.attributes.width);
            }
            Sao.renderHTMLViewerContent(this.container, value, height, width, this.record);

        },

        focus: function() {
            // No input element to focus
            return false;
        },

        get_value: function() {
            // Read-only, no value
            return null;
        },

        set_value: function() {
            // No-op — viewer only
        },

        set_readonly: function(readonly) {
            // Viewer is always read-only — nothing to do
        },

        get modified() {
            return false;
        },
    });

    Sao.View.Form.PurchaseSummary = Sao.class_(Sao.View.Form.HTMLViewer, {
        class_: 'form-purchase-summary',

        _summary_config: function(value) {
            if (!value) {
                return {};
            }
            if (typeof value == 'object') {
                return value;
            }
            var text = String(value || '');
            if (text.startsWith('purchase_summary:')) {
                text = text.slice(17);
            }
            try {
                return JSON.parse(text || '{}');
            } catch (error) {
                return {};
            }
        },

        _approval_info: function(config) {
            config = config || {};
            var approval = config.approval || {};
            var approval_raw = approval.status || config.approval_status || '';
            var approval_key = String(approval_raw || 'need_approval')
                .toLowerCase()
                .replace(/[^a-z0-9]+/g, '_')
                .replace(/^_+|_+$/g, '');
            var approval_labels = {
                need_approval: 'Need approval',
                first_approval: 'First Approval',
                final_approval: 'Final approval',
            };
            if (!approval_labels[approval_key]) {
                approval_key = 'need_approval';
            }
            return {
                key: approval_key,
                label: approval.label || approval_labels[approval_key],
            };
        },

        _update_summary_legend: function(config) {
            var panel = this.el.closest('fieldset.form-panel');
            var legend = panel.children('legend').first();
            if (!legend.length) {
                return;
            }
            legend.addClass('purchase-summary-legend');
            legend.find('.purchase-summary-title-strip').remove();
            legend.children('span').not('.purchase-summary-title-strip')
                .first().text('Summary');
            var escape = function(raw) {
                return jQuery('<div/>').text(raw === undefined || raw === null ?
                    '' : String(raw)).html();
            };
            var activity = (config.activity || config.activities || []).slice(0, 3);
            var info = this._approval_info(config);
            var activity_html = activity.map(function(item) {
                var text = item.text || item.label || item.field || '';
                var old_value = item.old_value !== undefined ? item.old_value :
                    item.old !== undefined ? item.old :
                    item.previous !== undefined ? item.previous : '';
                var new_value = item.new_value !== undefined ? item.new_value :
                    item.new !== undefined ? item.new :
                    item.current !== undefined ? item.current : '';
                if (old_value !== '' || new_value !== '') {
                    text = [text, [old_value, new_value].filter(function(part) {
                        return part !== '';
                    }).join(' => ')].filter(Boolean).join(' ');
                }
                text = [text, item.user, item.date || item.time]
                    .filter(Boolean).join(' - ');
                return '<span class="purchase-summary-title-log" title="' +
                    escape(text) + '">' + escape(text) + '</span>';
            }).join('');
            var strip = jQuery(
                '<span class="purchase-summary-title-strip">' +
                    '<span class="purchase-summary-title-logs">' +
                        activity_html + '</span>' +
                    '<span class="purchase-summary-approval purchase-summary-approval-' +
                        escape(info.key) + '">' + escape(info.label) + '</span>' +
                '</span>');
            if (this._collapse_button && this._collapse_button.parent()[0] ==
                    legend[0]) {
                strip.insertBefore(this._collapse_button);
            } else {
                legend.append(strip);
            }
        },

        display: function() {
            Sao.View.Form.Widget.prototype.display.call(this);
            window.setTimeout(() => this._ensure_collapse_control());
            window.setTimeout(() => this._ensure_collapse_control(), 50);
            var value = '';
            var record = this.record;
            if (record) {
                value = record.field_get_client(this.field_name);
            }
            this._sync_theme_classes();
            var render_key = 'purchase_summary|' + String(value || '');
            if (this._last_render_key === render_key) {
                return;
            }
            this._last_value = value;
            this._last_render_key = render_key;
            var height = parseInt(this.attributes.height || 168, 10);
            var width = parseInt(this.attributes.width || 1200, 10);
            this.el[0].style.setProperty('min-height', '0', 'important');
            this.container[0].style.setProperty('min-height', '0', 'important');
            if (value && !String(value).startsWith('purchase_summary:')) {
                value = 'purchase_summary:' + String(value);
            }
            Sao.renderHTMLViewerContent(
                this.container, value, height, width, this.record);
            this._update_summary_legend(this._summary_config(value));
        },
    });

    Sao.View.VisGraph = Sao.class_(Sao.View, {
        editable: false,
        view_type: 'graph',
        xml_parser: Sao.View.GraphXMLViewParser,
        init: function(view_id, screen, xml, children_field) {
            this.el = jQuery('<div/>', {
                'class': 'graph'
            });
            this.container = this.el;
            Sao.View.VisGraph._super.init.call(this, view_id, screen, xml);
        },

        display: function() {
            const loads = [];

            for (const record of this.screen.group) {
                loads.push(record.load('vis_data'));
            }

            jQuery.when.apply(jQuery, loads).done(() => {
                for (const record of this.screen.group) {
                    const visData = record.field_get_client('vis_data');
                    try {
                        let height = 300
                        let width = 400
                        if (this.attributes.height) {
                            height = parseInt(this.attributes.height);
                        }
                        if (this.attributes.width) {
                            width = parseInt(this.attributes.width);
                        }
                        Sao.renderHTMLViewerContent(this.container, visData,height,width, record);
                    } catch (e) {
                        console.warn('Erreur de parsing JSON pour vis_data', e);
                    }
                }
            });
        },

        reload: function() {
            this.display();
        },

        get deletable() {
            return false;
        },

        set_value: function() {
            // No-op — viewer only
        },

    });

    window.addEventListener("message", function (event) {
        if (event.data?.type === "open_tab") {
            const { model, res_id, view_mode, domain, context } = event.data.payload || {};

            Sao.Tab.create({
                model: model,
                res_id: res_id,
                mode: view_mode,
                domain: domain,
                target: "new",
                context: context
            });
        }
        if (event.data?.type === "exec_window"){
            const { model, res_id, view_mode, domain, context } = event.data.payload || {};
            var action = {
                type: 'ir.action.act_window',
                res_model: model,

                pyson_domain: domain,

                pyson_context: JSON.stringify(context),
                pyson_order: 'null',
                pyson_search_value: '[]',

                views: [],
                domains: []
            };

            Sao.Action.exec_action(action, { ids: [] }, {});
        }
    });
    // window.addEventListener("message", function (event) {
    //     // ⚠️ Sécurité : vérifier l'origine
    //     // if (event.origin !== "https://ton-react.example.com") return;

    //     if (event.data?.type === "open_tab") {
    //         const { model, res_id, view_mode, domain } = event.data.payload || {};

    //         Sao.Tab.create({
    //             model: model,
    //             res_id: res_id,
    //             mode: [view_mode],
    //             domain: domain,
    //             target: "new",
    //         });
    //     }
    // });

    Sao.traceHTMLViewerTheme = function(stage, payload) {
        if (!window.console) {
            return;
        }
        var storage_theme = null;
        try {
            storage_theme = window.localStorage &&
                localStorage.getItem('sao_theme_mode');
        } catch (error) {
            storage_theme = 'unavailable:' + (error && error.name || 'error');
        }
        try {
            console.info('[HTMLVIEWER][THEME_DEBUG]', JSON.stringify(
                jQuery.extend({
                    stage: stage,
                    href: window.location.href,
                    parentTheme: document.documentElement.getAttribute(
                        'data-theme-mode') || null,
                    storageTheme: storage_theme,
                }, payload || {})));
        } catch (error) {
            console.info('[HTMLVIEWER][THEME_DEBUG]', stage, payload);
        }
    };

    Sao.htmlViewerThemeMode = function() {
        return document.documentElement.getAttribute('data-theme-mode') || 'light';
    };

    Sao.isHTMLViewerDashboardURL = function(value) {
        return /\/dashboard\/index\.html(?:[?#]|$)/i.test(String(value || ''));
    };

    Sao.shouldRewriteHTMLViewerDashboardOrigin = function(url) {
        if (!url || url.origin == window.location.origin) {
            return false;
        }
        var current_host = window.location.hostname || '';
        var dashboard_host = url.hostname || '';
        return /(^|\.)open-squared\.tech$/i.test(current_host) &&
            /(^|\.)open-squared\.tech$/i.test(dashboard_host);
    };

    Sao.withHTMLViewerThemeParam = function(value) {
        var is_dashboard = Sao.isHTMLViewerDashboardURL(value);
        if (!is_dashboard) {
            Sao.traceHTMLViewerTheme('with-param-skip', {
                value: value,
                reason: 'not-dashboard-url',
            });
            return value;
        }
        try {
            var url = new URL(value, window.location.href);
            var input_origin = url.origin;
            if (Sao.shouldRewriteHTMLViewerDashboardOrigin(url)) {
                url.protocol = window.location.protocol;
                url.host = window.location.host;
                Sao.traceHTMLViewerTheme('origin-rewrite', {
                    input: value,
                    fromOrigin: input_origin,
                    toOrigin: url.origin,
                    currentOrigin: window.location.origin,
                });
            }
            var mode = Sao.htmlViewerThemeMode();
            url.searchParams.set('theme', mode);
            url.searchParams.set('theme_mode', mode);
            Sao.traceHTMLViewerTheme('with-param', {
                input: value,
                output: url.toString(),
                mode: mode,
                urlOrigin: url.origin,
                urlPathname: url.pathname,
                sameOrigin: url.origin == window.location.origin,
                themeParam: url.searchParams.get('theme'),
                themeModeParam: url.searchParams.get('theme_mode'),
            });
            return url.toString();
        } catch (e) {
            var separator = value.indexOf('?') == -1 ? '?' : '&';
            var mode = encodeURIComponent(Sao.htmlViewerThemeMode());
            var output = value + separator + 'theme=' + mode +
                '&theme_mode=' + mode;
            Sao.traceHTMLViewerTheme('with-param-fallback', {
                input: value,
                output: output,
                mode: mode,
                error: e && (e.name + ': ' + e.message),
            });
            return output;
        }
    };

    Sao.applyHTMLViewerThemeToIFrame = function(iframe) {
        var mode = Sao.htmlViewerThemeMode();
        var dashboard = iframe.getAttribute('data-htmlviewer-dashboard') == '1';
        Sao.traceHTMLViewerTheme('apply-start', {
            dashboard: dashboard,
            mode: mode,
            src: iframe.getAttribute('src') || '',
            originalSrc: iframe.getAttribute('data-original-src') || '',
        });
        if (!dashboard) {
            return;
        }
        var next_src = Sao.withHTMLViewerThemeParam(
            iframe.getAttribute('data-original-src') || iframe.getAttribute('src') || '');
        if (next_src && iframe.getAttribute('src') != next_src) {
            Sao.traceHTMLViewerTheme('apply-src-update', {
                previousSrc: iframe.getAttribute('src') || '',
                nextSrc: next_src,
            });
            iframe.setAttribute('src', next_src);
        }
        try {
            var doc = iframe.contentDocument || iframe.contentWindow.document;
            if (!doc || !doc.documentElement) {
                Sao.traceHTMLViewerTheme('apply-no-document', {
                    src: iframe.getAttribute('src') || '',
                });
                return;
            }
            var existing_style = doc.getElementById(
                'tradon-htmlviewer-theme-bridge');
            Sao.traceHTMLViewerTheme('apply-document-access', {
                src: iframe.getAttribute('src') || '',
                readyState: doc.readyState,
                docUrl: doc.location && doc.location.href || null,
                docThemeBefore: doc.documentElement.getAttribute(
                    'data-theme-mode') || null,
                bodyThemeBefore: doc.body &&
                    doc.body.getAttribute('data-theme-mode') || null,
                hasHead: Boolean(doc.head),
                hasBody: Boolean(doc.body),
                bridgeBefore: Boolean(existing_style),
            });
            doc.documentElement.setAttribute('data-theme-mode', mode);
            doc.body && doc.body.setAttribute('data-theme-mode', mode);
            var style = doc.getElementById('tradon-htmlviewer-theme-bridge');
            if (!style) {
                style = doc.createElement('style');
                style.id = 'tradon-htmlviewer-theme-bridge';
                doc.head.appendChild(style);
            }
            var dashboard_logo_url = new URL(
                'dist/tradon_wbg.png', window.location.href).toString();
            var compact_dashboard_css = `
                :root {
                    --tradon-dashboard-card-radius: 6px;
                    font-size: 14px !important;
                }
                body {
                    font-size: 0.9rem !important;
                }
                #root > [class*="min-h-screen"],
                #app > [class*="min-h-screen"],
                body > [class*="min-h-screen"] {
                    padding: 0.75rem !important;
                }
                [class*="container"], [class*="max-w-"] {
                    padding-left: 0.6rem !important;
                    padding-right: 0.6rem !important;
                }
                [class*="grid"][class*="gap-6"],
                [class*="grid"][class*="gap-5"],
                [class*="grid"][class*="gap-4"] {
                    gap: 0.7rem !important;
                }
                [class*="gap-6"], [class*="gap-5"], [class*="gap-4"] {
                    gap: 0.7rem !important;
                }
                [class*="gap-3"], [class*="gap-2"] {
                    gap: 0.4rem !important;
                }
                [class*="p-8"], [class*="p-7"], [class*="p-6"] {
                    padding: 0.75rem !important;
                }
                [class*="p-5"], [class*="p-4"] {
                    padding: 0.65rem !important;
                }
                [class*="px-6"], [class*="px-5"], [class*="px-4"] {
                    padding-left: 0.7rem !important;
                    padding-right: 0.7rem !important;
                }
                [class*="py-6"], [class*="py-5"], [class*="py-4"] {
                    padding-top: 0.55rem !important;
                    padding-bottom: 0.55rem !important;
                }
                [class*="mb-8"], [class*="mb-7"], [class*="mb-6"] {
                    margin-bottom: 0.75rem !important;
                }
                [class*="mt-8"], [class*="mt-7"], [class*="mt-6"] {
                    margin-top: 0.75rem !important;
                }
                [class*="space-y-6"] > :not([hidden]) ~ :not([hidden]),
                [class*="space-y-5"] > :not([hidden]) ~ :not([hidden]),
                [class*="space-y-4"] > :not([hidden]) ~ :not([hidden]) {
                    margin-top: 0.7rem !important;
                }
                [class*="bg-white"], [class*="card"], [class*="Card"],
                section, article {
                    border-radius: var(--tradon-dashboard-card-radius) !important;
                    min-height: 0 !important;
                }
                [class*="rounded-2xl"], [class*="rounded-xl"], [class*="rounded-lg"] {
                    border-radius: var(--tradon-dashboard-card-radius) !important;
                }
                [class*="shadow-lg"], [class*="shadow-xl"], [class*="shadow-md"] {
                    box-shadow: 0 3px 8px rgba(15, 23, 42, 0.12) !important;
                }
                [class*="text-6xl"], [class*="text-5xl"] {
                    font-size: 1.75rem !important;
                    line-height: 2rem !important;
                }
                [class*="text-4xl"] {
                    font-size: 1.55rem !important;
                    line-height: 1.85rem !important;
                }
                [class*="text-3xl"] {
                    font-size: 1.3rem !important;
                    line-height: 1.6rem !important;
                }
                [class*="text-2xl"] {
                    font-size: 1.1rem !important;
                    line-height: 1.35rem !important;
                }
                [class*="text-xl"] {
                    font-size: 1rem !important;
                    line-height: 1.25rem !important;
                }
                [class*="text-lg"] {
                    font-size: 0.95rem !important;
                    line-height: 1.2rem !important;
                }
                [class*="w-12"][class*="h-12"],
                [class*="w-10"][class*="h-10"],
                [class*="w-8"][class*="h-8"] {
                    width: 1.35rem !important;
                    height: 1.35rem !important;
                }
                [class*="w-6"][class*="h-6"],
                [class*="w-5"][class*="h-5"] {
                    width: 1rem !important;
                    height: 1rem !important;
                }
                svg {
                    max-width: 1.35rem !important;
                    max-height: 1.35rem !important;
                }
                [class*="h-3"], [class*="h-4"] {
                    min-height: 0 !important;
                }
                canvas, [class*="chart"], [class*="Chart"], [class*="recharts"] {
                    max-height: 150px !important;
                }
            `;
            var dark_dashboard_css = `
                :root { color-scheme: dark; }
                html {
                    font-size: 14px !important;
                }
                body {
                    font-size: 0.9rem !important;
                }
                #root > [class*="min-h-screen"],
                #app > [class*="min-h-screen"],
                body > [class*="min-h-screen"] {
                    padding: 0.75rem !important;
                }
                [class*="grid"][class*="gap-4"] {
                    gap: 0.7rem !important;
                }
                [class*="p-6"] {
                    padding: 0.75rem !important;
                }
                [class*="p-4"] {
                    padding: 0.65rem !important;
                }
                [class*="mb-6"] {
                    margin-bottom: 0.75rem !important;
                }
                [class*="gap-2"] {
                    gap: 0.4rem !important;
                }
                [class*="text-4xl"] {
                    font-size: 1.55rem !important;
                    line-height: 1.85rem !important;
                }
                html, body, #root, #app, [id*="root"], [class*="app"] {
                    background: #000000 !important;
                    color: #eaf7f4 !important;
                }
                body {
                    scrollbar-color: #31534e #000000;
                }
                body > div, #root, #app,
                #root > div, #app > div,
                #root > div > div, #app > div > div,
                main,
                [class*="min-h-screen"], [class*="dashboard"], [class*="Dashboard"] {
                    background-color: #000000 !important;
                }
                [class*="bg-gray-50"], [class*="bg-slate-50"],
                [class*="bg-neutral-50"], [class*="bg-zinc-50"],
                [class*="bg-gray-100"], [class*="bg-slate-100"],
                [class*="bg-neutral-100"], [class*="bg-zinc-100"] {
                    background: #000000 !important;
                }
                [class*="bg-white"], [class*="card"],
                [class*="Card"], section, article {
                    background: linear-gradient(180deg, #142326 0%, #101d20 100%) !important;
                    border-color: rgba(118, 173, 165, 0.22) !important;
                    color: #eaf7f4 !important;
                    box-shadow: 0 10px 24px rgba(0, 0, 0, 0.28), inset 0 1px 0 rgba(255, 255, 255, 0.035) !important;
                }
                [class*="text-gray"], [class*="text-slate"], [class*="text-neutral"],
                [class*="text-zinc"] {
                    color: #c3d4cf !important;
                }
                [class*="text-gray-400"], [class*="text-gray-500"],
                [class*="text-slate-400"], [class*="text-slate-500"],
                [class*="text-neutral-400"], [class*="text-neutral-500"] {
                    color: #a9c1bb !important;
                }
                h1, h2, h3, h4, h5, h6,
                [class*="font-semibold"], [class*="font-bold"] {
                    color: #f4fbf8 !important;
                }
                svg, svg * {
                    color: #a9e3dd !important;
                    stroke: currentColor !important;
                }
                svg {
                    opacity: 0.94 !important;
                }
                [class*="bg-gray-200"], [class*="bg-slate-200"],
                [class*="bg-neutral-200"], [class*="bg-zinc-200"] {
                    background: #1b2c2e !important;
                }
                [class*="bg-green"], [class*="bg-emerald"], [class*="bg-teal"],
                [class*="bg-cyan"] {
                    background: #11625d !important;
                    color: #a2fff7 !important;
                }
                [class*="text-green"], [class*="text-emerald"], [class*="text-teal"] {
                    color: #5ee6dc !important;
                }
                [class*="bg-blue"], [class*="bg-sky"], [class*="bg-indigo"] {
                    background: #0f5e83 !important;
                    color: #9be0ff !important;
                }
                [class*="bg-red"], [class*="bg-rose"], [class*="bg-pink"] {
                    background: #4a2228 !important;
                    color: #ff9ba6 !important;
                }
                [class*="bg-yellow"], [class*="bg-amber"], [class*="bg-orange"] {
                    background: #4a3818 !important;
                    color: #ffd071 !important;
                }
                [class*="rounded-full"], [class*="badge"], [class*="Badge"] {
                    box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.10) !important;
                    font-weight: 700 !important;
                }
                [class*="bg-green"][class*="rounded"],
                [class*="bg-emerald"][class*="rounded"],
                [class*="bg-teal"][class*="rounded"],
                [class*="bg-cyan"][class*="rounded"],
                [class*="bg-blue"][class*="rounded"],
                [class*="bg-sky"][class*="rounded"],
                [class*="bg-indigo"][class*="rounded"] {
                    box-shadow: inset 0 0 0 1px rgba(126, 228, 220, 0.20) !important;
                }
                [class*="bg-green"][class*="rounded-lg"],
                [class*="bg-green"][class*="rounded-xl"],
                [class*="bg-emerald"][class*="rounded-lg"],
                [class*="bg-emerald"][class*="rounded-xl"],
                [class*="bg-teal"][class*="rounded-lg"],
                [class*="bg-teal"][class*="rounded-xl"],
                [class*="bg-cyan"][class*="rounded-lg"],
                [class*="bg-cyan"][class*="rounded-xl"] {
                    background: rgba(20, 88, 82, 0.18) !important;
                    border: 1px solid rgba(46, 196, 182, 0.52) !important;
                    color: #dffdfa !important;
                    box-shadow: inset 0 0 0 1px rgba(126, 228, 220, 0.08) !important;
                }
                [class*="bg-blue"][class*="rounded-lg"],
                [class*="bg-blue"][class*="rounded-xl"],
                [class*="bg-sky"][class*="rounded-lg"],
                [class*="bg-sky"][class*="rounded-xl"],
                [class*="bg-indigo"][class*="rounded-lg"],
                [class*="bg-indigo"][class*="rounded-xl"] {
                    background: rgba(35, 82, 118, 0.18) !important;
                    border: 1px solid rgba(82, 153, 214, 0.50) !important;
                    color: #e6f5ff !important;
                    box-shadow: inset 0 0 0 1px rgba(155, 224, 255, 0.08) !important;
                }
                [class*="flex"][class*="items-center"][class*="justify-between"],
                [class*="flex"][class*="justify-between"][class*="items-center"] {
                    background: transparent !important;
                    box-shadow: none !important;
                    border-color: transparent !important;
                }
                [class*="w-3"][class*="h-3"], [class*="w-4"][class*="h-4"] {
                    box-shadow: none !important;
                }
                button, a[role="button"], [role="button"] {
                    color: #edf8f5 !important;
                    border-color: rgba(118, 173, 165, 0.20) !important;
                    transition: background-color 120ms ease, border-color 120ms ease, color 120ms ease;
                }
                button:hover, button:focus,
                a[role="button"]:hover, a[role="button"]:focus,
                [role="button"]:hover, [role="button"]:focus {
                    background: #1b3436 !important;
                    border-color: rgba(126, 228, 220, 0.34) !important;
                    color: #ffffff !important;
                }
                canvas, [class*="chart"], [class*="Chart"], [class*="recharts"] {
                    background: transparent !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                [class*="chart"] svg, [class*="Chart"] svg,
                [class*="recharts"] svg {
                    background: transparent !important;
                    overflow: visible !important;
                }
                [class*="chart"] svg text,
                [class*="Chart"] svg text,
                [class*="chart"] svg line,
                [class*="Chart"] svg line,
                [class*="chart"] svg rect,
                [class*="Chart"] svg rect {
                    opacity: 0 !important;
                }
                [class*="recharts-cartesian-axis"],
                [class*="recharts-cartesian-grid"],
                [class*="recharts-tooltip-cursor"],
                [class*="axis"], [class*="Axis"],
                [class*="tick"], [class*="Tick"] {
                    opacity: 0 !important;
                }
                [class*="recharts-line-curve"],
                [class*="recharts-area-curve"],
                [class*="chart"] svg path,
                [class*="Chart"] svg path,
                [class*="chart"] svg polyline,
                [class*="Chart"] svg polyline {
                    stroke: #15d6a4 !important;
                    stroke-width: 3 !important;
                    stroke-linecap: round !important;
                    stroke-linejoin: round !important;
                    filter: drop-shadow(0 0 7px rgba(21, 214, 164, 0.34)) !important;
                }
                [class*="chart"] svg path[fill="none"],
                [class*="Chart"] svg path[fill="none"],
                [class*="chart"] svg polyline,
                [class*="Chart"] svg polyline {
                    fill: none !important;
                }
                [class*="chart"] svg polygon,
                [class*="Chart"] svg polygon,
                [class*="chart"] svg path:not([fill="none"]),
                [class*="Chart"] svg path:not([fill="none"]) {
                    fill: rgba(21, 214, 164, 0.13) !important;
                    stroke: transparent !important;
                }
                [class*="recharts-area-area"] {
                    fill: rgba(21, 214, 164, 0.14) !important;
                }
                svg [stroke="#000"], svg [stroke="black"],
                svg [stroke="#111"], svg [stroke="#222"] {
                    stroke: #7ed6cf !important;
                }
                [class*="text-4xl"], [class*="text-5xl"], [class*="text-6xl"] {
                    background: transparent !important;
                    box-shadow: none !important;
                    border-color: transparent !important;
                }
                img[src*="logo"], img[src*="Logo"], img[src*="tradon"], img[src*="Tradon"] {
                    content: url("${dashboard_logo_url}") !important;
                    object-fit: contain !important;
                    background: transparent !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                [class*="logo"], [class*="Logo"] {
                    background-image: url("${dashboard_logo_url}") !important;
                    background-size: contain !important;
                    background-repeat: no-repeat !important;
                    background-position: center !important;
                    background-color: transparent !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                [class*="splash"], [class*="Splash"],
                [class*="loading"], [class*="Loading"],
                [class*="loader"], [class*="Loader"] {
                    background: transparent !important;
                    border-color: transparent !important;
                    box-shadow: none !important;
                }
                [class*="border"], hr {
                    border-color: rgba(118, 173, 165, 0.22) !important;
                }
                input, select, textarea {
                    background: #1a3033 !important;
                    border-color: rgba(118, 173, 165, 0.26) !important;
                    color: #f4fbf8 !important;
                }
            `;
            if (mode != 'dark') {
                style.textContent = compact_dashboard_css;
                Sao.traceHTMLViewerTheme('apply-light-compact-injected', {
                    src: iframe.getAttribute('src') || '',
                    bridgePresent: Boolean(doc.getElementById(
                        'tradon-htmlviewer-theme-bridge')),
                    styleLength: style && style.textContent ?
                        style.textContent.length : 0,
                });
                return;
            }
            style.textContent = compact_dashboard_css + dark_dashboard_css;
            doc.querySelectorAll('img').forEach(function(img) {
                var marker = [
                    img.getAttribute('src') || '',
                    img.getAttribute('alt') || '',
                    img.className || '',
                    img.id || '',
                ].join(' ');
                if (!/logo|tradon/i.test(marker)) {
                    return;
                }
                if (!img.getAttribute('data-tradon-light-src')) {
                    img.setAttribute('data-tradon-light-src', img.getAttribute('src') || '');
                }
                img.setAttribute('src', dashboard_logo_url);
                var parent = img.parentElement;
                var depth = 0;
                while (parent && depth < 3) {
                    var parent_marker = [
                        parent.className || '',
                        parent.id || '',
                    ].join(' ');
                    if (/logo|tradon|splash|loading|loader/i.test(parent_marker) ||
                            parent.children.length == 1) {
                        parent.style.setProperty('background', 'transparent', 'important');
                        parent.style.setProperty('background-color', 'transparent', 'important');
                        parent.style.setProperty('border-color', 'transparent', 'important');
                        parent.style.setProperty('box-shadow', 'none', 'important');
                    }
                    parent = parent.parentElement;
                    depth += 1;
                }
            });
            Sao.traceHTMLViewerTheme('apply-dark-injected', {
                src: iframe.getAttribute('src') || '',
                docThemeAfter: doc.documentElement.getAttribute(
                    'data-theme-mode') || null,
                bodyThemeAfter: doc.body &&
                    doc.body.getAttribute('data-theme-mode') || null,
                bridgePresent: Boolean(doc.getElementById(
                    'tradon-htmlviewer-theme-bridge')),
                styleLength: style && style.textContent ?
                    style.textContent.length : 0,
                bodyClass: doc.body && doc.body.className || '',
                rootFound: Boolean(doc.getElementById('root') ||
                    doc.getElementById('app')),
            });
        } catch (e) {
            Sao.traceHTMLViewerTheme('apply-error', {
                src: iframe.getAttribute('src') || '',
                errorName: e && e.name || '',
                errorMessage: e && e.message || String(e),
            });
            // Cross-origin dashboards can still read the theme query parameter.
        }
    };

    Sao.renderPurchaseSummaryMap = function(node, map, height) {
        var ensure_scripts = function(callback) {
            var remaining = 0;
            var done = function() {
                remaining -= 1;
                if (remaining <= 0) {
                    callback();
                }
            };
            if (typeof d3 == 'undefined') {
                remaining += 1;
                var d3_script = document.createElement('script');
                d3_script.src = 'https://d3js.org/d3.v7.min.js';
                d3_script.onload = done;
                document.head.appendChild(d3_script);
            }
            if (typeof topojson == 'undefined') {
                remaining += 1;
                var topo_script = document.createElement('script');
                topo_script.src = 'https://d3js.org/topojson.v3.min.js';
                topo_script.onload = done;
                document.head.appendChild(topo_script);
            }
            if (!remaining) {
                callback();
            }
        };
        var label_boxes = [];
        var as_point = function(point) {
            if (!point || !isFinite(Number(point.lat)) ||
                    !isFinite(Number(point.lon))) {
                return null;
            }
            return {
                lat: Number(point.lat),
                lon: Number(point.lon),
            };
        };
        var auto_sea_waypoints = function(from_point, to_point) {
            if (!from_point || !to_point) {
                return [];
            }
            var east_asia = from_point.lon >= 95 && from_point.lat > 10;
            var east_africa = to_point.lon >= 25 && to_point.lon <= 55 &&
                to_point.lat <= 5;
            if (east_asia && east_africa) {
                return [
                    {lon: 122, lat: 22},
                    {lon: 104, lat: 1},
                    {lon: 79, lat: -8},
                    {lon: 55, lat: -20},
                    {lon: 43, lat: -21},
                ];
            }
            if (east_africa && east_asia) {
                return [
                    {lon: 43, lat: -21},
                    {lon: 55, lat: -20},
                    {lon: 79, lat: -8},
                    {lon: 104, lat: 1},
                    {lon: 122, lat: 22},
                ];
            }
            return [];
        };
        var route_points = function(map, from_point, to_point) {
            var configured = map.route_points || map.sea_route ||
                map.waypoints || [];
            var middle = configured.map(as_point).filter(Boolean);
            if (!middle.length) {
                middle = auto_sea_waypoints(from_point, to_point);
            }
            return [from_point].concat(middle, [to_point]).filter(Boolean);
        };
        var place_label = function(projection, point, text, base_dy) {
            var projected = projection([point.lon, point.lat]);
            if (!projected) {
                return null;
            }
            var value = String(text || '');
            var width = Math.max(34, value.length * 6.8);
            var candidates = [
                {dx: 0, dy: base_dy || -18, anchor: 'middle'},
                {dx: 17, dy: -3, anchor: 'start'},
                {dx: -17, dy: -3, anchor: 'end'},
                {dx: 0, dy: 18, anchor: 'middle'},
            ];
            for (const candidate of candidates) {
                var x = projected[0] + candidate.dx;
                var y = projected[1] + candidate.dy;
                var x1 = candidate.anchor == 'end' ? x - width :
                    candidate.anchor == 'middle' ? x - width / 2 : x;
                var box = {x1: x1, x2: x1 + width, y1: y - 11, y2: y + 3};
                var overlaps = label_boxes.some(existing =>
                    !(box.x2 < existing.x1 || box.x1 > existing.x2 ||
                        box.y2 < existing.y1 || box.y1 > existing.y2));
                if (!overlaps) {
                    label_boxes.push(box);
                    return {x: x, y: y, anchor: candidate.anchor, text: value};
                }
            }
            return {
                x: projected[0],
                y: projected[1] + (base_dy || -18),
                anchor: 'middle',
                text: value,
            };
        };
        ensure_scripts(function() {
            var el = jQuery(node);
            el.empty();
            var max_width = Number(map.width || map.max_width || 214);
            var width = Math.min(
                Math.max(170, node.clientWidth || max_width),
                Math.max(170, max_width));
            var h = Math.max(88, height || node.clientHeight || 104);
            node.style.width = width + 'px';
            node.style.maxWidth = width + 'px';
            node.style.flex = '0 0 ' + width + 'px';
            var departures = map.departures || [];
            var arrivals = map.arrivals || [];
            var from = map.from || (departures[0] && departures[0].name) || '';
            var to = map.to || (arrivals[0] && arrivals[0].name) || '';
            var points = departures.concat(arrivals).filter(point =>
                isFinite(Number(point.lat)) && isFinite(Number(point.lon)));
            var center_lon = points.length ?
                d3.mean(points, point => Number(point.lon)) : 15;
            var center_lat = points.length ?
                d3.mean(points, point => Number(point.lat)) : 15;
            var projection = d3.geoNaturalEarth1()
                .scale(Math.min(width, h) * 0.43)
                .center([center_lon, center_lat])
                .translate([width / 2, h / 2]);
            var path = d3.geoPath().projection(projection);
            var svg = d3.select(node).append('svg')
                .attr('class', 'purchase-summary-world-svg')
                .attr('width', width)
                .attr('height', h)
                .attr('viewBox', '0 0 ' + width + ' ' + h)
                .style('width', width + 'px')
                .style('max-width', width + 'px');
            d3.json('https://unpkg.com/world-atlas@2.0.2/countries-110m.json')
                .then(function(world) {
                    var countries = topojson.feature(
                        world, world.objects.countries).features;
                    svg.selectAll('path.purchase-summary-country')
                        .data(countries)
                        .join('path')
                        .attr('class', 'purchase-summary-country')
                        .attr('d', path);
                    var draw_point = function(point, letter, extra_class) {
                        var projected = projection([point.lon, point.lat]);
                        if (!projected) {
                            return;
                        }
                        var group = svg.append('g')
                            .attr('class', 'purchase-summary-map-marker ' +
                                (extra_class || ''))
                            .attr('transform', 'translate(' + projected[0] +
                                ' ' + projected[1] + ')');
                        group.append('circle').attr('r', 7);
                        group.append('text').attr('y', 3).text(letter);
                    };
                    if (departures[0]) {
                        draw_point(departures[0], 'A', '');
                    }
                    if (arrivals[0]) {
                        draw_point(arrivals[0], 'D',
                            'purchase-summary-map-marker-to');
                    }
                    if (departures[0] && arrivals[0]) {
                        var route = route_points(
                            map, as_point(departures[0]), as_point(arrivals[0]))
                            .map(function(point) {
                                return projection([point.lon, point.lat]);
                            })
                            .filter(Boolean);
                        if (route.length >= 2) {
                            var route_line = d3.line()
                                .x(point => point[0])
                                .y(point => point[1])
                                .curve(d3.curveCatmullRom.alpha(0.5));
                            svg.insert('path', '.purchase-summary-map-marker')
                                .attr('class', 'purchase-summary-route-svg')
                                .attr('d', route_line(route));
                        }
                    }
                    [
                        {point: departures[0], text: from, dy: -17},
                        {point: arrivals[0], text: to, dy: 20},
                    ].forEach(function(item) {
                        if (!item.point) {
                            return;
                        }
                        var placed = place_label(
                            projection, item.point, item.text, item.dy);
                        if (!placed) {
                            return;
                        }
                        svg.append('text')
                            .attr('class', 'purchase-summary-map-label')
                            .attr('x', placed.x)
                            .attr('y', placed.y)
                            .attr('text-anchor', placed.anchor)
                            .text(placed.text);
                    });
                });
        });
    };

    Sao.renderPurchaseSummaryContent = function(container, value, height) {
        var decode = function(raw) {
            if (!raw) {
                return {};
            }
            if (typeof raw == 'object') {
                return raw;
            }
            var text = String(raw || '');
            if (text.startsWith('purchase_summary:')) {
                text = text.slice(17);
            }
            return JSON.parse(text || '{}');
        };
        var escape = function(raw) {
            return jQuery('<div/>').text(raw === undefined || raw === null ?
                '' : String(raw)).html();
        };
        var doc_link = function(kind, link, label) {
            link = link || {};
            var count = Number(link.count || link.total || (link.res_id ? 1 : 0));
            var model = link.model || '';
            var res_id = link.res_id || link.id || '';
            var view_mode = link.view_mode || link.view || 'form';
            var disabled = !model || !res_id;
            var title = link.title || (kind == 'shipment' ? 'Open shipment' :
                kind == 'sale' ? 'Open sale' : 'Open invoice');
            return '<button type="button" class="purchase-summary-doc-link ' +
                'purchase-summary-doc-' + escape(kind) + '"' +
                (disabled ? ' disabled aria-disabled="true"' :
                    ' data-model="' + escape(model) + '"' +
                    ' data-res-id="' + escape(res_id) + '"' +
                    ' data-view-mode="' + escape(view_mode) + '"') +
                ' title="' + escape(title) + '" aria-label="' +
                    escape(title) + '">' +
                '<span class="purchase-summary-doc-label">' +
                    escape(label || '') + '</span>' +
                '<span class="purchase-summary-doc-count">' + escape(count) +
                    '</span></button>';
        };
        var number = function(value, digits) {
            var numeric = Number(value || 0);
            return numeric.toLocaleString(undefined, {
                maximumFractionDigits: digits === undefined ? 0 : digits,
                minimumFractionDigits: digits === undefined ? 0 : digits,
            });
        };
        var clean_label = function(value) {
            var label = value === undefined || value === null ? '' :
                String(value);
            if (/^\d+$/.test(label)) {
                label = '';
            }
            return label;
        };
        var money = function(value, currency, digits) {
            var label = currency === undefined || currency === null ? '' :
                String(currency);
            if (/^\d+$/.test(label)) {
                label = '';
            }
            return [escape(label), number(value, digits)].filter(Boolean)
                .join(' ');
        };
        var signed = function(value, suffix) {
            var numeric = Number(value || 0);
            var sign = numeric >= 0 ? '+' : '';
            return sign + number(numeric, 2) + (suffix || '');
        };
        var tone = function(value) {
            var numeric = Number(value || 0);
            if (numeric > 0) return 'positive';
            if (numeric < 0) return 'negative';
            return 'neutral';
        };
        var config = decode(value);
        var map = config.map || {};
        var execution = config.execution || {};
        var totals = execution.totals || {};
        var rows = execution.rows || [];
        var links = execution.links || config.links || {};
        var sale_link = links.sale || links.sales || execution.sale ||
            execution.sales || config.sale || config.sales || {};
        var shipment_link = links.shipment || links.shipments ||
            execution.shipment || execution.shipments || config.shipment ||
            config.shipments || {};
        var pnl = config.pnl || {};
        var mtm = config.mtm || {};
        var amount = config.amount || config.financial_amount || totals.amount ||
            pnl.amount || {};
        var amount_value = typeof amount == 'object' ? amount.value : amount;
        var amount_currency = clean_label(config.total_currency ||
            config.currency_badge || (typeof amount == 'object' ?
                amount.currency : '') || config.currency);
        var normalize_chips = function(raw) {
            if (!raw) {
                return [];
            }
            if (raw instanceof Array) {
                return raw;
            }
            if (typeof raw == 'object') {
                if (raw.dimension || raw.label || raw.name || raw.value ||
                        raw.text) {
                    return [raw];
                }
                return Object.keys(raw).map(function(key) {
                    return {
                        dimension: key,
                        value: raw[key],
                    };
                });
            }
            return [];
        };
        var total_chips = normalize_chips(config.total_chips ||
            config.total_badges || config.dimension_chips ||
            config.total_dimensions || config.dimensions ||
            (typeof amount == 'object' ? amount.dimensions : []));
        var total_chips_html = total_chips.slice(0, 2).map(function(item) {
            if (item instanceof Array) {
                item = {
                    dimension: item[0],
                    value: item[1],
                };
            }
            var dimension = item.dimension || item.label || item.name ||
                item.key || '';
            var chip_value = item.value !== undefined ? item.value :
                item.text !== undefined ? item.text : '';
            if (!dimension || chip_value === undefined || chip_value === null ||
                    chip_value === '') {
                return '';
            }
            return '<span class="purchase-summary-total-chip">' +
                escape(dimension) + ': ' + escape(chip_value) + '</span>';
        }).filter(Boolean).join('');
        var departures = map.departures || [];
        var arrivals = map.arrivals || [];
        var from = map.from || (departures[0] && departures[0].name) || '';
        var to = map.to || (arrivals[0] && arrivals[0].name) || '';
        var h = Math.max(162, Number(config.height || config.banner_height ||
            Math.min(Number(height || 162), 162)));
        var map_id = 'purchase-summary-map-' + Math.random().toString(36).slice(2);

        var row_quantity = function(row) {
            var physical = row.physical !== undefined ? row.physical :
                row.physic !== undefined ? row.physic :
                row.open !== undefined ? row.open :
                row.shipped || 0;
            var open = row.open !== undefined && row.physical !== undefined ?
                row.open : row.quantity || totals.quantity || 0;
            return number(physical) + '/' + number(open) + ' ' +
                (row.unit || totals.unit || '');
        };
        var quantity_value = function(value, row) {
            return number(value || 0) + (row.unit || totals.unit ?
                ' ' + (row.unit || totals.unit) : '');
        };
        var percent_value = function(row, keys) {
            for (var i = 0; i < keys.length; i++) {
                if (row[keys[i]] !== undefined && row[keys[i]] !== null &&
                        row[keys[i]] !== '') {
                    if (typeof row[keys[i]] == 'string' &&
                            row[keys[i]].indexOf('%') >= 0) {
                        return row[keys[i]];
                    }
                    return number(row[keys[i]], 0) + '%';
                }
            }
            return '';
        };
        var money_value = function(value, row) {
            if (value && typeof value == 'object') {
                return money(value.value, value.currency || row.currency ||
                    config.currency, value.digits);
            }
            return money(value || 0, row.currency || config.currency, 0);
        };
        var raw_metric_value = function(value) {
            if (value && typeof value == 'object') {
                return Number(value.value || 0);
            }
            return Number(value || 0);
        };
        var line_metric = function(label, value, row, extra_class) {
            return '<span class="' + escape(extra_class || '') + ' ' +
                tone(raw_metric_value(value)) + '"><small>' + escape(label) +
                '</small><strong>' + escape(money_value(value, row)) +
                '</strong></span>';
        };
        var exec_icon = function(name) {
            return '<i class="glyphicon glyphicon-' + escape(name) +
                ' purchase-summary-exec-icon" aria-hidden="true"></i>';
        };
        var exec_metric = function(row_class, label, icon, value) {
            return '<span class="' + escape(row_class) + '">' +
                exec_icon(icon) +
                '<span class="purchase-summary-exec-copy"><small>' +
                    escape(label) + '</small><strong>' + escape(value) +
                '</strong></span></span>';
        };
        var period_value = function(row) {
            return row.period || row.shipment_period || row.period_at ||
                row.date_period || totals.period || totals.shipment_period || '';
        };
        var visible_rows = rows.length ? rows : [{
            index: 1,
            product: totals.product || 'Line',
            quantity: totals.quantity,
            period: totals.period || totals.shipment_period,
            matched: totals.matched,
            shipped: totals.shipped,
            unit: totals.unit,
            pnl: totals.pnl,
            mtm: totals.mtm,
            amount: totals.amount,
        }];
        var exec_count = visible_rows.length;
        var exec_mode = exec_count <= 1 ? 'one' :
            exec_count == 2 ? 'two' :
            'paged';
        var rows_per_page = exec_mode == 'paged' ? 2 : visible_rows.length;
        var page_count = Math.max(1,
            Math.ceil(visible_rows.length / rows_per_page));
        var page = 0;
        var rows_html = function(current_page) {
            current_page = Math.max(0, Math.min(page_count - 1,
                current_page || 0));
            var start = current_page * rows_per_page;
            var page_rows = visible_rows.slice(start, start + rows_per_page);
            return page_rows.map(function(row, index) {
            var row_sale = row.sale || row.sales || sale_link;
            var row_shipment = row.shipment || row.shipments || shipment_link;
            return '<div class="purchase-summary-exec-row">' +
                '<span class="purchase-summary-exec-index">' +
                    escape(row.index || row.sequence || start + index + 1) +
                    '</span>' +
                '<span class="purchase-summary-exec-product-title">Product</span>' +
                '<span class="purchase-summary-exec-contract-title">Contract &amp; Status</span>' +
                '<strong class="purchase-summary-exec-product" title="' +
                    escape(row.product || row.label || 'Line') +
                    '">' + escape(row.product || row.label || 'Line') + '</strong>' +
                '<span class="purchase-summary-exec-qty"><small>Qty</small>' +
                    escape(row_quantity(row)) + '</span>' +
                exec_metric('purchase-summary-exec-period', 'Period',
                    'calendar', period_value(row)) +
                exec_metric('purchase-summary-exec-priced', 'Priced',
                    'tag', percent_value(row, ['priced', 'priced_pct',
                        'priced_percent'])) +
                exec_metric('purchase-summary-exec-hedged', 'Hedged',
                    'ok-circle', percent_value(row, ['hedged', 'hedged_pct',
                        'hedged_percent'])) +
                exec_metric('purchase-summary-exec-invoiced', 'Invoiced',
                    'list-alt', percent_value(row, ['invoiced', 'invoiced_pct',
                        'invoiced_percent'])) +
                exec_metric('purchase-summary-exec-paid', 'Paid',
                    'usd', percent_value(row, ['paid', 'paid_pct',
                        'paid_percent'])) +
                exec_metric('purchase-summary-exec-matched', 'Matched',
                    'th-large', quantity_value(row.matched, row)) +
                exec_metric('purchase-summary-exec-shipped', 'Shipped',
                    'transfer', quantity_value(row.shipped, row)) +
                '<span class="purchase-summary-exec-sales">' +
                    doc_link('sale', row_sale, 'Sales') + '</span>' +
                '<span class="purchase-summary-exec-shipments">' +
                    doc_link('shipment', row_shipment, 'Shipments') +
                    '</span>' +
                '<div class="purchase-summary-exec-financial">' +
                    line_metric('PnL', row.pnl, row, 'purchase-summary-exec-pnl') +
                    line_metric('MTM', row.mtm, row, 'purchase-summary-exec-mtm') +
                    line_metric('Amount', row.amount, row,
                        'purchase-summary-exec-amount') +
                '</div>' +
                '</div>';
            }).join('');
        };
        var pager_html = page_count > 1 ?
            '<div class="purchase-summary-exec-pager">' +
                '<button type="button" class="purchase-summary-exec-page-prev" ' +
                    'aria-label="' + escape(Sao.i18n.gettext('Previous')) +
                    '" title="' + escape(Sao.i18n.gettext('Previous')) +
                    '"></button>' +
                '<span class="purchase-summary-exec-page-label"></span>' +
                '<button type="button" class="purchase-summary-exec-page-next" ' +
                    'aria-label="' + escape(Sao.i18n.gettext('Next')) +
                    '" title="' + escape(Sao.i18n.gettext('Next')) +
                    '"></button>' +
            '</div>' : '';

        container.empty().append(
            '<section class="purchase-summary-banner" style="--purchase-summary-height:' +
                h + 'px">' +
                '<div class="purchase-summary-map">' +
                    '<div class="purchase-summary-map-heading">' +
                        '<small>Route</small>' +
                        '<strong>' + escape([from, to].filter(Boolean)
                            .join(' \u2192 ')) + '</strong>' +
                    '</div>' +
                    '<div id="' + map_id + '" class="purchase-summary-world"></div>' +
                '</div>' +
                '<div class="purchase-summary-execution">' +
                    '<div class="purchase-summary-exec-rows purchase-summary-exec-' +
                        exec_mode + '">' + rows_html(page) + '</div>' +
                    pager_html +
                '</div>' +
                '<div class="purchase-summary-total">' +
                    (amount_currency ? '<em class="purchase-summary-currency-badge">' +
                        escape(amount_currency) + '</em>' : '') +
                    '<div class="purchase-summary-metrics">' +
                        '<span><small>PnL</small><strong>' +
                            money(pnl.value, pnl.currency || config.currency, 0) +
                            '<em class="purchase-summary-badge ' +
                            tone(pnl.change_d1) + '">' + signed(
                                pnl.change_d1, pnl.change_suffix || '') +
                            '</em></strong></span>' +
                        '<span><small>MTM</small><strong>' +
                            money(mtm.value, mtm.currency || pnl.currency ||
                                config.currency, 0) + '<em class="purchase-summary-badge ' +
                            tone(mtm.change) + '">' + signed(
                                mtm.change, mtm.change_suffix || '') +
                            '</em></strong></span>' +
                        '<span class="purchase-summary-amount"><small>Amount</small><strong>' +
                            number(amount_value, typeof amount == 'object' ?
                                amount.digits : 0) + '</strong></span>' +
                    '</div>' +
                    (total_chips_html ? '<div class="purchase-summary-total-chips">' +
                        total_chips_html + '</div>' : '') +
                '</div>' +
            '</section>');
        var bind_doc_links = function(scope) {
            scope.find('.purchase-summary-doc-link').off('click').on(
                    'click', function(evt) {
            evt.preventDefault();
            evt.stopPropagation();
            var button = jQuery(this);
            if (button.is('[disabled], .disabled')) {
                return;
            }
            var model = button.attr('data-model');
            var res_id = parseInt(button.attr('data-res-id'), 10);
            var view_mode = button.attr('data-view-mode') || 'form';
            if (!model || isNaN(res_id)) {
                return;
            }
            Sao.Tab.create({
                model: model,
                res_id: res_id,
                mode: [view_mode],
            });
            });
        };
        var update_page = function(next_page) {
            page = Math.max(0, Math.min(page_count - 1, next_page));
            var start = page * rows_per_page + 1;
            var end = Math.min(visible_rows.length, (page + 1) * rows_per_page);
            var rows_node = container.find('.purchase-summary-exec-rows');
            rows_node.html(rows_html(page));
            bind_doc_links(rows_node);
            container.find('.purchase-summary-exec-page-label')
                .text(start + '-' + end + ' / ' + visible_rows.length);
            container.find('.purchase-summary-exec-page-prev')
                .prop('disabled', page <= 0);
            container.find('.purchase-summary-exec-page-next')
                .prop('disabled', page >= page_count - 1);
        };
        bind_doc_links(container);
        container.find('.purchase-summary-exec-page-prev').on('click',
            function(evt) {
                evt.preventDefault();
                update_page(page - 1);
            });
        container.find('.purchase-summary-exec-page-next').on('click',
            function(evt) {
                evt.preventDefault();
                update_page(page + 1);
            });
        update_page(page);
        Sao.renderPurchaseSummaryMap(
            container.find('#' + map_id)[0], map, Math.max(88, h - 34));
    };

    Sao.renderHTMLViewerContent = function(container, value, height, width, record) {
        function decodeHtmlEntities(str) {
            const txt = document.createElement('textarea');
            txt.innerHTML = str;
            return txt.value;
        }
        container.empty();
        container.removeClass('htmlviewer-react-dashboard');

        if (!value) return;

        if (String(value || '').startsWith('purchase_summary:')) {
            Sao.renderPurchaseSummaryContent(container, value, height);
            return;
        } else if (/^imo:\d+$/.test(value)) {
            // Case 1: VesselFinder ship by IMO
            var imo = value.split(':')[1];

            var iframe = document.createElement('iframe');
            iframe.style.width = '100%';
            iframe.style.height = height.toString()+'px';
            iframe.style.border = '0';
            iframe.src = `https://www.vesselfinder.com/aismap?width=100%25&height=${height-10}&names=true&imo=${imo}&track=true`;
            container[0].appendChild(iframe);

        } else if (/^https?:\/\//i.test(value)) {
            // Case 2: Generic URL (not VesselFinder)
            var is_dashboard = Sao.isHTMLViewerDashboardURL(value);
            Sao.traceHTMLViewerTheme('render-url', {
                value: value,
                isDashboard: is_dashboard,
                height: height,
                width: width,
            });
            var iframe = jQuery('<iframe/>', {
                src: Sao.withHTMLViewerThemeParam(value),
                width: '100%',
                height: height.toString()+'px',
                frameborder: 0,
                scrolling: 'auto',
                // sandbox: 'allow-same-origin allow-scripts allow-popups allow-forms', // optional: adjust based on your security needs
            });
            if (is_dashboard) {
                iframe.attr({
                    'data-htmlviewer-dashboard': '1',
                    'data-original-src': value,
                });
                container.addClass('htmlviewer-react-dashboard');
                iframe.on('load', function() {
                    Sao.traceHTMLViewerTheme('iframe-load', {
                        src: this.getAttribute('src') || '',
                        originalSrc: this.getAttribute('data-original-src') || '',
                    });
                    Sao.applyHTMLViewerThemeToIFrame(this);
                });
            } else {
                container.removeClass('htmlviewer-react-dashboard');
            }
            container.append(iframe);
            if (is_dashboard) {
                Sao.applyHTMLViewerThemeToIFrame(iframe[0]);
            }
        } else if (value.startsWith("metabase:")) {
            const url = value.replace(/^metabase:/, '');
            const iframe = jQuery('<iframe/>', {
                src: url,
                width: '100%',
                height: height.toString()+'px',
                frameborder: 0,
                allowtransparency: 'true',
                sandbox: 'allow-same-origin allow-scripts allow-popups allow-forms',
            });
            container.append(iframe);
        } else if (value.startsWith("chatbot:")) {
            let chatData = JSON.parse(value.replace(/^chatbot:/, '')) || [];
            let lastMessage = chatData[chatData.length - 1];
            container.empty();
            const chatHeight = height - 60;

            // 1. Ajouter le conteneur HTML
            $('<div/>', {
                id: 'chat-wrapper',
                html: `
                    <style>
                    
                        .chat-bubble {
                            max-width: 70%;
                            padding: 10px 15px;
                            margin: 5px 0;
                            border-radius: 18px;
                            clear: both;
                            font-weight: 500;
                            display: inline-block;
                        }

                        .chat-bubble.bot {
                            background-color: #f1f1f1;
                            float: left;
                            border-radius: 0 18px 18px 18px;
                            color: #000;
                        }

                        .chat-bubble.user {
                            background-color: #c2cbc7;
                            float: right;
                            border-radius: 18px 18px 0 18px;
                            color: #000;
                        }

                        #chat-input {
                            margin-top: 10px;
                            display: flex;
                            gap: 5px;
                        }

                        #chat-input input {
                            flex: 1;
                            padding: 10px;
                            border-radius: 20px;
                            border: 1px solid #ccc;
                            outline: none;
                            font-size: 1em;
                        }

                        #chat-input button {
                            padding: 10px 20px;
                            border-radius: 20px;
                            background-color: #267f82;
                            color: white;
                            border: none;
                            cursor: pointer;
                        }
                    </style>

                    <div id="chat-container"></div>
                    <div id="chat-input">
                        <input type="text" id="user-input" placeholder="Ask anything..." />
                        <button id="send-btn">Send</button>
                    </div>
                `,
                css: {
                    width: '100%',
                    height: height.toString()+'px',
                    boxSizing: 'border-box'
                }
            }).appendTo(container);

            $('#chat-container').css({
                height: chatHeight.toString() + 'px',
                overflowY: 'auto',
                borderRadius: '16px',
                border: '1px solid #ccc',
                padding: '10px',
                boxSizing: 'border-box',
                background: '#fff'
            });

            // 2. Ajout des messages de l'historique
            const chatContainer = document.getElementById('chat-container');

            const addMessage = (type, content) => {
                if ((type === 'bot') || (type === 'user')){
                    const bubble = document.createElement('div');
                    bubble.className = `chat-bubble ${type}`;
                    bubble.innerHTML = decodeHtmlEntities(content);
                    chatContainer.appendChild(bubble);
                    bubble.querySelectorAll('a.tryton-link').forEach(link => {
                        link.addEventListener('click', (event) => {
                            event.preventDefault();
                            const target = event.currentTarget;
                            const model = target.getAttribute('data-model');
                            const res_id = parseInt(target.getAttribute('data-id'), 10);
                            const view_mode = target.getAttribute('data-view') || 'form';

                            if (!model || isNaN(res_id)) return;

                            Sao.Tab.create({
                                model: model,
                                res_id: res_id,
                                mode: [view_mode],
                                target: "new",
                            });
                        });
                    });
                    chatContainer.scrollTop = chatContainer.scrollHeight;
                }
            };
            for (let msg of chatData) {
                addMessage(msg.type, msg.content);
            }

            // 3. Gérer la saisie utilisateur
            const input = document.getElementById('user-input');
            const button = document.getElementById('send-btn');

            button.addEventListener('click', () => {
                const question = input.value.trim();
                if (question) {
                    addMessage('user', question);
                    let currentUserId = Sao.Session.current_session.user_id;
                    // charger les inputs via RPC
                    let fields = ['id','user_id'];  // les champs dont on a besoin
                    let domain = [['user_id', '=', currentUserId]];  // filtrer sur ce dashboard

                    Sao.rpc({
                        'method': 'model.purchase.dashboard.search_read',
                        'params': [domain, 0, null, null, fields, Sao.Session.current_session.context]
                    }, Sao.Session.current_session, true, false).then(inputs => {
                        console.log("RPC results",inputs);
                        let userInput = inputs.find(r => r.user_id === currentUserId);
                        Sao.Action.execute(479, {
                            model: 'purchase.dashboard',
                            id: userInput.id,
                            ids: [userInput.id]
                        }, {user_request: question}, Sao.Session.current_session.context, true)
                        .then(() => { 
                            chatData.push({ type: 'user', content: question });
                            input.value = '';
                            record.field_set_client('input', JSON.stringify(chatData));
                        })
                    });
                }
            });

            // 4. Permettre la touche "Entrée"
            input.addEventListener('keydown', (e) => {
                if (e.key === 'Enter') {
                    button.click();
                }
            });
        } else {
            if (value.startsWith('d3:')) {
                const json = value.slice(3);
                const config = JSON.parse(json); // doit contenir { highlightedCountry: '250' }

                if (config.type === 'execution-summary') {
                    let d3Div = container.find('.d3-execution-summary-container');
                    if (d3Div.length === 0) {
                        d3Div = $('<div/>', {
                            class: 'd3-execution-summary-container',
                            css: {
                                width: '100%',
                                height: height.toString() + 'px',
                                overflow: 'hidden',
                                background: 'transparent'
                            }
                        }).appendTo(container);
                    } else {
                        d3Div.empty();
                    }

                    const loadD3ExecutionSummary = () => {
                        const node = d3Div[0];
                        const w = Math.max(300, node.clientWidth || width || 360);
                        const h = Math.max(125, height || 160);
                        const totals = config.totals || {};
                        const rows = config.rows || [];
                        const totalQty = Math.max(1, Number(totals.quantity || 0));
                        const matched = Number(totals.matched || 0);
                        const shipped = Number(totals.shipped || 0);
                        const matchedPct = Math.min(100, Math.round((matched / totalQty) * 100));
                        const shippedPct = Math.min(100, Math.round((shipped / totalQty) * 100));
                        const fmt = d3.format(',.0f');
                        const hasTitle = Boolean(config.title);
                        const statsY = hasTitle ? 30 : 8;
                        const barY = hasTitle ? 58 : 36;
                        const rowY = hasTitle ? 84 : 62;
                        const html = document.documentElement;
                        const themeMode = html.getAttribute('data-theme-mode');
                        const isDarkMode = themeMode === 'dark';
                        const isLightMode = !isDarkMode && (
                            html.getAttribute('theme') === 'default' ||
                            themeMode === 'light');
                        const palette = !isLightMode ? {
                            matched: '#65d7d0',
                            shipped: '#4fb8b2',
                            open: '#405b56',
                            text: '#eef7f4',
                            muted: '#b7cac5',
                            line: '#66817b'
                        } : {
                            matched: '#2b8c87',
                            shipped: '#4ba9a4',
                            open: '#dce9e5',
                            text: '#173b40',
                            muted: '#526967',
                            line: '#bfd4ce'
                        };

                        const svg = d3.select(node).append('svg')
                            .attr('width', w)
                            .attr('height', h)
                            .attr('viewBox', `0 0 ${w} ${h}`);

                        if (hasTitle) {
                            svg.append('text')
                                .attr('x', 4)
                                .attr('y', 15)
                                .attr('font-family', 'Inter, sans-serif')
                                .attr('font-size', 12)
                                .attr('font-weight', 700)
                                .attr('fill', palette.text)
                                .text(config.title);
                        }

                        const stats = [
                            {label: 'Lines', value: fmt(totals.lines || rows.length || 0)},
                            {label: 'Matched', value: matchedPct + '%'},
                            {label: 'Shipped', value: shippedPct + '%'}
                        ];
                        const stat = svg.selectAll('g.stat')
                            .data(stats)
                            .join('g')
                            .attr('class', 'stat')
                            .attr('transform', (d, i) => `translate(${4 + i * Math.min(95, (w - 16) / 3)}, ${statsY})`);
                        stat.append('text')
                            .attr('font-family', 'Inter, sans-serif')
                            .attr('font-size', 10)
                            .attr('fill', palette.muted)
                            .text(d => d.label);
                        stat.append('text')
                            .attr('y', 16)
                            .attr('font-family', 'Inter, sans-serif')
                            .attr('font-size', 14)
                            .attr('font-weight', 700)
                            .attr('fill', palette.text)
                            .text(d => d.value);

                        const barX = 4;
                        const barW = Math.max(160, w - 8);
                        const barH = 8;
                        svg.append('rect')
                            .attr('x', barX)
                            .attr('y', barY)
                            .attr('width', barW)
                            .attr('height', barH)
                            .attr('rx', 4)
                            .attr('fill', palette.open);
                        svg.append('rect')
                            .attr('x', barX)
                            .attr('y', barY)
                            .attr('width', barW * Math.min(1, matched / totalQty))
                            .attr('height', barH)
                            .attr('rx', 4)
                            .attr('fill', palette.matched);
                        svg.append('rect')
                            .attr('x', barX)
                            .attr('y', barY)
                            .attr('width', barW * Math.min(1, shipped / totalQty))
                            .attr('height', barH)
                            .attr('rx', 4)
                            .attr('fill', palette.shipped);

                        const row = svg.selectAll('g.exec-row')
                            .data(rows.slice(0, 3))
                            .join('g')
                            .attr('class', 'exec-row')
                            .attr('transform', (d, i) => `translate(4, ${rowY + i * 24})`);
                        row.append('line')
                            .attr('x1', 0)
                            .attr('x2', w - 8)
                            .attr('y1', -8)
                            .attr('y2', -8)
                            .attr('stroke', palette.line);
                        row.append('text')
                            .attr('font-family', 'Inter, sans-serif')
                            .attr('font-size', 10)
                            .attr('font-weight', 700)
                            .attr('fill', palette.text)
                            .text(d => (d.product || 'Line').slice(0, 18));
                        row.append('text')
                            .attr('x', 170)
                            .attr('font-family', 'Inter, sans-serif')
                            .attr('font-size', 10)
                            .attr('fill', palette.muted)
                            .text(d => `${fmt(d.shipped || 0)}/${fmt(d.quantity || 0)} ${d.unit || ''}`);
                        row.append('text')
                            .attr('x', 250)
                            .attr('font-family', 'Inter, sans-serif')
                            .attr('font-size', 10)
                            .attr('fill', palette.muted)
                            .text(d => (d.route || '').slice(0, 32));

                        if (!rows.length) {
                            svg.append('text')
                                .attr('x', 4)
                                .attr('y', 90)
                                .attr('font-family', 'Inter, sans-serif')
                                .attr('font-size', 11)
                                .attr('fill', palette.muted)
                                .text('No purchase lines to summarize');
                        }
                    };

                    const ensureD3 = (cb) => {
                        if (typeof d3 !== 'undefined') {
                            cb();
                            return;
                        }
                        const script = document.createElement('script');
                        script.src = 'https://d3js.org/d3.v7.min.js';
                        script.onload = cb;
                        document.head.appendChild(script);
                    };

                    ensureD3(loadD3ExecutionSummary);
                    return;
                }

                // Réutilise le conteneur si déjà présent
                let d3Div = container.find('.d3-map-container');
                if (d3Div.length === 0) {
                    d3Div = $('<div/>', {
                        class: 'd3-map-container',
                        css: {
                            width: '100%',
                            minWidth: '800px',
                            height: (height - 25*height/1000).toString() + 'px',
                            overflow: 'visible',
                            background: 'transparent'
                        }
                    }).appendTo(container);
                } else {
                    d3Div.empty(); // vide sans supprimer le conteneur
                }

                const loadD3Map = () => {
                    const svg = d3.select(d3Div[0])
                        .append("svg")
                        .attr("width", width)
                        .attr("height", (height - 25*height/1000));

                    const scale = Math.min(width, height) * 0.3;

                    const projection = d3.geoNaturalEarth1()
                        .scale(scale)
                        .translate([width / 2, (height - 10) / 2]);

                    const path = d3.geoPath().projection(projection);

                    function showTooltip(data, type) {
                        // Supprime les anciens tooltips
                        svg.selectAll(".boat-tooltip-container").remove();

                        const svgHeight = +svg.attr("height");
                        const tooltipHeight = 400;

                        const tooltipGroup = svg.append("g")
                            .attr("class", "boat-tooltip-container")
                            .attr("transform", `translate(10, ${svgHeight - tooltipHeight - 100})`);

                        let html = `
                            <div xmlns="http://www.w3.org/1999/xhtml" style="
                                font-family: Inter, sans-serif;
                                font-size: 12px;
                                color: #edf6f1;
                                background: rgba(17, 29, 32, 0.96);
                                border: 1px solid rgba(154, 190, 183, 0.18);
                                border-radius: 10px;
                                padding: 10px;
                                box-shadow: 0 2px 6px rgba(0,0,0,0.15);
                            ">
                                <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;">
                                    <p>${data.name || data.properties?.name || 'Objet'}</p>
                                    <button style="
                                        background: none;
                                        border: none;
                                        font-size: 14px;
                                        cursor: pointer;
                                        padding: 0 5px;
                                    ">&times;</button>
                                </div>
                        `;

                        if (type === 'boat') {
                            html += `
                                <div>
                                    <p><strong>IMO:</strong> ${data.imo || 'N/A'}</p>
                                    <p><strong>Position:</strong> ${data.lat.toFixed(2)}, ${data.lon.toFixed(2)}</p>
                                    <p><strong>Statut:</strong> ${data.status || 'En route'}</p>
                                    
                                    ${data.links ? `
                                    <div style="margin-top: 10px;">
                                        <ul style="padding-left: 20px; margin: 0;">
                                            ${data.links.map(link => `
                                                <li style="margin-bottom: 5px;">
                                                    <a href="${link.url}" target="_blank"
                                                        style="
                                                            color: #65d7d0;
                                                            text-decoration: none;
                                                        "
                                                        onmouseover="this.style.textDecoration='underline'"
                                                        onmouseout="this.style.textDecoration='none'"
                                                    >${link.text}</a>
                                                </li>
                                            `).join('')}
                                        </ul>
                                    </div>
                                    ` : ''}
                                    
                                    ${data.actions ? `
                                    <div style="margin-top: 15px;">
                                        ${data.actions.map(action => `
                                            <button onclick="
                                                const actionType = '${action.type}';
                                                const actionId = '${action.id}';
                                                console.log('Action:', actionType, 'ID:', actionId);
                                                // Ajoutez ici votre logique d'action
                                                d3.select(this).node().parentElement.parentElement.parentElement.parentElement.remove();
                                            "
                                                style="
                                                    background: #267f82;
                                                    color: white;
                                                    border: none;
                                                    border-radius: 4px;
                                                    padding: 6px 12px;
                                                    margin: 4px;
                                                    cursor: pointer;
                                                "
                                            >${action.label}</button>
                                        `).join('')}
                                    </div>
                                    ` : ''}
                                </div>
                            </div>
                                
                            `;
                        } else if (type === 'country') {
                            html += `
                                <p><strong>Country:</strong> ${data.properties.name}</p>
                            `;
                        }
                        
                        html += `</div>`;

                        tooltipGroup.append("foreignObject")
                            .attr("width", 180)
                            .attr("height", 400)
                            .attr("x", 10)
                            .attr("y", 100)
                            .html(html);

                        // Ajout du bouton × (sélecteur plus sûr)
                        tooltipGroup.select("button").on("click", () => {
                            svg.selectAll(".boat-tooltip-container").remove();
                        });

                        // Clic extérieur pour fermer
                        function outsideClickHandler(event) {
                            if (!event.target.closest(".boat-tooltip-container")) {
                                svg.selectAll(".boat-tooltip-container").remove();
                                document.body.removeEventListener('click', outsideClickHandler, true);
                            }
                        }
                        document.body.addEventListener('click', outsideClickHandler, true);
                    }


                    d3.json("https://unpkg.com/world-atlas@2.0.2/countries-110m.json").then(worldData => {
                        const countries = topojson.feature(worldData, worldData.objects.countries).features;
                        const highlightedNames = (config.highlightedCountryNames || []).map(d => d.name);
                        const labelBoxes = [];
                        const labelPlacement = (d, baseY, text, fontSize) => {
                            const point = projection([d.lon, d.lat]);
                            if (!point) {
                                return {show: false, y: baseY};
                            }
                            const value = String(text || '');
                            const width = Math.max(36, value.length * fontSize * 0.56);
                            const candidates = [baseY, baseY - 14, baseY + 14, baseY - 26];
                            for (const y of candidates) {
                                const box = {
                                    x1: point[0] - width / 2,
                                    x2: point[0] + width / 2,
                                    y1: point[1] + y - fontSize,
                                    y2: point[1] + y + 4
                                };
                                const overlaps = labelBoxes.some(existing =>
                                    !(box.x2 < existing.x1 || box.x1 > existing.x2 ||
                                        box.y2 < existing.y1 || box.y1 > existing.y2));
                                if (!overlaps) {
                                    labelBoxes.push(box);
                                    return {show: true, y: y};
                                }
                            }
                            return {show: false, y: baseY};
                        };
                        const html = document.documentElement;
                        const themeMode = html.getAttribute('data-theme-mode');
                        const isDarkMode = themeMode === 'dark';
                        const isLightMode = !isDarkMode && (
                            html.getAttribute('theme') === 'default' ||
                            themeMode === 'light');
                        const mapPalette = isLightMode ? {
                            country: '#9aa8aa',
                            countryHighlight: '#cf8240',
                            countryStroke: '#f2f6f4',
                            label: '#1f3336',
                            labelStroke: 'rgba(255, 255, 255, 0.92)',
                            route: '#2b8c87'
                        } : {
                            country: '#4e5d60',
                            countryHighlight: '#8a5526',
                            countryStroke: '#243538',
                            label: '#f4fbf8',
                            labelStroke: 'rgba(8, 17, 19, 0.88)',
                            route: '#0077cc'
                        };
                        const styleMapLabel = selection => selection
                            .attr("font-family", "Inter")
                            .attr("fill", mapPalette.label)
                            .attr("paint-order", "stroke")
                            .attr("stroke", mapPalette.labelStroke)
                            .attr("stroke-width", 3)
                            .attr("stroke-linejoin", "round")
                            .attr("pointer-events", "none");
                        svg.selectAll("path.country")
                            .data(countries, d => d.id)
                            .join("path")
                            .attr("class", "country")
                            .attr("d", path)
                            .attr("fill", d => highlightedNames.includes(d.properties.name) ? mapPalette.countryHighlight : mapPalette.country)
                            .attr("stroke", mapPalette.countryStroke)
                            .attr("stroke-width", 0.5)
                            .on("click", function(event, d) {
                                event.stopPropagation();
                                console.log("countries",d)
                                showTooltip(d, "country");
                            });

                        const departureGroup = svg.selectAll("g.departure-group")
                            .data(config.departures || [])
                            .join("g")
                            .attr("class", "departure-group")
                            .attr("transform", d => {
                                const [x, y] = projection([d.lon, d.lat]);
                                return `translate(${x}, ${y})`;
                            });
                        
                        departureGroup.append("image")
                            .attr("xlink:href", d => 'dist/departure.png')
                            .attr("width", 45)
                            .attr("height", 45)
                            .attr("x", -30)
                            .attr("y", -37)
                            .style("cursor", "pointer")
                            .on("click", function(event, d) {
                                event.stopPropagation();
                                showTooltip(d, "port");
                            });

                        departureGroup.append("text")
                            .text(d => `${d.name}`)
                            .attr("text-anchor", "middle")
                            .attr("font-size", "12px")
                            .each(function(d) {
                                const placement = labelPlacement(d, -35, d.name, 12);
                                d3.select(this)
                                    .attr("y", placement.y)
                                    .attr("opacity", placement.show ? 1 : 0);
                            })
                            .call(styleMapLabel);

                        const arrivalGroup = svg.selectAll("g.arrival-group")
                            .data(config.arrivals || [])
                            .join("g")
                            .attr("class", "arrival-group")
                            .attr("transform", d => {
                                const [x, y] = projection([d.lon, d.lat]);
                                return `translate(${x}, ${y})`;
                            });
                        
                        arrivalGroup.append("image")
                            .attr("xlink:href", d => 'dist/arrival.png')
                            .attr("width", 45)
                            .attr("height", 45)
                            .attr("x", -30)
                            .attr("y", -37)
                            .style("cursor", "pointer")
                            .on("click", function(event, d) {
                                event.stopPropagation();
                                showTooltip(d, "port");
                            });

                        arrivalGroup.append("text")
                            .text(d => `${d.name}`)
                            .attr("text-anchor", "middle")
                            .attr("font-size", "12px")
                            .each(function(d) {
                                const placement = labelPlacement(d, -35, d.name, 12);
                                d3.select(this)
                                    .attr("y", placement.y)
                                    .attr("opacity", placement.show ? 1 : 0);
                            })
                            .call(styleMapLabel);


                        (config.routes || []).forEach((routePoints, idx) => {
                            const departure = (config.departures || [])[idx];
                            const arrival = (config.arrivals || [])[idx];

                            const fullRoute = [
                                ...(departure ? [departure] : []),
                                ...routePoints,
                                ...(arrival ? [arrival] : [])
                            ];

                            if (fullRoute.length < 2) return;

                            const projectedRoute = fullRoute.map(d => projection([d.lon, d.lat]));

                            const lineGenerator = d3.line()
                                .x(d => d[0])
                                .y(d => d[1])
                                .curve(d3.curveCatmullRom.alpha(0.5));

                            const pathData = lineGenerator(projectedRoute);

                            const routePath = svg.append("path")
                                .attr("class", "route-line")
                                .attr("d", pathData)
                                .attr("fill", "none")
                                .attr("stroke", mapPalette.route)
                                .attr("stroke-width", 2)
                                .attr("stroke-dasharray", "6,4");

                            const totalLength = routePath.node().getTotalLength();

                            routePath
                                .attr("stroke-dashoffset", totalLength)
                                .transition()
                                .duration(4000)
                                .ease(d3.easeLinear)
                                .attr("stroke-dashoffset", 0);
                        });
                        
                        const cottonGroup = svg.selectAll("g.cotton-group")
                            .data(config.cottonStocks || [])
                            .join("g")
                            .attr("class", "cotton-group")
                            .attr("transform", d => {
                                const [x, y] = projection([d.lon, d.lat]);
                                return `translate(${x}, ${y})`;
                            });
                        
                        cottonGroup.append("image")
                            .attr("xlink:href", d => 'dist/bale.png')
                            .attr("width", 80)
                            .attr("height", 80)
                            .attr("x", -15)
                            .attr("y", -25)
                            .style("cursor", "pointer")
                            .on("click", function(event, d) {
                                event.stopPropagation();
                                showTooltip(d, "stock");
                            });

                        cottonGroup.append("text")
                            .text(d => `Stock ${d.name}`)
                            .attr("text-anchor", "middle")
                            .attr("font-size", "12px")
                            .each(function(d) {
                                const text = `Stock ${d.name}`;
                                const placement = labelPlacement(d, -20, text, 12);
                                d3.select(this)
                                    .attr("y", placement.y)
                                    .attr("opacity", placement.show ? 1 : 0);
                            })
                            .call(styleMapLabel);

                        const boatGroup = svg.selectAll("g.boat-group")
                            .data(config.boats || [])
                            .join("g")
                            .attr("class", "boat-group")
                            .attr("transform", d => {
                                const [x, y] = projection([d.lon, d.lat]);
                                return `translate(${x}, ${y})`;
                            });

                        // Ajoute l'image du bateau
                        boatGroup.append("image")
                            .attr("xlink:href", d => `dist/boat_${d.direction || "right"}.png`)
                            .attr("width", 30)
                            .attr("height", 30)
                            .attr("x", -15)
                            .attr("y", -25)
                            .style("cursor", "pointer")
                            .on("click", function(event, d) {
                                event.stopPropagation();
                                showTooltip(d, "boat");
                            });

                        // Ajoute le tooltip permanent au-dessus
                        boatGroup.append("text")
                            .text(d => d.name || "")
                            .attr("text-anchor", "middle")
                            .attr("font-size", "10px")
                            .each(function(d) {
                                const text = d.name || "";
                                const placement = labelPlacement(d, -20, text, 10);
                                d3.select(this)
                                    .attr("y", placement.y)
                                    .attr("opacity", placement.show ? 1 : 0);
                            })
                            .call(styleMapLabel);

                    }).catch(err => {
                        console.error("Erreur D3:", err);
                        d3Div.html('<p style="color:red;">Erreur lors du chargement de la carte D3</p>');
                    });
                };

                // Vérifie que d3 et topojson sont chargés
                const ensureScripts = (cb) => {
                    let remaining = 2;

                    const checkDone = () => {
                        if (--remaining === 0) cb();
                    };

                    const script = document.createElement('script');
                    script.src = 'https://d3js.org/d3.v7.min.js';
                    script.onload = () => {
                        console.log("✅ D3 rechargé, version :", d3.version);
                        checkDone();
                    };
                    document.head.appendChild(script);

                    if (typeof topojson === 'undefined') {
                        const scriptTopo = document.createElement('script');
                        scriptTopo.src = 'https://d3js.org/topojson.v3.min.js';
                        scriptTopo.onload = checkDone;
                        document.head.appendChild(scriptTopo);
                    } else {
                        checkDone();
                    }
                };

                ensureScripts(loadD3Map);
                return;
            }
            if (value.startsWith('visjson:')) {
                const json = value.slice(8);
                const graphData = JSON.parse(json);

                const iframe = $('<iframe/>', {
                    css: {
                        width: '100%',
                        height: height.toString() + 'px',
                        border: 'none'
                    }
                }).appendTo(container);

                iframe.on('load', function () {
                    const doc = iframe[0].contentDocument || iframe[0].contentWindow.document;

                    doc.open();
                    doc.write(`
            <!DOCTYPE html>
            <html>
            <head>
                <meta charset="utf-8">
                <title>Vis Network</title>

                <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>

                <style>
                    html, body {
                        margin: 0;
                        padding: 0;
                        width: 100%;
                        height: 100%;
                        overflow: hidden;
                        font-family: Inter, sans-serif;
                    }

                    #network {
                        width: 100%;
                        height: 100%;
                    }
                </style>
            </head>
            <body>
                <div id="network"></div>

                <script>
                    try {
                        const graphData = ${JSON.stringify(graphData)};

                        const nodes = new vis.DataSet(graphData.nodes || []);
                        const edges = new vis.DataSet(graphData.edges || []);

                        const network = new vis.Network(
                            document.getElementById('network'),
                            { nodes, edges },
                            {
                                layout: {
                                    improvedLayout: false
                                },
                                physics: {
                                    enabled: false,
                                    stabilization: {
                                        iterations: 200
                                    }
                                },
                                interaction: {
                                    dragNodes: true,
                                    dragView: true
                                },
                                nodes: {
                                    shape: 'box',
                                    font: { multi: 'html' }
                                },
                                edges: {
                                    arrows: 'to'
                                }
                            }
                        );

                        // Double-click handler → ouverture Tryton
                        network.on("doubleClick", function (params) {
                            params.event.preventDefault();

                            if (params.nodes.length > 0) {
                                const node = nodes.get(params.nodes[0]);
                                if (node && node.data_model && node.data_id) {
                                    parent.Sao.Tab.create({
                                        model: node.data_model,
                                        res_id: node.data_id,
                                        mode: ['form'],
                                        target: "new"
                                    });
                                }
                            } else if (params.edges.length > 0) {
                                const edge = edges.get(params.edges[0]);
                                if (edge && edge.data_model && edge.data_id) {
                                    parent.Sao.Tab.create({
                                        model: edge.data_model,
                                        res_id: edge.data_id,
                                        mode: ['form'],
                                        target: "new"
                                    });
                                }
                            }
                        });

                    } catch (e) {
                        document.body.innerHTML =
                            '<p style="color:red;padding:10px;">Erreur Vis.js</p>';
                        console.error(e);
                    }
                </script>
            </body>
            </html>
                    `);
                    doc.close();
                });

                return; // stop further rendering
            }
            if (value.startsWith('pivotjson:')) {
                const json = value.slice(10);
                const pivotData = JSON.parse(json);

                console.log('[PivotTable] Données parsées:', pivotData);
                console.log('[PivotTable] Hauteur iframe:', height);

                const htmlContent = `<!DOCTYPE html>
            <html>
            <head>
                <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pivottable/2.23.0/pivot.min.css">
                <script src="https://code.jquery.com/jquery-3.6.0.min.js"><\/script>
                <script src="https://code.jquery.com/ui/1.13.2/jquery-ui.min.js"><\/script>
                <script src="https://cdnjs.cloudflare.com/ajax/libs/pivottable/2.23.0/pivot.min.js"><\/script>
                <style>
                    body {
                        font-family: Inter, sans-serif;
                        font-size: 14px;
                        color: #333;
                        margin: 0;
                        padding: 10px;
                        background: #fff;
                    }
                    .pvtTable {
                        font-family: Inter, sans-serif;
                        font-size: 14px;
                        color: #333;
                        border-collapse: collapse;
                        width: 100%;
                    }
                    .pvtTable th,
                    .pvtTable td {
                        border: 1px solid #d0d7de !important;
                        padding: 8px 12px;
                        text-align: center;
                        background-color: #fff;
                    }
                    .pvtTotal,
                    .pvtGrandTotal {
                        background-color: #f1f1f1 !important;
                        font-weight: 600 !important;
                    }
                    .pvtAxisContainer,
                    .pvtVals {
                        background: #f9fafb !important;
                        border: 1px solid #dfe3e8 !important;
                        border-radius: 6px;
                        padding: 4px;
                        margin: 4px;
                    }
                    .pvtAxisLabel,
                    .pvtColLabel,
                    .pvtRowLabel {
                        background: #e5e9e7 !important;
                    }
                    .pvtAttr {
                        background: #c4d9c9 !important;
                    }
                    .pvtTotalLabel {
                        background: #d7ddda !important;
                    }
                    .pvtRenderer,
                    .pvtAggregator,
                    .pvtAttrDropdown {
                        font-family: Inter, sans-serif;
                        font-size: 14px;
                        padding: 6px 10px;
                        border: 1px solid #ccc;
                        border-radius: 6px;
                        background-color: #fff;
                        color: #333;
                    }
                </style>
            </head>
            <body>
                <div id="pivot" style="padding: 10px;"></div>
                <script>
                    console.log('[PivotTable][iframe] DOM prêt, chargement des scripts...');

                    function waitForLibs(callback, attempts) {
                        attempts = attempts || 0;
                        if (attempts > 50) {
                            console.error('[PivotTable][iframe] Timeout: jQuery ou pivotUI non disponibles après 5s');
                            return;
                        }
                        if (typeof jQuery === 'undefined') {
                            console.warn('[PivotTable][iframe] jQuery pas encore chargé, attente... (tentative ' + attempts + ')');
                            setTimeout(function() { waitForLibs(callback, attempts + 1); }, 100);
                            return;
                        }
                        if (typeof jQuery.fn.pivotUI === 'undefined') {
                            console.warn('[PivotTable][iframe] pivotUI pas encore chargé, attente... (tentative ' + attempts + ')');
                            setTimeout(function() { waitForLibs(callback, attempts + 1); }, 100);
                            return;
                        }
                        console.log('[PivotTable][iframe] Toutes les libs sont chargées, initialisation du pivot...');
                        callback();
                    }

                    const pivotData = ${JSON.stringify(pivotData)};
                    console.log('[PivotTable][iframe] Données reçues:', pivotData);

                    waitForLibs(function() {
                        try {
                            $("#pivot").pivotUI(pivotData.data, pivotData.options || {});
                            console.log('[PivotTable][iframe] pivotUI initialisé avec succès');
                        } catch(e) {
                            console.error('[PivotTable][iframe] Erreur lors de pivotUI:', e);
                        }
                    });
                <\/script>
            </body>
            </html>`;

                console.log('[PivotTable] Contenu HTML généré, création de l\'iframe via srcdoc...');

                const iframe = $('<iframe/>', {
                    css: {
                        width: '100%',
                        height: height.toString() + 'px',
                        border: 'none'
                    }
                });

                iframe.on('load', function() {
                    console.log('[PivotTable] iframe chargée (événement load déclenché)');
                });

                iframe.prop('srcdoc', htmlContent).appendTo(container);

                console.log('[PivotTable] iframe ajoutée au container via srcdoc');

                return;
            }

            container.html(value);
            container.css({
                height: height.toString() + 'px',
                overflow: 'auto'
            });

            if (value.includes('class="last-news"')) {
                // Insère le HTML
                container.html(value);

                // Sélectionne et applique un style spécifique à la section "last-news"
                container.find('.last-news').css({
                    background: '#f5f5f5',
                    borderRadius: '16px',
                    padding: '16px',
                    boxShadow: '0px 2px 6px rgba(0, 0, 0, 0.05)',
                    fontFamily: 'Inter, sans-serif',
                    fontSize: '12px',
                    color: '#123D3D'
                });

                // Stylise le titre
                container.find('.last-news-title').css({
                    fontSize: '16px',
                    fontWeight: 'bold',
                    marginBottom: '12px'
                });

                container.find('.last-news-content').css({
                    display: 'flex',
                    justifyContent: 'space-between',
                    alignItems: 'center',
                    gap: '12px',
                    flexWrap: 'wrap'
                });

                container.find('.last-news-timestamp').css({
                    fontSize: '12px',
                    color: '#888',
                    whiteSpace: 'nowrap'
                });

                // Stylise chaque item
                container.find('.last-news-item').css({
                    marginBottom: '12px'
                });

                // Stylise les liens
                container.find('.last-news a').css({
                    color: '#006D77',
                    fontWeight: '200',
                    textDecoration: 'none'
                }).hover(function () {
                    $(this).css('textDecoration', 'underline');
                });

                //return; // Empêche le reste du traitement
            }

            // Dynamically bind Tryton link actions
            container.find('a.tryton-link').on('click', (event) => {
                event.preventDefault();

                const target = jQuery(event.currentTarget);
                const model = target.data('model');
                const res_id = parseInt(target.data('id'), 10);
                const view_mode = target.data('view') || 'form';

                if (!model || isNaN(res_id)) return;

                Sao.Tab.create({
                    model: model,
                    res_id: res_id,
                    mode: [view_mode],
                    target: "new",
                });
            });
        }
    };


    Sao.View.FormXMLViewParser.WIDGETS = {
        'binary': Sao.View.Form.Binary,
        'boolean': Sao.View.Form.Boolean,
        'callto': Sao.View.Form.CallTo,
        'char': Sao.View.Form.Char,
        'date': Sao.View.Form.Date,
        'datetime': Sao.View.Form.DateTime,
        'dict': Sao.View.Form.Dict,
        'document': Sao.View.Form.Document,
        'email': Sao.View.Form.Email,
        'float': Sao.View.Form.Float,
        'graph': Sao.View.Form.RichGraph,
        'html': Sao.View.Form.HTML,
        'image': Sao.View.Form.Image,
        'integer': Sao.View.Form.Integer,
        'many2many': Sao.View.Form.Many2Many,
        'many2one': Sao.View.Form.Many2One,
        'multiselection': Sao.View.Form.MultiSelection,
        'numeric': Sao.View.Form.Float,
        'one2many': Sao.View.Form.One2Many,
        'one2one': Sao.View.Form.One2One,
        'password': Sao.View.Form.Password,
        'progressbar': Sao.View.Form.ProgressBar,
        'pyson': Sao.View.Form.PYSON,
        'reference': Sao.View.Form.Reference,
        'rich-graph': Sao.View.Form.RichGraph,
        'rich_graph': Sao.View.Form.RichGraph,
        'richgraph': Sao.View.Form.RichGraph,
        'richtext': Sao.View.Form.RichText,
        'selection': Sao.View.Form.Selection,
        'sip': Sao.View.Form.SIP,
        'text': Sao.View.Form.Text,
        'time': Sao.View.Form.Time,
        'timedelta': Sao.View.Form.TimeDelta,
        'timestamp': Sao.View.Form.DateTime,
        'tolerance_gauge': Sao.View.Form.ToleranceGauge,
        'url': Sao.View.Form.URL,
    };
    Sao.View.FormXMLViewParser.WIDGETS['html_viewer'] = Sao.View.Form.HTMLViewer;
    Sao.View.FormXMLViewParser.WIDGETS['purchase_summary'] =
        Sao.View.Form.PurchaseSummary;

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    if ('IntersectionObserver' in window) {
        var moreObserver = new IntersectionObserver(function(entries, observer) {
            for (const entry of entries) {
                if (entry.isIntersecting) {
                    jQuery(entry.target).trigger('click');
                }
            }
        }, {
            rootMargin: '0px 0px 50px 0px',
        });
    }

    Sao.View.TreeXMLViewParser = Sao.class_(Sao.View.XMLViewParser, {
        _parse_tree: function(node, attributes) {
            for (const child of node.childNodes) {
                this.parse(child);
            }
        },
        _parse_field: function(node, attributes) {
            var name = attributes.name;
            var field = this.view.screen.model.fields[name];
            var widget = attributes.widget || (field && field.description.type);
            var column_widget = widget == 'variation' ?
                (field && field.description.type || 'char') : widget;
            attributes.widget = widget;
            var ColumnFactory = Sao.View.TreeXMLViewParser.WIDGETS[column_widget];
            if (!ColumnFactory) {
                console.error('[TREE][UNKNOWN_WIDGET]', {
                    viewId: this.view.view_id,
                    model: this.view.screen && this.view.screen.model_name,
                    field: name,
                    widget: column_widget,
                    attributes: attributes,
                    node: node.outerHTML,
                    availableWidgets: Object.keys(
                        Sao.View.TreeXMLViewParser.WIDGETS).sort(),
                });
                attributes.widget = 'char';
                ColumnFactory = Sao.View.Tree.CharColumn;
            }
            var column = new ColumnFactory(this.view.screen.model, attributes);
            if (!this.view.widgets[name]) {
                this.view.widgets[name] = [];
            }
            column.tree = this.view;
            this.view.widgets[name].push(column);

            if ('symbol' in attributes) {
                column.suffixes.push(
                    new Sao.View.Tree.Symbol(attributes, 1));
            }
            if (~['url', 'email', 'callto', 'sip'
                    ].indexOf(attributes.widget)) {
                column.prefixes.push(
                    new Sao.View.Tree.Affix(attributes, attributes.widget));
            }
            if ('icon' in attributes) {
                column.prefixes.push(new Sao.View.Tree.Affix(attributes));
            }
            for (const affix of node.childNodes) {
                let affix_attributes = {};
                for (const attribute of affix.attributes) {
                    affix_attributes[attribute.name] = attribute.value;
                }
                if (!affix_attributes.name) {
                    affix_attributes.name = name;
                }
                let list;
                if (affix.tagName == 'prefix') {
                    list = column.prefixes;
                } else {
                    list = column.suffixes;
                }
                list.push(new Sao.View.Tree.Affix(affix_attributes));
            }
            if ('symbol' in attributes) {
                column.prefixes.push(
                    new Sao.View.Tree.Symbol(attributes, 0));
            }
            var field_attrs = this.field_attrs[name] ||
                (field && field.description) || {};
            if (!this.field_attrs[name]) {
                console.warn('[TREE][MISSING_FIELD_ATTRS]', {
                    viewId: this.view.view_id,
                    model: this.view.screen && this.view.screen.model_name,
                    field: name,
                    widget: attributes.widget,
                    attributes: attributes,
                    fallbackFieldAttributes: field_attrs,
                });
            }
            var server_sortable = field_attrs.sortable !== false;
            column.server_sortable = server_sortable;
            var ag_grid_sortable = Sao.View.AGGridTree &&
                this.view instanceof Sao.View.AGGridTree;
            if (!this.view.attributes.sequence &&
                    !this.view.children_field &&
                    (server_sortable || ag_grid_sortable)){
                column.sortable = true;
            }
            this.view.columns.push(column);

            if (attributes.optional) {
                this.view.optionals.push(column);
            }

            if (parseInt(attributes.sum || '0', 10)) {
                var sum = jQuery('<label/>', {
                    'text': attributes.string,
                });
                var aggregate = jQuery('<div/>', {
                    'class': 'value',
                });
                this.view.sum_widgets.set(column, [sum, aggregate]);
                if (window.console && this.view instanceof Sao.View.AGGridTree) {
                    console.info('[AGGRID][SUMMARY_SUM_COLUMN_JSON]',
                        JSON.stringify({
                            viewId: this.view.view_id,
                            model: this.view.screen &&
                                this.view.screen.model_name,
                            name: attributes.name || null,
                            label: attributes.string || attributes.name || '',
                            widget: attributes.widget || '',
                            sum: attributes.sum || '',
                        }));
                }
            }
        },
        _parse_button: function(node, attributes) {
            var column = new Sao.View.Tree.ButtonColumn(
                this.view, attributes);
            this.view.columns.push(column);
        }
    });

    Sao.View.AGGridValueSetFilter = function() {};
    Sao.View.AGGridValueSetFilter.prototype.init = function(params) {
        this.params = params;
        var filter_params = params.filterParams ||
            (params.colDef && params.colDef.filterParams) || {};
        Sao.View.AGGridValueSetFilter._next_debug_id =
            (Sao.View.AGGridValueSetFilter._next_debug_id || 0) + 1;
        this._debug_id = Sao.View.AGGridValueSetFilter._next_debug_id;
        this.ownerDebugId = filter_params.ownerDebugId || null;
        this.trace = filter_params.trace || null;
        this.emptyLabel = filter_params.emptyLabel || '(Empty)';
        this.colId = filter_params.colId || null;
        this.getLiveValues = filter_params.getLiveValues || null;
        this.getNodeValue = filter_params.getNodeValue || null;
        this.onSelectionChanged = filter_params.onSelectionChanged || null;
        this.configuredValues = (filter_params.values || [])
            .map(value => String(value));
        var grid_values = this._collectConfiguredLiveValues();
        if (!grid_values.length || this._hasOnlyEmptyValue(grid_values)) {
            grid_values = this._collectValuesFromGrid(params);
        }
        var values = grid_values.length && !this._hasOnlyEmptyValue(grid_values) ?
            grid_values : this.configuredValues;
        if (this.configuredValues.length && grid_values.length) {
            values = Array.from(new Set(values.concat(this.configuredValues)));
        }
        this.values = this._normalizeValues(values);
        this.selected = new Set(this.values);
        this._applySavedSelection();
        this._trace('init', {
            selected: Array.from(this.selected).sort(),
            valuesCount: this.values.length,
            saved: this._getSavedSelection() ?
                Array.from(this._getSavedSelection()).sort() : null,
        });
        this.gui = document.createElement('div');
        this.gui.className = 'ag-grid-value-set-filter';

        this.search = document.createElement('input');
        this.search.type = 'search';
        this.search.className = 'form-control input-sm ag-grid-value-set-search';
        this.search.placeholder = filter_params.searchPlaceholder || 'Search';
        this.gui.appendChild(this.search);

        var actions = document.createElement('div');
        actions.className = 'ag-grid-value-set-actions';
        this.allButton = document.createElement('button');
        this.allButton.type = 'button';
        this.allButton.className = 'btn btn-default btn-xs';
        this.allButton.textContent = 'All';
        this.noneButton = document.createElement('button');
        this.noneButton.type = 'button';
        this.noneButton.className = 'btn btn-default btn-xs';
        this.noneButton.textContent = 'None';
        this.clearButton = document.createElement('button');
        this.clearButton.type = 'button';
        this.clearButton.className = 'btn btn-default btn-xs';
        this.clearButton.textContent = 'Clear';
        this.applySearchButton = document.createElement('button');
        this.applySearchButton.type = 'button';
        this.applySearchButton.className = 'btn btn-primary btn-xs';
        this.applySearchButton.textContent = 'OK';
        actions.appendChild(this.allButton);
        actions.appendChild(this.noneButton);
        actions.appendChild(this.clearButton);
        actions.appendChild(this.applySearchButton);
        this.gui.appendChild(actions);

        this.list = document.createElement('div');
        this.list.className = 'ag-grid-value-set-list';
        this.gui.appendChild(this.list);

        this._renderOptions();

        this.search.addEventListener('input', () => this._applySearch());
        this.search.addEventListener('keydown', event => {
            if (event.key != 'Enter') {
                return;
            }
            event.preventDefault();
            this._selectSearchMatches('search-enter');
            params.filterChangedCallback();
            this._closePopup();
        });
        this.noneButton.addEventListener('pointerdown', event => {
            if (event.isTrusted) {
                this._noneButtonUserIntent = true;
            }
            this._trace('none-pointerdown', {
                isTrusted: Boolean(event.isTrusted),
            });
        });
        this.noneButton.addEventListener('keydown', event => {
            if (event.isTrusted &&
                    (event.key == 'Enter' || event.key == ' ' ||
                    event.key == 'Spacebar')) {
                this._noneButtonUserIntent = true;
            }
            this._trace('none-keydown', {
                isTrusted: Boolean(event.isTrusted),
                key: event.key || null,
            });
        });
        this.list.addEventListener('change', event => {
            if (!event.target || event.target.type != 'checkbox') {
                return;
            }
            if (event.target.checked) {
                this.selected.add(event.target.value);
            } else {
                this.selected.delete(event.target.value);
            }
            this._trace('checkbox-change', {
                isTrusted: Boolean(event.isTrusted),
                value: event.target.value,
                checked: Boolean(event.target.checked),
                selected: Array.from(this.selected).sort(),
            });
            this._restoreSavedSelectionIfEmpty('checkbox-change');
            this._notifySelectionChanged('checkbox-change');
            params.filterChangedCallback();
        });
        this.allButton.addEventListener('click', event => {
            this.selected = new Set(this.values);
            this._syncCheckboxes();
            this._trace('all-click', {
                isTrusted: Boolean(event.isTrusted),
                selected: Array.from(this.selected).sort(),
            });
            this._notifySelectionChanged('all-button');
            params.filterChangedCallback();
        });
        this.noneButton.addEventListener('click', event => {
            var source = this._consumeNoneButtonUserIntent() ?
                'none-button' : 'none-button-lifecycle';
            this.selected = new Set();
            this._trace('none-click', {
                isTrusted: Boolean(event.isTrusted),
                source: source,
                guiConnected: Boolean(this.gui && this.gui.isConnected),
                stack: (new Error()).stack,
            });
            if (source != 'none-button' &&
                    this._restoreSavedSelectionIfEmpty(source)) {
                this._notifySelectionChanged(source);
                params.filterChangedCallback();
                return;
            }
            this._syncCheckboxes();
            this._notifySelectionChanged(source);
            params.filterChangedCallback();
        });
        this.clearButton.addEventListener('click', event => {
            this.selected = new Set(this.values);
            this.search.value = '';
            this._applySearch();
            this._syncCheckboxes();
            this._trace('clear-click', {
                isTrusted: Boolean(event.isTrusted),
                selected: Array.from(this.selected).sort(),
            });
            this._notifySelectionChanged('clear-button');
            params.filterChangedCallback();
        });
        this.applySearchButton.addEventListener('click', () => {
            this._selectSearchMatches('search-ok-button');
            params.filterChangedCallback();
            this._closePopup();
        });
    };
    Sao.View.AGGridValueSetFilter.prototype.getGui = function() {
        return this.gui;
    };
    Sao.View.AGGridValueSetFilter.prototype.afterGuiAttached = function(params) {
        this.hidePopup = params && params.hidePopup || null;
        this._refreshValuesFromGrid();
        this._trace('after-gui-attached', {
            selected: Array.from(this.selected).sort(),
            valuesCount: this.values.length,
            guiConnected: Boolean(this.gui && this.gui.isConnected),
        });
        if (this.search) {
            this.search.focus();
            this.search.select();
        }
    };
    Sao.View.AGGridValueSetFilter.prototype.isFilterActive = function() {
        return this.selected.size != this.values.length;
    };
    Sao.View.AGGridValueSetFilter.prototype.doesFilterPass = function(params) {
        return true;
    };
    Sao.View.AGGridValueSetFilter.prototype.getModel = function() {
        if (!this.isFilterActive()) {
            return null;
        }
        return {
            filterType: 'saoValueSet',
            values: Array.from(this.selected),
        };
    };
    Sao.View.AGGridValueSetFilter.prototype.setModel = function(model) {
        this._trace('set-model-before', {
            model: model,
            selected: Array.from(this.selected).sort(),
        });
        if (!model || !Array.isArray(model.values)) {
            this.selected = this._getSavedSelection() || new Set(this.values);
        } else {
            this.selected = new Set(model.values.map(value => String(value)));
        }
        this._restoreSavedSelectionIfEmpty('set-model');
        this._syncCheckboxes();
        this._trace('set-model-after', {
            selected: Array.from(this.selected).sort(),
        });
    };
    Sao.View.AGGridValueSetFilter.prototype._syncCheckboxes = function() {
        Array.from(this.list.querySelectorAll('input[type="checkbox"]'))
            .forEach(input => {
                input.checked = this.selected.has(input.value);
            });
    };
    Sao.View.AGGridValueSetFilter.prototype._applySearch = function() {
        var query = (this.search.value || '').toLowerCase();
        Array.from(this.list.querySelectorAll('.ag-grid-value-set-option'))
            .forEach(option => {
                var text = option.textContent || '';
                option.style.display = text.toLowerCase().indexOf(query) >= 0 ?
                    '' : 'none';
            });
    };
    Sao.View.AGGridValueSetFilter.prototype._getSearchMatchingValues = function() {
        var query = (this.search.value || '').toLowerCase();
        if (!query) {
            return this.values.slice();
        }
        return this.values.filter(value =>
            String(value).toLowerCase().indexOf(query) >= 0);
    };
    Sao.View.AGGridValueSetFilter.prototype._selectSearchMatches = function(source) {
        this.selected = new Set(this._getSearchMatchingValues());
        this._syncCheckboxes();
        this._trace(source || 'search-apply', {
            query: this.search && this.search.value || '',
            selected: Array.from(this.selected).sort(),
        });
        this._notifySelectionChanged(source || 'search-apply');
    };
    Sao.View.AGGridValueSetFilter.prototype._closePopup = function() {
        if (this.hidePopup) {
            this.hidePopup();
        }
    };
    Sao.View.AGGridValueSetFilter.prototype.destroy = function() {};
    Sao.View.AGGridValueSetFilter.prototype._notifySelectionChanged = function(source) {
        if (!this.onSelectionChanged || !this.colId) {
            return;
        }
        this.onSelectionChanged(
            this.colId,
            Array.from(this.selected),
            this.values.slice(),
            source || 'lifecycle',
            {
                filterDebugId: this._debug_id,
                ownerDebugId: this.ownerDebugId,
                guiConnected: Boolean(this.gui && this.gui.isConnected),
            });
    };
    Sao.View.AGGridValueSetFilter.prototype._hasOnlyEmptyValue = function(values) {
        return values.length == 1 && values[0] == this.emptyLabel;
    };
    Sao.View.AGGridValueSetFilter.prototype._normalizeValues = function(values) {
        return Array.from(new Set((values || []).map(value => String(value))))
            .sort(function(a, b) {
                return String(a).localeCompare(String(b), undefined, {
                    numeric: true,
                    sensitivity: 'base',
                });
            });
    };
    Sao.View.AGGridValueSetFilter.prototype._renderOptions = function() {
        this.list.innerHTML = '';
        this.values.forEach(value => {
            var label = document.createElement('label');
            label.className = 'ag-grid-value-set-option';
            var checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.checked = this.selected.has(value);
            checkbox.value = value;
            var text = document.createElement('span');
            text.textContent = value;
            label.appendChild(checkbox);
            label.appendChild(text);
            this.list.appendChild(label);
        });
        this._applySearch();
    };
    Sao.View.AGGridValueSetFilter.prototype._refreshValuesFromGrid = function() {
        var grid_values = this._collectConfiguredLiveValues();
        if (!grid_values.length || this._hasOnlyEmptyValue(grid_values)) {
            grid_values = this._collectValuesFromGrid(this.params);
        }
        if (!grid_values.length) {
            return;
        }
        var values = !this._hasOnlyEmptyValue(grid_values) ?
            grid_values : this.configuredValues;
        if (this.configuredValues.length && grid_values.length) {
            values = values.concat(this.configuredValues);
        }
        values = this._normalizeValues(values);
        if (values.join('\u0000') == this.values.join('\u0000')) {
            return;
        }
        var was_all_selected = !this.isFilterActive();
        var previous_selected = this.selected;
        this.values = values;
        this.selected = was_all_selected ? new Set(values) :
            new Set(values.filter(value => previous_selected.has(value)));
        this._applySavedSelection();
        this._renderOptions();
    };
    Sao.View.AGGridValueSetFilter.prototype._getSavedSelection = function() {
        var saved = null;
        var filter_params = this.params.filterParams ||
            (this.params.colDef && this.params.colDef.filterParams) || {};
        if (filter_params.getSavedValueFilter) {
            saved = filter_params.getSavedValueFilter(this.colId);
        } else {
            saved = filter_params.savedValueFilter || null;
        }
        if (!saved || !Array.isArray(saved.selected)) {
            return null;
        }
        var available = new Set(this.values);
        var selected = new Set(saved.selected.map(value => String(value))
            .filter(value => available.has(value)));
        return selected;
    };
    Sao.View.AGGridValueSetFilter.prototype._applySavedSelection = function() {
        var saved_selection = this._getSavedSelection();
        if (saved_selection) {
            this.selected = saved_selection;
        }
    };
    Sao.View.AGGridValueSetFilter.prototype._consumeNoneButtonUserIntent = function() {
        var has_intent = Boolean(this._noneButtonUserIntent);
        this._noneButtonUserIntent = false;
        return has_intent;
    };
    Sao.View.AGGridValueSetFilter.prototype._restoreSavedSelectionIfEmpty = function(source) {
        if (source == 'none-button' || this.selected.size) {
            return false;
        }
        var saved_selection = this._getSavedSelection();
        if (!saved_selection || !saved_selection.size) {
            return false;
        }
        this.selected = saved_selection;
        this._syncCheckboxes();
        this._trace('restore-saved-selection-if-empty', {
            source: source,
            selected: Array.from(this.selected).sort(),
        });
        return true;
    };
    Sao.View.AGGridValueSetFilter.prototype._trace = function(reason, data) {
        if (this.trace) {
            this.trace(reason, jQuery.extend({
                filterDebugId: this._debug_id,
                ownerDebugId: this.ownerDebugId,
                colId: this.colId,
            }, data || {}));
        }
    };
    Sao.View.AGGridValueSetFilter.prototype._getField = function(params) {
        if (params && params.colDef && params.colDef.field) {
            return params.colDef.field;
        }
        if (params && params.column && params.column.getColDef) {
            var col_def = params.column.getColDef();
            if (col_def && col_def.field) {
                return col_def.field;
            }
        }
        if (params && params.column && params.column.getColId) {
            return params.column.getColId();
        }
        return null;
    };
    Sao.View.AGGridValueSetFilter.prototype._getNodeValue = function(params, node) {
        if (!node || !node.data) {
            return null;
        }
        var field = this._getField(params);
        if (field && Object.prototype.hasOwnProperty.call(node.data, field)) {
            return node.data[field];
        }
        if (params && params.getValue) {
            return params.getValue(node);
        }
        return null;
    };
    Sao.View.AGGridValueSetFilter.prototype._collectConfiguredLiveValues = function() {
        if (!this.getLiveValues) {
            return [];
        }
        try {
            return this._normalizeValues(this.getLiveValues() || []);
        } catch (error) {
            return [];
        }
    };
    Sao.View.AGGridValueSetFilter.prototype._getFilterNodeValue = function(node) {
        if (this.getNodeValue) {
            try {
                return this.getNodeValue(node);
            } catch (error) {
                return null;
            }
        }
        return this._getNodeValue(this.params, node);
    };
    Sao.View.AGGridValueSetFilter.prototype._collectValuesFromGrid = function(params) {
        var values = new Map();
        if (!params || !params.api || !params.api.forEachNode) {
            return [];
        }
        params.api.forEachNode(node => {
            if (!node || !node.data) {
                return;
            }
            var value = this._getFilterNodeValue(node);
            values.set(value ? String(value) : this.emptyLabel, true);
        });
        return Array.from(values.keys()).sort(function(a, b) {
            return String(a).localeCompare(String(b), undefined, {
                numeric: true,
                sensitivity: 'base',
            });
        });
    };

    Sao.View.AGGridTree = Sao.class_(Sao.View, {
        view_type: 'tree',
        xml_parser: Sao.View.TreeXMLViewParser,
        display_size: Sao.config.display_size,
        init: function(view_id, screen, xml, children_field) {
            Sao.View.AGGridTree._next_debug_id =
                (Sao.View.AGGridTree._next_debug_id || 0) + 1;
            this._debug_id = Sao.View.AGGridTree._next_debug_id;
            this.children_field = children_field;
            this.is_embedded_one2many = Boolean(
                screen.attributes && screen.attributes.one2many);
            this.optionals = [];
            this.sum_widgets = new Map();
            this.columns = [];
            this.selection_mode = (screen.attributes.selection_mode ||
                Sao.common.SELECTION_MULTIPLE);
            this._selected_ids = new Set();
            this._syncing_grid = false;
            this._grid_columns = [];
            this._sao_value_filters = {};
            this._sao_value_filter_mode = 'all';
            this._sao_value_filter_groups = null;
            this._state_save_timer = null;
            this._state_restore_done = false;
            this.gridApi = null;
            this.gridColumnApi = null;
            this._persisted_state = null;
            this._toolbar_icons = {
                columns: jQuery(
                    '<span class="ag-grid-toolbar-icon" aria-hidden="true">' +
                        '<svg viewBox="0 0 20 20" focusable="false">' +
                            '<rect x="2.5" y="3.5" width="4.5" height="13" rx="1.2"></rect>' +
                            '<rect x="7.75" y="3.5" width="4.5" height="13" rx="1.2"></rect>' +
                            '<rect x="13" y="3.5" width="4.5" height="13" rx="1.2"></rect>' +
                        '</svg>' +
                    '</span>'),
                grouping: jQuery(
                    '<span class="ag-grid-toolbar-icon" aria-hidden="true">' +
                        '<svg viewBox="0 0 20 20" focusable="false">' +
                            '<path d="M3 5.5h5"></path>' +
                            '<path d="M3 10h5"></path>' +
                            '<path d="M3 14.5h5"></path>' +
                            '<path d="M9.5 5.5h2.5"></path>' +
                            '<path d="M9.5 10h2.5"></path>' +
                            '<path d="M9.5 14.5h2.5"></path>' +
                            '<path d="M12 5.5h4.5v9H12"></path>' +
                        '</svg>' +
                    '</span>'),
                layouts: jQuery(
                    '<span class="ag-grid-toolbar-icon" aria-hidden="true">' +
                        '<svg viewBox="0 0 20 20" focusable="false">' +
                            '<path d="M4 4.5h12"></path>' +
                            '<path d="M4 8.5h12"></path>' +
                            '<path d="M4 12.5h8"></path>' +
                            '<path d="M4 16.5h6"></path>' +
                        '</svg>' +
                    '</span>'),
                filters: jQuery(
                    '<span class="ag-grid-toolbar-icon" aria-hidden="true">' +
                        '<svg viewBox="0 0 20 20" focusable="false">' +
                            '<path d="M3.5 5.5h13"></path>' +
                            '<path d="M6.5 10h7"></path>' +
                            '<path d="M9 14.5h2"></path>' +
                        '</svg>' +
                    '</span>')
            };

            this.el = jQuery('<div/>', {
                'class': 'tree-container ag-grid-tree-container',
            });
            this.toolbar = jQuery('<div/>', {
                'class': 'ag-grid-tree-toolbar',
            }).appendTo(this.el);
            if (this.is_embedded_one2many) {
                this.el.addClass('ag-grid-tree-embedded-one2many');
            }
            this.column_menu = jQuery('<div/>', {
                'class': 'ag-grid-tree-columns',
            }).appendTo(this.toolbar);
            this.column_menu_button = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext('Columns'),
            }).append(this._toolbar_icons.columns.clone())
                .append(jQuery('<span/>', {
                    'class': 'ag-grid-tree-columns-label',
                    'text': ' ' + Sao.i18n.gettext('Columns'),
                }))
                .click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._toggle_column_menu();
                })
                .appendTo(this.column_menu);
            this.column_menu_dropdown = jQuery('<ul/>', {
                'class': 'dropdown-menu ag-grid-tree-columns-panel',
            }).hide().appendTo(jQuery(document.body));
            this.grouping_menu = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping',
            }).appendTo(this.toolbar);
            this.grouping_button = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext('Grouping'),
            }).append(this._toolbar_icons.grouping.clone())
                .append(jQuery('<span/>', {
                    'class': 'ag-grid-tree-grouping-label',
                    'text': ' ' + Sao.i18n.gettext('Grouping'),
                }))
                .click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._toggle_grouping_menu();
                })
                .appendTo(this.grouping_menu);
            this.grouping_panel = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-panel',
            }).hide().appendTo(jQuery(document.body));
            this.layout_menu = jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts',
            }).appendTo(this.toolbar);
            this.layout_button = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext('Views'),
            }).append(this._toolbar_icons.layouts.clone())
                .append(jQuery('<span/>', {
                    'class': 'ag-grid-tree-layouts-label',
                    'text': ' ' + Sao.i18n.gettext('Views'),
                }))
                .click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._toggle_layout_menu();
                })
                .appendTo(this.layout_menu);
            this.layout_panel = jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-panel',
            }).hide().appendTo(jQuery(document.body));
            this.value_filter_mode_button = jQuery('<button/>', {
                'class': 'btn btn-default btn-sm ag-grid-value-filter-mode',
                'type': 'button',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext('Combine checklist filters'),
            }).append(this._toolbar_icons.filters.clone())
                .append(jQuery('<span/>', {
                    'class': 'ag-grid-value-filter-mode-label',
                    'text': ' ' + Sao.i18n.gettext('All filters'),
                }))
                .click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._toggle_filter_logic_menu();
                })
                .appendTo(this.toolbar);
            this.active_filter_chips = jQuery('<div/>', {
                'class': 'ag-grid-active-filter-chips',
                'aria-live': 'polite',
            }).hide().appendTo(this.toolbar);
            this.active_filter_chips.on('dragover', evt => {
                if (!this._filter_drag_payload) {
                    return;
                }
                evt.preventDefault();
                evt.originalEvent.dataTransfer.dropEffect = 'copy';
                this.active_filter_chips.addClass('drag-over');
            });
            this.active_filter_chips.on('dragleave', evt => {
                if (evt.currentTarget.contains(evt.relatedTarget)) {
                    return;
                }
                this.active_filter_chips.removeClass('drag-over');
            });
            this.active_filter_chips.on('drop', evt => {
                evt.preventDefault();
                this.active_filter_chips.removeClass('drag-over');
                this._apply_filter_drop(evt.originalEvent);
            });
            this.import_layout_input = jQuery('<input/>', {
                'type': 'file',
                'accept': 'application/json,.json',
                'class': 'ag-grid-view-import-input',
            }).hide().appendTo(this.el);
            this.import_layout_input.on('change', evt => {
                this._import_layout_from_input(evt.currentTarget);
            });
            this.filter_logic_panel = jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-panel',
            }).hide().appendTo(jQuery(document.body));
            // Kept for potential future reactivation of the copy feature.
            // this.copy_button = jQuery('<button/>', {
            //     'class': 'btn btn-default btn-sm',
            //     'type': 'button',
            //     'text': Sao.i18n.gettext('Copy Selected Rows'),
            // }).appendTo(this.toolbar)
            //     .click(this.on_copy.bind(this));
            this.copy_button = null;
            this.status = jQuery('<div/>', {
                'class': 'ag-grid-tree-status text-muted',
            }).appendTo(this.toolbar);
            this.summary_bar = jQuery('<div/>', {
                'class': 'ag-grid-tree-summary',
            }).appendTo(this.el);
            this.grid_host = jQuery('<div/>', {
                'class': 'ag-grid-theme-alpine ag-grid-tree-host',
            }).appendTo(this.el);
            this.horizontal_scroll = jQuery('<div/>', {
                'class': 'ag-grid-tree-external-horizontal-scroll',
            }).appendTo(this.el);
            this.horizontal_scroll_inner = jQuery('<div/>', {
                'class': 'ag-grid-tree-external-horizontal-scroll-inner',
            }).appendTo(this.horizontal_scroll);
            this._syncing_external_horizontal_scroll = false;
            this._trace_aggrid_lifecycle('init-start');
            this.horizontal_scroll.on('scroll', () => {
                this._sync_external_horizontal_scroll();
            });
            this.el.on('remove', () => {
                this._trace_aggrid_lifecycle('remove');
                this._destroyed = true;
                if (this._layout_refresh_timer) {
                    window.clearTimeout(this._layout_refresh_timer);
                    this._layout_refresh_timer = null;
                }
                if (this._layout_refresh_timers) {
                    this._layout_refresh_timers.forEach(timer =>
                        window.clearTimeout(timer));
                    this._layout_refresh_timers = [];
                }
                if (this._resize_observer) {
                    this._resize_observer.disconnect();
                    this._resize_observer = null;
                }
                if (this._offcanvas_observer) {
                    this._offcanvas_observer.disconnect();
                    this._offcanvas_observer = null;
                }
                if (this._theme_observer) {
                    this._theme_observer.disconnect();
                    this._theme_observer = null;
                }
                if (this._window_resize_handler) {
                    window.removeEventListener(
                        'resize', this._window_resize_handler);
                    this._window_resize_handler = null;
                }
                this._hide_column_menu();
                this._hide_grouping_menu();
                if (this.column_menu_dropdown) {
                    this.column_menu_dropdown.remove();
                }
                if (this.grouping_panel) {
                    this.grouping_panel.remove();
                }
                this._hide_layout_menu();
                if (this.layout_panel) {
                    this.layout_panel.remove();
                }
                this._hide_filter_logic_menu();
                if (this.filter_logic_panel) {
                    this.filter_logic_panel.remove();
                }
            });

            Sao.View.AGGridTree._super.init.call(this, view_id, screen, xml);
            this._persisted_state = this._load_persisted_state();
            if (this._persisted_state &&
                    this._persisted_state.valueFilters) {
                this._apply_sao_value_filter_state(
                    this._persisted_state.valueFilters);
            }
            this._apply_saved_sort_to_screen_order();
            this._update_layout_button();
            this._update_sao_value_filter_mode_button();
            this._render_active_filter_chips();
            this._update_copy_state();
            this._init_grid();
            this._install_resize_observer();
            this._install_theme_observer();
            this._trace_aggrid_lifecycle('init-end');
        },
        reset: function() {
            this.display_size = Sao.config.display_size;
        },
        get editable() {
            return false;
        },
        get creatable() {
            return false;
        },
        get_fields: function() {
            return Object.keys(this.widgets);
        },
        get_buttons: function() {
            return this.columns.filter(column =>
                column instanceof Sao.View.Tree.ButtonColumn);
        },
        get selected_records() {
            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                return [];
            }
            return this.group.filter(record => this._selected_ids.has(record.id));
        },
        get listed_records() {
            return this.group.slice();
        },
        get_listed_paths: function() {
            return this.group.map(record => [record.id]);
        },
        get_selected_paths: function() {
            return this.selected_records.map(record => [record.id]);
        },
        get_expanded_paths: function() {
            return [];
        },
        set_cursor: function() {
            if (!this.gridApi || !this.record) {
                return;
            }
            var node = this.gridApi.getRowNode('record:' + String(this.record.id));
            if (!node) {
                return;
            }
            if (this.selection_mode != Sao.common.SELECTION_NONE) {
                this._syncing_grid = true;
                node.setSelected(true, true);
                this._syncing_grid = false;
            }
            if (node.rowIndex !== null && node.rowIndex !== undefined) {
                this.gridApi.ensureIndexVisible(node.rowIndex, 'middle');
                var focusable = this._grid_columns.find(column => !column.hide);
                if (focusable) {
                    this.gridApi.setFocusedCell(node.rowIndex, focusable.colId);
                }
            }
        },
        _get_storage_key: function() {
            var session = Sao.Session.current_session || {};
            var database = session.database || 'default';
            var user_id = session.user_id || 'anonymous';
            return [
                'sao_ag_grid_state',
                database,
                user_id,
                this.screen.model_name,
                this.view_id,
            ].join(':');
        },
        _trace_aggrid_lifecycle: function(reason, extra) {
        },
        _trace_aggrid_value_filter_component: function(reason, data) {
        },
        _describe_sao_value_filters: function() {
            return Object.keys(this._sao_value_filters || {}).map(col_id => {
                var filter = this._sao_value_filters[col_id] || {};
                return {
                    colId: col_id,
                    selected: Array.from(filter.selected || []).sort(),
                    allCount: filter.all ? filter.all.size : 0,
                    active: this._is_sao_value_filter_active(filter),
                };
            });
        },
        _get_layout_store_cache_key: function() {
            return this._get_layout_storage_key();
        },
        _get_layout_storage_key: function() {
            var key = this._get_storage_key();
            if (!this.is_embedded_one2many) {
                return key;
            }
            var attributes = this.screen && this.screen.attributes || {};
            var context_parts = [
                'embedded_one2many',
                attributes.name,
                attributes.field,
                attributes.relation_field,
                attributes.one2many,
                attributes.parent_name,
                attributes.child_name,
                this.screen && this.screen.exclude_field,
                this.screen && this.screen.group &&
                    this.screen.group.parent_name,
                this.screen && this.screen.group &&
                    this.screen.group.child_name,
            ].map(function(value) {
                return String(value || '').replace(/[:\s]+/g, '_');
            }).filter(function(value) {
                return Boolean(value);
            });
            return [key].concat(context_parts).join(':');
        },
        _new_layout_id: function() {
            return 'layout_' + String(Date.now()) + '_' +
                String(Math.floor(Math.random() * 100000));
        },
        _find_layout_preset: function(id) {
            var store = this._layout_store;
            if (!store || !id) {
                return null;
            }
            return (store.presets || []).find(preset => preset.id == id) || null;
        },
        _normalize_layout_store: function(store) {
            store = store || {};
            if (store.version !== 2 || !Array.isArray(store.presets)) {
                store = {
                    version: 2,
                    defaultPresetId: null,
                    activePresetId: null,
                    currentState: store && (store.columns || store.grouping) ?
                        store : null,
                    presets: [],
                };
            }
            store.presets = (store.presets || []).filter(preset =>
                preset && preset.id && preset.name && preset.state);
            if (!this._find_layout_preset_in(store, store.defaultPresetId)) {
                store.defaultPresetId = null;
            }
            if (!this._find_layout_preset_in(store, store.activePresetId)) {
                store.activePresetId = store.defaultPresetId || null;
            }
            return store;
        },
        _find_layout_preset_in: function(store, id) {
            if (!store || !id) {
                return null;
            }
            return (store.presets || []).find(preset => preset.id == id) || null;
        },
        _load_layout_store: function() {
            var cache_key = this._get_layout_store_cache_key();
            var cached_store = Sao.Screen.ag_grid_states[cache_key];
            if (cached_store) {
                return this._normalize_layout_store(
                    jQuery.extend(true, {}, cached_store));
            }
            if (!window.localStorage) {
                return this._normalize_layout_store(null);
            }
            try {
                var raw_state = localStorage.getItem(
                    this._get_layout_storage_key());
                if (!raw_state) {
                    return this._normalize_layout_store(null);
                }
                return this._normalize_layout_store(JSON.parse(raw_state));
            } catch (error) {
                Sao.Logger.warn(
                    'Unable to load AG Grid state for %s',
                    this.screen.model_name);
            }
            return this._normalize_layout_store(null);
        },
        _load_persisted_state: function() {
            this._layout_store = this._load_layout_store();
            var active_preset = this._find_layout_preset(
                this._layout_store.defaultPresetId ||
                this._layout_store.activePresetId);
            var state = active_preset ? active_preset.state :
                this._layout_store.currentState;
            this._active_layout_id = active_preset ? active_preset.id : null;
            this._layout_dirty = false;
            this._trace_sao_value_filter_state('load_persisted_state',
                state || {});
            return state ? jQuery.extend(true, {}, state) : null;
        },
        _save_persisted_state: function(state) {
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            this._layout_store.currentState = jQuery.extend(true, {}, state);
            this._layout_store.activePresetId = this._active_layout_id || null;
            var active_preset = this._find_layout_preset(this._active_layout_id);
            this._layout_dirty = Boolean(active_preset &&
                !Sao.common.compare(active_preset.state, state));
            this._save_layout_store();
        },
        _save_layout_store: function() {
            var store = this._normalize_layout_store(this._layout_store || {});
            this._layout_store = store;
            Sao.Screen.ag_grid_states[this._get_layout_store_cache_key()] =
                jQuery.extend(true, {}, store);
            if (!window.localStorage) {
                return;
            }
            try {
                localStorage.setItem(
                    this._get_layout_storage_key(),
                    JSON.stringify(store));
            } catch (error) {
                Sao.Logger.warn(
                    'Unable to save AG Grid state for %s',
                    this.screen.model_name);
            }
        },
        _schedule_state_save: function(options) {
            if (this._syncing_grid) {
                return;
            }
            this._pending_state_save_options = jQuery.extend(
                {}, this._pending_state_save_options || {}, options || {});
            if (this._state_save_timer) {
                window.clearTimeout(this._state_save_timer);
            }
            this._state_save_timer = window.setTimeout(() => {
                var save_options = this._pending_state_save_options || {};
                this._pending_state_save_options = null;
                this._state_save_timer = null;
                this._mark_grid_state_dirty(save_options);
            }, 80);
        },
        _mark_grid_state_dirty: function(options) {
            options = options || {};
            var state = this._capture_grid_state();
            var active_preset = this._find_layout_preset(this._active_layout_id);
            this._layout_dirty = active_preset ?
                !Sao.common.compare(active_preset.state, state) :
                !this._is_standard_layout_state(state);
            this._render_column_menu();
            this._render_grouping_panel();
            this._render_layout_panel();
            this._update_layout_button();
            if (!options.skipLayoutRefresh) {
                this._schedule_grid_layout_refresh();
            }
        },
        _on_grid_column_layout_changed: function(options) {
            options = options || {};
            this._schedule_state_save(options);
            if (!options.skipLayoutRefresh) {
                this._schedule_grid_layout_refresh();
            }
            this._update_summary_bar();
        },
        _on_grid_column_resized: function(params) {
            this._trace_aggrid_column_resize('event', params);
            if (params && params.finished === false) {
                this._column_resize_active = true;
                this._cancel_grid_layout_refresh();
                return;
            }
            this._column_resize_active = false;
            this._on_grid_column_layout_changed({skipLayoutRefresh: true});
            window.requestAnimationFrame(() => {
                this._trace_aggrid_column_resize('before-scroll-refresh', params);
                this._force_horizontal_scroll_width();
                this._trace_aggrid_column_resize('after-scroll-refresh', params);
            });
        },
        _trace_aggrid_column_resize: function(reason, params, extra) {
        },
        _refresh_grid_layout_now: function() {
            if (!this.gridApi || !this.grid_host || !this.grid_host.length) {
                return;
            }
            if (this._column_resize_active) {
                return;
            }
            this._resize_grid_host();
            this._force_horizontal_scroll_width();
            if (this.gridApi && this.gridApi.doLayout) {
                this.gridApi.doLayout();
            }
        },
        _cancel_grid_layout_refresh: function() {
            if (!this._layout_refresh_timers) {
                return;
            }
            this._layout_refresh_timers.forEach(timer =>
                window.clearTimeout(timer));
            this._layout_refresh_timers = [];
        },
        _schedule_grid_layout_refresh: function(delays) {
            if (!this.gridApi || !this.grid_host || !this.grid_host.length) {
                return;
            }
            if (this._column_resize_active) {
                return;
            }
            this._cancel_grid_layout_refresh();
            this._layout_refresh_timers = [];
            (delays || [0]).forEach(delay => {
                var timer = window.setTimeout(() => {
                    this._layout_refresh_timers =
                        (this._layout_refresh_timers || [])
                            .filter(item => item != timer);
                    this._refresh_grid_layout_now();
                }, delay);
                this._layout_refresh_timers.push(timer);
            });
        },
        _is_compact_density: function() {
            return document.documentElement.getAttribute('data-density') ==
                'compact';
        },
        _density_px: function(name, fallback) {
            var value = window.getComputedStyle(document.documentElement)
                .getPropertyValue(name);
            value = parseFloat(value);
            return isNaN(value) ? fallback : value;
        },
        _embedded_grid_auto_height: function() {
            var compact_density = this._is_compact_density();
            var row_height = compact_density ?
                this._density_px('--tradon-density-ag-row-height', 25) : 38;
            var header_height = compact_density ?
                this._density_px('--tradon-density-ag-header-height', 28) : 42;
            var row_count = 0;
            if (this.gridApi && this.gridApi.getDisplayedRowCount) {
                row_count = this.gridApi.getDisplayedRowCount();
            } else {
                row_count = (this._last_row_data || []).filter(row =>
                    row.__kind == 'record').length;
            }
            var body_height = row_count ?
                Math.min(Math.max(row_count, 1), 4) * row_height + 8 :
                (compact_density ? 52 : 82);
            var min_height = compact_density ? 86 : 132;
            var max_height = compact_density ? 149 : 220;
            return Math.max(
                min_height,
                Math.min(max_height, header_height + body_height + 2));
        },
        _install_resize_observer: function() {
            if (!this.grid_host || !this.grid_host.length) {
                return;
            }
            if (window.ResizeObserver) {
                var targets = [
                    this.grid_host.closest('.screen-container')[0],
                    this.grid_host.closest('#tabcontent')[0],
                    this.grid_host.closest('.tab-pane.active')[0],
                    this.grid_host.closest('.main-offcanvas')[0],
                    this.grid_host.parent()[0],
                ].filter(Boolean);
                this._resize_observer = new ResizeObserver(() => {
                    this._schedule_grid_layout_refresh([0, 80]);
                });
                targets.forEach(target => this._resize_observer.observe(target));
            }
            if (window.MutationObserver) {
                var offcanvas = document.querySelector('.row-offcanvas');
                if (offcanvas) {
                    this._offcanvas_observer = new MutationObserver(() => {
                        this._schedule_grid_layout_refresh([0, 80, 180, 320]);
                    });
                    this._offcanvas_observer.observe(offcanvas, {
                        attributes: true,
                        attributeFilter: ['class', 'style'],
                    });
                }
            }
            this._window_resize_handler = () => {
                this._schedule_grid_layout_refresh([0, 120]);
            };
            window.addEventListener('resize', this._window_resize_handler);
        },
        _install_theme_observer: function() {
            if (!window.MutationObserver || this._theme_observer) {
                return;
            }
            this._last_theme_mode = document.documentElement.getAttribute(
                'data-theme-mode') || 'light';
            this._theme_observer = new MutationObserver(mutations => {
                var changed = mutations.some(mutation =>
                    mutation.attributeName == 'data-theme-mode');
                if (!changed) {
                    return;
                }
                var mode = document.documentElement.getAttribute(
                    'data-theme-mode') || 'light';
                if (mode == this._last_theme_mode) {
                    return;
                }
                this._last_theme_mode = mode;
                this._refresh_after_theme_change();
            });
            this._theme_observer.observe(document.documentElement, {
                attributes: true,
                attributeFilter: ['data-theme-mode'],
            });
        },
        _refresh_after_theme_change: function() {
            if (!this.gridApi) {
                return;
            }
            window.requestAnimationFrame(() => {
                if (!this.gridApi) {
                    return;
                }
                if (this.gridApi.refreshCells) {
                    this.gridApi.refreshCells({force: true});
                }
                if (this.gridApi.redrawRows) {
                    this.gridApi.redrawRows();
                }
                this._update_summary_bar();
                this._schedule_grid_layout_refresh([0, 80]);
            });
        },
        _persist_grid_state: function(options) {
            options = options || {};
            var state = this._capture_grid_state();
            state.valueFilters = this._merge_restored_value_filter_state(
                state.valueFilters);
            this._trace_sao_value_filter_state('auto_persist', state);
            if (this._should_keep_persisted_value_filters(state.valueFilters)) {
                state.valueFilters = jQuery.extend(
                    true, {}, this._persisted_state.valueFilters);
                this._trace_sao_value_filter_state(
                    'auto_persist_keep_saved_value_filters', state);
            }
            this._persisted_state = state;
            this._save_persisted_state(state);
            this._sync_optional_columns_from_grid_state(state.columns);
            this._render_column_menu();
            this._render_grouping_panel();
            this._render_layout_panel();
            this._update_layout_button();
            if (!options.skipLayoutRefresh) {
                this._schedule_grid_layout_refresh();
            }
        },
        _capture_grid_state: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            return {
                columns: column_api && column_api.getColumnState ?
                    column_api.getColumnState()
                        .filter(column_state =>
                            column_state.colId != '__selection__')
                        .map(column_state =>
                            this._strip_sort_from_column_state(column_state)) : [],
                filters: this.gridApi && this.gridApi.getFilterModel ?
                    this._strip_sao_value_set_filter_model(
                        this.gridApi.getFilterModel()) : {},
                valueFilters: this._capture_sao_value_filter_state(),
                grouping: this._get_grouping_state(),
            };
        },
        _strip_sort_from_column_state: function(column_state) {
            column_state = jQuery.extend({}, column_state || {});
            column_state.sort = null;
            column_state.sortIndex = null;
            return column_state;
        },
        _strip_sao_value_set_filter_model: function(filter_model) {
            var cleaned = {};
            Object.keys(filter_model || {}).forEach(col_id => {
                var model = filter_model[col_id];
                if (model && model.filterType == 'saoValueSet') {
                    return;
                }
                cleaned[col_id] = model;
            });
            return cleaned;
        },
        _capture_sao_value_filter_state: function() {
            var filters = {};
            Object.keys(this._sao_value_filters || {}).forEach(col_id => {
                var filter = this._sao_value_filters[col_id];
                if (!this._is_sao_value_filter_active(filter)) {
                    return;
                }
                filters[col_id] = {
                    selected: Array.from(filter.selected || []).sort(),
                    all: Array.from(filter.all || []).sort(),
                };
            });
            var filter_model = this.gridApi && this.gridApi.getFilterModel ?
                this.gridApi.getFilterModel() : {};
            Object.keys(filter_model || {}).forEach(col_id => {
                if (filters[col_id]) {
                    return;
                }
                var model = filter_model[col_id];
                if (!model || model.filterType != 'saoValueSet' ||
                        !Array.isArray(model.values)) {
                    return;
                }
                var column = this._find_column_by_id(col_id);
                var all_values = column ?
                    this._get_live_filter_values(column, col_id) : [];
                if (!all_values.length || model.values.length == all_values.length) {
                    return;
                }
                filters[col_id] = {
                    selected: model.values.map(value => String(value)).sort(),
                    all: all_values.map(value => String(value)).sort(),
                };
            });
            if (!Object.keys(filters).length &&
                    this._sao_value_filter_restore_guard &&
                    (!this.group || !this.group.length) &&
                    this._persisted_state &&
                    this._persisted_state.valueFilters &&
                    this._persisted_state.valueFilters.filters &&
                    Object.keys(this._persisted_state.valueFilters.filters).length) {
                return jQuery.extend(true, {},
                    this._persisted_state.valueFilters);
            }
            return {
                mode: this._sao_value_filter_mode == 'any' ? 'any' : 'all',
                filters: filters,
                groups: this._sao_value_filter_groups ?
                    jQuery.extend(true, {}, this._sao_value_filter_groups) : null,
            };
        },
        _merge_restored_value_filter_state: function(value_filters) {
            value_filters = value_filters || {};
            if (!this._sao_value_filter_restore_guard ||
                    this.group && this.group.length) {
                return value_filters;
            }
            var restored = this._restored_value_filter_state;
            if (!restored || !restored.filters) {
                return value_filters;
            }
            value_filters = jQuery.extend(true, {
                mode: this._sao_value_filter_mode == 'any' ? 'any' : 'all',
                filters: {},
                groups: null,
            }, value_filters);
            Object.keys(restored.filters || {}).forEach(col_id => {
                var current = value_filters.filters[col_id];
                var saved = restored.filters[col_id];
                if ((!current || !current.selected || !current.selected.length) &&
                        saved && saved.selected && saved.selected.length) {
                    value_filters.filters[col_id] =
                        jQuery.extend(true, {}, saved);
                }
            });
            if (!value_filters.groups && restored.groups) {
                value_filters.groups = jQuery.extend(true, {}, restored.groups);
            }
            return value_filters;
        },
        _should_keep_persisted_value_filters: function(value_filters) {
            var current_keys = Object.keys(
                value_filters && value_filters.filters || {});
            var saved_keys = Object.keys(
                this._persisted_state &&
                this._persisted_state.valueFilters &&
                this._persisted_state.valueFilters.filters || {});
            return this._sao_value_filter_restore_guard &&
                (!this.group || !this.group.length) &&
                !current_keys.length && Boolean(saved_keys.length);
        },
        _trace_sao_value_filter_state: function(reason, state) {
            if (!window.console) {
                return;
            }
            if (~['auto_persist', 'update_layout', 'save_layout_as']
                    .indexOf(reason)) {
                return;
            }
            state = state || {};
            var value_filters = state.valueFilters || state;
            var filters = value_filters.filters || {};
            var grid_filter_model = this.gridApi && this.gridApi.getFilterModel ?
                this.gridApi.getFilterModel() : {};
            console.info('[AGGRID][VALUE_FILTER_STATE_JSON]',
                JSON.stringify({
                    reason: reason,
                    instanceId: this._debug_id,
                    viewId: this.view_id,
                    model: this.screen && this.screen.model_name,
                    storageKey: this._get_storage_key(),
                    activeLayoutId: this._active_layout_id || null,
                    activeLayoutName: this._get_active_layout_name ?
                        this._get_active_layout_name() : null,
                    storeActivePresetId: this._layout_store &&
                        this._layout_store.activePresetId || null,
                    storeDefaultPresetId: this._layout_store &&
                        this._layout_store.defaultPresetId || null,
                    mode: value_filters.mode || this._sao_value_filter_mode,
                    stateFilterKeys: Object.keys(filters),
                    memoryFilterKeys: Object.keys(this._sao_value_filters || {})
                        .filter(col_id => this._is_sao_value_filter_active(
                            this._sao_value_filters[col_id])),
                    gridFilterModelKeys: Object.keys(grid_filter_model || {}),
                    stateFilters: Object.keys(filters).map(col_id => ({
                        colId: col_id,
                        selected: filters[col_id].selected || [],
                        allCount: (filters[col_id].all || []).length,
                    })),
                }));
        },
        _apply_sao_value_filter_state: function(state) {
            state = state || {};
            this._trace_sao_value_filter_state('apply_value_filter_state', {
                valueFilters: state,
            });
            this._restored_value_filter_state = jQuery.extend(true, {}, state);
            this._sao_value_filter_restore_guard =
                Boolean(Object.keys(state.filters || {}).length);
            this._sao_value_filter_mode = state.mode == 'any' ? 'any' : 'all';
            this._sao_value_filters = {};
            Object.keys(state.filters || {}).forEach(col_id => {
                var filter = state.filters[col_id] || {};
                this._sao_value_filters[col_id] = {
                    selected: new Set((filter.selected || [])
                        .map(value => String(value))),
                    all: new Set((filter.all || [])
                        .map(value => String(value))),
                };
            });
            this._sao_value_filter_groups = state.groups ?
                jQuery.extend(true, {}, state.groups) : null;
            this._update_sao_value_filter_mode_button();
            this._render_active_filter_chips();
        },
        _apply_saved_sort_to_screen_order: function() {
            this.screen.order = this.screen.default_order;
        },
        _apply_persisted_grid_state: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !column_api.applyColumnState) {
                return false;
            }
            if (!this._persisted_state || !this._persisted_state.columns ||
                    !this._persisted_state.columns.length) {
                return false;
            }
            var column_state = this._persisted_state.columns.map(state =>
                this._strip_sort_from_column_state(state));
            this._syncing_grid = true;
            var apply_options = {
                state: column_state,
                applyOrder: true,
            };
            this._trace_aggrid_column_resize(
                'apply-persisted-column-state', null, {
                    appliedState: column_state.map(state => ({
                        colId: state.colId,
                        width: state.width,
                        sort: state.sort || null,
                    })),
                });
            apply_options.defaultState = {sort: null};
            var applied = column_api.applyColumnState(apply_options);
            this._syncing_grid = false;
            if (applied === false) {
                Sao.Logger.warn(
                    'AG Grid state partially restored for %s',
                    this.screen.model_name);
            }
            this._sync_optional_columns_from_grid_state(
                this._persisted_state.columns);
            if (this.gridApi && this.gridApi.setFilterModel &&
                    this._persisted_state.filters) {
                this.gridApi.setFilterModel(
                    this._strip_sao_value_set_filter_model(
                        this._persisted_state.filters));
            }
            return true;
        },
        _apply_grouping_column_order: function() {
            var grouping = this._get_grouping_state();
            if (!grouping.groupBy.length) {
                return;
            }
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !column_api.getColumnState ||
                    !column_api.applyColumnState) {
                return;
            }
            var group_ids = grouping.groupBy.filter(col_id =>
                Boolean(this._find_column_by_id(col_id)));
            if (!group_ids.length) {
                return;
            }
            var current_state = column_api.getColumnState();
            var current_ids = current_state
                .map(state => state.colId)
                .filter(col_id => col_id && col_id != '__selection__');
            var ordered_ids = group_ids.concat(current_ids.filter(col_id =>
                !~group_ids.indexOf(col_id)));
            var ordered_state = ordered_ids.map(col_id => ({colId: col_id}));
            this._syncing_grid = true;
            column_api.applyColumnState({
                state: ordered_state,
                applyOrder: true,
            });
            this._syncing_grid = false;
        },
        _get_column_visibility_map: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            var visibility = {};
            if (column_api && column_api.getColumnState) {
                column_api.getColumnState().forEach(column_state => {
                    visibility[column_state.colId] = !column_state.hide;
                });
            } else if (this._persisted_state && this._persisted_state.columns) {
                this._persisted_state.columns.forEach(column_state => {
                    visibility[column_state.colId] = !column_state.hide;
                });
            }
            return visibility;
        },
        _get_default_grouping_state: function() {
            return {
                groupBy: [],
                aggregations: {},
                showSubtotals: true,
                showGrandTotal: true,
                expandAll: false,
                expandedPaths: [],
            };
        },
        _normalize_grouping_state: function(state) {
            var defaults = this._get_default_grouping_state();
            state = jQuery.extend(true, {}, defaults, state || {});
            var valid_columns = new Set(this._list_groupable_columns()
                .map(column => column.attributes.name));
            var numeric_columns = new Set(this._list_aggregatable_columns()
                .map(column => column.attributes.name));
            var seen = new Set();
            state.groupBy = (state.groupBy || []).filter(name => {
                if (!valid_columns.has(name) || seen.has(name)) {
                    return false;
                }
                seen.add(name);
                return true;
            });
            var aggregations = {};
            Object.keys(state.aggregations || {}).forEach(name => {
                var operator = state.aggregations[name];
                if (!numeric_columns.has(name) || !operator || operator == 'none') {
                    return;
                }
                aggregations[name] = operator;
            });
            state.aggregations = aggregations;
            state.expandedPaths = (state.expandedPaths || []).filter(path =>
                Array.isArray(path) && path.length);
            state.showSubtotals = Boolean(state.showSubtotals);
            state.showGrandTotal = Boolean(state.showGrandTotal);
            state.expandAll = Boolean(state.expandAll);
            return state;
        },
        _get_grouping_state: function() {
            var state = this._persisted_state && this._persisted_state.grouping ?
                this._persisted_state.grouping : null;
            return this._normalize_grouping_state(state);
        },
        _build_grouping_state: function(current, updates) {
            current = this._normalize_grouping_state(current);
            updates = updates || {};
            return this._normalize_grouping_state({
                groupBy: Object.prototype.hasOwnProperty.call(updates, 'groupBy') ?
                    (updates.groupBy || []).slice() : current.groupBy.slice(),
                aggregations: Object.prototype.hasOwnProperty.call(
                    updates, 'aggregations') ?
                    jQuery.extend({}, updates.aggregations || {}) :
                    jQuery.extend({}, current.aggregations || {}),
                showSubtotals: Object.prototype.hasOwnProperty.call(
                    updates, 'showSubtotals') ?
                    updates.showSubtotals : current.showSubtotals,
                showGrandTotal: Object.prototype.hasOwnProperty.call(
                    updates, 'showGrandTotal') ?
                    updates.showGrandTotal : current.showGrandTotal,
                expandAll: Object.prototype.hasOwnProperty.call(
                    updates, 'expandAll') ?
                    updates.expandAll : current.expandAll,
                expandedPaths: Object.prototype.hasOwnProperty.call(
                    updates, 'expandedPaths') ?
                    (updates.expandedPaths || []).map(path => path.slice()) :
                    current.expandedPaths.map(path => path.slice()),
            });
        },
        _set_grouping_state: function(next_state) {
            var previous = this._get_grouping_state();
            var state = this._normalize_grouping_state(next_state);
            this._persisted_state = jQuery.extend({}, this._persisted_state || {});
            this._persisted_state.grouping = this._build_grouping_state(
                this._get_default_grouping_state(), state);
            this._mark_grid_state_dirty();
            this._force_rowdata_reset = true;
            this.display(this.get_selected_paths());
            this._render_column_menu();
            this._render_grouping_panel();
            this._render_layout_panel();
            this._update_layout_button();
            if (this.grouping_panel && this.grouping_panel.is(':visible')) {
                this._position_grouping_menu();
            }
        },
        _move_grouping_level: function(index, delta) {
            var current = this._get_grouping_state();
            var next = current.groupBy.slice();
            var other_index = index + delta;
            if (other_index < 0 || other_index >= next.length) {
                return;
            }
            var tmp = next[other_index];
            next[other_index] = next[index];
            next[index] = tmp;
            this._set_grouping_state(this._build_grouping_state(current, {
                groupBy: next,
            }));
        },
        _remove_grouping_level: function(column_name) {
            var current = this._get_grouping_state();
            var next_group_by = current.groupBy.filter(name => name != column_name);
            this._set_grouping_state(this._build_grouping_state(current, {
                groupBy: next_group_by,
                expandedPaths: [],
                expandAll: false,
            }));
        },
        _is_grouping_active: function() {
            return Boolean(this._get_grouping_state().groupBy.length);
        },
        _list_toggleable_columns: function() {
            return this.columns.filter(column =>
                !(column instanceof Sao.View.Tree.ButtonColumn) &&
                column.attributes.name &&
                (column.attributes.name !== this.screen.exclude_field));
        },
        _list_groupable_columns: function() {
            return this._list_toggleable_columns();
        },
        _list_aggregatable_columns: function() {
            return this._list_toggleable_columns().filter(column =>
                this._is_aggregatable_column(column));
        },
        _find_column_by_id: function(col_id) {
            return this.columns.find(column => column.attributes.name == col_id);
        },
        _is_numeric_column: function(column) {
            if (!column || !column.attributes || !column.attributes.name) {
                return false;
            }
            var field = this.screen.model.fields[column.attributes.name];
            if (!field || !field.description) {
                return false;
            }
            return ~['integer', 'float', 'numeric', 'timedelta']
                .indexOf(field.description.type);
        },
        _is_aggregatable_column: function(column) {
            return this._is_numeric_column(column);
        },
        _toggle_column_menu: function() {
            if (!this.column_menu_dropdown) {
                return;
            }
            if (this.column_menu_dropdown.is(':visible')) {
                this._hide_column_menu();
            } else {
                this._show_column_menu();
            }
        },
        _show_column_menu: function() {
            this._render_column_menu();
            this._size_column_menu();
            this._position_column_menu();
            this.column_menu_dropdown.show().scrollTop(0);
            this.column_menu_button.attr('aria-expanded', 'true');
            this._bind_column_menu_events();
        },
        _hide_column_menu: function() {
            if (!this.column_menu_dropdown) {
                return;
            }
            this.column_menu_dropdown.hide();
            this.column_menu_button.attr('aria-expanded', 'false');
            this._unbind_column_menu_events();
        },
        _position_column_menu: function() {
            if (!this.column_menu_dropdown || !this.column_menu_button ||
                    !this.column_menu_button.length) {
                return;
            }
            var button_rect = this.column_menu_button[0].getBoundingClientRect();
            this.column_menu_dropdown.css({
                visibility: 'hidden',
                display: 'block',
                position: 'fixed',
                top: 0,
                left: 0,
            });
            var menu_width = this.column_menu_dropdown.outerWidth();
            var menu_height = this.column_menu_dropdown.outerHeight();
            var left = Math.max(12, Math.min(
                button_rect.left,
                window.innerWidth - menu_width - 12));
            var top = button_rect.bottom + 4;
            if (top + menu_height > window.innerHeight - 12) {
                top = Math.max(12, button_rect.top - menu_height - 4);
            }
            this.column_menu_dropdown.css({
                left: left + 'px',
                top: top + 'px',
                visibility: 'visible',
            });
        },
        _size_column_menu: function() {
            if (!this.column_menu_dropdown || !this.column_menu_dropdown.length) {
                return;
            }
            var widest_label = 0;
            this.column_menu_dropdown.find('.ag-grid-tree-column-toggle-text')
                .each((index, node) => {
                    widest_label = Math.max(
                        widest_label,
                        Math.ceil(node.scrollWidth || node.offsetWidth || 0));
                });
            var target_width = Math.max(220, Math.min(
                460,
                widest_label + 72));
            this.column_menu_dropdown.css({
                width: target_width + 'px',
                minWidth: target_width + 'px',
            });
        },
        _bind_column_menu_events: function() {
            if (this._column_menu_events_bound) {
                return;
            }
            this._column_menu_events_bound = true;
            this._column_menu_document_handler = event => {
                var target = event.target;
                if (this.column_menu_dropdown &&
                        this.column_menu_dropdown[0].contains(target)) {
                    return;
                }
                if (this.column_menu_button &&
                        this.column_menu_button[0].contains(target)) {
                    return;
                }
                this._hide_column_menu();
            };
            this._column_menu_keydown_handler = event => {
                if (event.key == 'Escape') {
                    this._hide_column_menu();
                }
            };
            this._column_menu_reposition_handler = () => {
                if (!this.column_menu_dropdown ||
                        !this.column_menu_dropdown.is(':visible')) {
                    return;
                }
                this._size_column_menu();
                this._position_column_menu();
            };
            jQuery(document).on(
                'mousedown.aggrid-columns-' + this.view_id,
                this._column_menu_document_handler);
            jQuery(document).on(
                'keydown.aggrid-columns-' + this.view_id,
                this._column_menu_keydown_handler);
            jQuery(window).on(
                'resize.aggrid-columns-' + this.view_id,
                this._column_menu_reposition_handler);
            jQuery(window).on(
                'scroll.aggrid-columns-' + this.view_id,
                this._column_menu_reposition_handler);
        },
        _unbind_column_menu_events: function() {
            if (!this._column_menu_events_bound) {
                return;
            }
            this._column_menu_events_bound = false;
            jQuery(document).off('mousedown.aggrid-columns-' + this.view_id);
            jQuery(document).off('keydown.aggrid-columns-' + this.view_id);
            jQuery(window).off('resize.aggrid-columns-' + this.view_id);
            jQuery(window).off('scroll.aggrid-columns-' + this.view_id);
        },
        _toggle_grouping_menu: function() {
            if (!this.grouping_panel) {
                return;
            }
            if (this.grouping_panel.is(':visible')) {
                this._hide_grouping_menu();
            } else {
                this._show_grouping_menu();
            }
        },
        _show_grouping_menu: function() {
            this._render_grouping_panel();
            this._position_grouping_menu();
            this.grouping_panel.show();
            this.grouping_button.attr('aria-expanded', 'true');
            this._bind_grouping_menu_events();
        },
        _hide_grouping_menu: function() {
            if (!this.grouping_panel) {
                return;
            }
            this.grouping_panel.hide();
            this.grouping_button.attr('aria-expanded', 'false');
            this._unbind_grouping_menu_events();
        },
        _position_grouping_menu: function() {
            if (!this.grouping_panel || !this.grouping_button ||
                    !this.grouping_button.length) {
                return;
            }
            var button_rect = this.grouping_button[0].getBoundingClientRect();
            this.grouping_panel.css({
                visibility: 'hidden',
                display: 'block',
                position: 'fixed',
                top: 0,
                left: 0,
            });
            var panel_width = this.grouping_panel.outerWidth();
            var panel_height = this.grouping_panel.outerHeight();
            var left = Math.max(12, Math.min(
                button_rect.left,
                window.innerWidth - panel_width - 12));
            var top = button_rect.bottom + 4;
            if (top + panel_height > window.innerHeight - 12) {
                top = Math.max(12, button_rect.top - panel_height - 4);
            }
            this.grouping_panel.css({
                left: left + 'px',
                top: top + 'px',
                visibility: 'visible',
            });
        },
        _bind_grouping_menu_events: function() {
            if (this._grouping_menu_events_bound) {
                return;
            }
            this._grouping_menu_events_bound = true;
            this._grouping_menu_document_handler = event => {
                var target = event.target;
                if (this.grouping_panel &&
                        this.grouping_panel[0].contains(target)) {
                    return;
                }
                if (this.grouping_button &&
                        this.grouping_button[0].contains(target)) {
                    return;
                }
                this._hide_grouping_menu();
            };
            this._grouping_menu_keydown_handler = event => {
                if (event.key == 'Escape') {
                    this._hide_grouping_menu();
                }
            };
            this._grouping_menu_reposition_handler = () => {
                if (!this.grouping_panel || !this.grouping_panel.is(':visible')) {
                    return;
                }
                this._position_grouping_menu();
            };
            jQuery(document).on(
                'mousedown.aggrid-grouping-' + this.view_id,
                this._grouping_menu_document_handler);
            jQuery(document).on(
                'keydown.aggrid-grouping-' + this.view_id,
                this._grouping_menu_keydown_handler);
            jQuery(window).on(
                'resize.aggrid-grouping-' + this.view_id,
                this._grouping_menu_reposition_handler);
            jQuery(window).on(
                'scroll.aggrid-grouping-' + this.view_id,
                this._grouping_menu_reposition_handler);
        },
        _unbind_grouping_menu_events: function() {
            if (!this._grouping_menu_events_bound) {
                return;
            }
            this._grouping_menu_events_bound = false;
            jQuery(document).off('mousedown.aggrid-grouping-' + this.view_id);
            jQuery(document).off('keydown.aggrid-grouping-' + this.view_id);
            jQuery(window).off('resize.aggrid-grouping-' + this.view_id);
            jQuery(window).off('scroll.aggrid-grouping-' + this.view_id);
        },
        _toggle_layout_menu: function() {
            if (!this.layout_panel) {
                return;
            }
            if (this.layout_panel.is(':visible')) {
                this._hide_layout_menu();
            } else {
                this._show_layout_menu();
            }
        },
        _show_layout_menu: function() {
            this._render_layout_panel();
            this._position_layout_menu();
            this.layout_panel.show();
            this.layout_button.attr('aria-expanded', 'true');
            this._bind_layout_menu_events();
        },
        _hide_layout_menu: function() {
            if (!this.layout_panel) {
                return;
            }
            this.layout_panel.hide();
            this.layout_button.attr('aria-expanded', 'false');
            this._unbind_layout_menu_events();
        },
        _position_layout_menu: function() {
            if (!this.layout_panel || !this.layout_button ||
                    !this.layout_button.length) {
                return;
            }
            var button_rect = this.layout_button[0].getBoundingClientRect();
            this.layout_panel.css({
                visibility: 'hidden',
                display: 'block',
                position: 'fixed',
                top: 0,
                left: 0,
            });
            var panel_width = this.layout_panel.outerWidth();
            var panel_height = this.layout_panel.outerHeight();
            var left = Math.max(12, Math.min(
                button_rect.left,
                window.innerWidth - panel_width - 12));
            var top = button_rect.bottom + 4;
            if (top + panel_height > window.innerHeight - 12) {
                top = Math.max(12, button_rect.top - panel_height - 4);
            }
            this.layout_panel.css({
                left: left + 'px',
                top: top + 'px',
                visibility: 'visible',
            });
        },
        _bind_layout_menu_events: function() {
            if (this._layout_menu_events_bound) {
                return;
            }
            this._layout_menu_events_bound = true;
            this._layout_menu_document_handler = event => {
                var target = event.target;
                if (this.layout_panel && this.layout_panel[0].contains(target)) {
                    return;
                }
                if (this.layout_button && this.layout_button[0].contains(target)) {
                    return;
                }
                this._hide_layout_menu();
            };
            this._layout_menu_keydown_handler = event => {
                if (event.key == 'Escape') {
                    this._hide_layout_menu();
                }
            };
            this._layout_menu_reposition_handler = () => {
                if (!this.layout_panel || !this.layout_panel.is(':visible')) {
                    return;
                }
                this._position_layout_menu();
            };
            jQuery(document).on(
                'mousedown.aggrid-layouts-' + this.view_id,
                this._layout_menu_document_handler);
            jQuery(document).on(
                'keydown.aggrid-layouts-' + this.view_id,
                this._layout_menu_keydown_handler);
            jQuery(window).on(
                'resize.aggrid-layouts-' + this.view_id,
                this._layout_menu_reposition_handler);
            jQuery(window).on(
                'scroll.aggrid-layouts-' + this.view_id,
                this._layout_menu_reposition_handler);
        },
        _unbind_layout_menu_events: function() {
            if (!this._layout_menu_events_bound) {
                return;
            }
            this._layout_menu_events_bound = false;
            jQuery(document).off('mousedown.aggrid-layouts-' + this.view_id);
            jQuery(document).off('keydown.aggrid-layouts-' + this.view_id);
            jQuery(window).off('resize.aggrid-layouts-' + this.view_id);
            jQuery(window).off('scroll.aggrid-layouts-' + this.view_id);
        },
        _toggle_filter_logic_menu: function() {
            if (!this.filter_logic_panel) {
                return;
            }
            if (this.filter_logic_panel.is(':visible')) {
                this._hide_filter_logic_menu();
            } else {
                this._show_filter_logic_menu();
            }
        },
        _show_filter_logic_menu: function() {
            this._render_filter_logic_panel();
            this._position_filter_logic_menu();
            this.filter_logic_panel.show();
            this.value_filter_mode_button.attr('aria-expanded', 'true');
            this._bind_filter_logic_menu_events();
        },
        _hide_filter_logic_menu: function() {
            if (!this.filter_logic_panel) {
                return;
            }
            this.filter_logic_panel.hide();
            if (this.value_filter_mode_button) {
                this.value_filter_mode_button.attr('aria-expanded', 'false');
            }
            this._unbind_filter_logic_menu_events();
        },
        _position_filter_logic_menu: function() {
            if (!this.filter_logic_panel || !this.value_filter_mode_button ||
                    !this.value_filter_mode_button.length) {
                return;
            }
            var button_rect =
                this.value_filter_mode_button[0].getBoundingClientRect();
            this.filter_logic_panel.css({
                visibility: 'hidden',
                display: 'block',
                position: 'fixed',
                top: 0,
                left: 0,
            });
            var panel_width = this.filter_logic_panel.outerWidth();
            var panel_height = this.filter_logic_panel.outerHeight();
            var left = Math.max(12, Math.min(
                button_rect.left,
                window.innerWidth - panel_width - 12));
            var top = button_rect.bottom + 4;
            if (top + panel_height > window.innerHeight - 12) {
                top = Math.max(12, button_rect.top - panel_height - 4);
            }
            this.filter_logic_panel.css({
                left: left + 'px',
                top: top + 'px',
                visibility: 'visible',
            });
        },
        _bind_filter_logic_menu_events: function() {
            if (this._filter_logic_menu_events_bound) {
                return;
            }
            this._filter_logic_menu_events_bound = true;
            this._filter_logic_menu_document_handler = event => {
                var target = event.target;
                if (this.filter_logic_panel &&
                        this.filter_logic_panel[0].contains(target)) {
                    return;
                }
                if (this.value_filter_mode_button &&
                        this.value_filter_mode_button[0].contains(target)) {
                    return;
                }
                this._hide_filter_logic_menu();
            };
            this._filter_logic_menu_keydown_handler = event => {
                if (event.key == 'Escape') {
                    this._hide_filter_logic_menu();
                }
            };
            this._filter_logic_menu_reposition_handler = () => {
                if (!this.filter_logic_panel ||
                        !this.filter_logic_panel.is(':visible')) {
                    return;
                }
                this._position_filter_logic_menu();
            };
            jQuery(document).on(
                'mousedown.aggrid-filter-logic-' + this.view_id,
                this._filter_logic_menu_document_handler);
            jQuery(document).on(
                'keydown.aggrid-filter-logic-' + this.view_id,
                this._filter_logic_menu_keydown_handler);
            jQuery(window).on(
                'resize.aggrid-filter-logic-' + this.view_id,
                this._filter_logic_menu_reposition_handler);
            jQuery(window).on(
                'scroll.aggrid-filter-logic-' + this.view_id,
                this._filter_logic_menu_reposition_handler);
        },
        _unbind_filter_logic_menu_events: function() {
            if (!this._filter_logic_menu_events_bound) {
                return;
            }
            this._filter_logic_menu_events_bound = false;
            jQuery(document).off(
                'mousedown.aggrid-filter-logic-' + this.view_id);
            jQuery(document).off(
                'keydown.aggrid-filter-logic-' + this.view_id);
            jQuery(window).off('resize.aggrid-filter-logic-' + this.view_id);
            jQuery(window).off('scroll.aggrid-filter-logic-' + this.view_id);
        },
        _get_active_layout_name: function() {
            var preset = this._find_layout_preset(this._active_layout_id);
            if (!preset) {
                return this._is_standard_layout_active() ?
                    Sao.i18n.gettext('Default view') :
                    Sao.i18n.gettext('Unsaved view');
            }
            return preset.name + (this._layout_dirty ? ' *' : '');
        },
        _update_layout_button: function() {
            if (!this.layout_button) {
                return;
            }
            var label = this._get_active_layout_name();
            this.layout_button.attr('title', label);
            this.layout_button.find('.ag-grid-tree-layouts-label')
                .text(' ' + label);
        },
        _apply_layout_state: function(state, preset_id) {
            if (!state) {
                state = this._get_standard_layout_state();
            }
            this._active_layout_id = preset_id || null;
            this._layout_dirty = false;
            this._persisted_state = jQuery.extend(true, {}, state);
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            this._layout_store.activePresetId = this._active_layout_id;
            this._layout_store.currentState = jQuery.extend(true, {}, state);
            this._save_layout_store();
            this._apply_sao_value_filter_state(state.valueFilters);
            this._trace_sao_value_filter_state('apply_layout_state', state);
            this._apply_saved_sort_to_screen_order();
            this._force_rowdata_reset = true;
            this.display(this.get_selected_paths());
            this._render_column_menu();
            this._render_grouping_panel();
            this._render_layout_panel();
            this._update_layout_button();
        },
        _get_standard_layout_state: function() {
            return {
                columns: [],
                filters: {},
                valueFilters: {
                    mode: 'all',
                    filters: {},
                    groups: null,
                },
                grouping: this._get_default_grouping_state(),
            };
        },
        _is_standard_layout_state: function(state) {
            state = state || {};
            var value_filters = state.valueFilters || {};
            var grouping = state.grouping || this._get_default_grouping_state();
            return !(state.columns && state.columns.length) &&
                !Object.keys(state.filters || {}).length &&
                !Object.keys(value_filters.filters || {}).length &&
                !value_filters.groups &&
                Sao.common.compare(
                    this._normalize_grouping_state(grouping),
                    this._get_default_grouping_state());
        },
        _is_standard_layout_active: function() {
            return !this._active_layout_id &&
                this._is_standard_layout_state(this._persisted_state);
        },
        _apply_standard_layout_state: function() {
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            this._layout_store.defaultPresetId = null;
            this._apply_layout_state(this._get_standard_layout_state(), null);
        },
        _sanitize_layout_file_name: function(name) {
            name = String(name || Sao.i18n.gettext('AG Grid view')).trim();
            name = name.replace(/[\\/:*?"<>|]+/g, '-')
                .replace(/\s+/g, ' ')
                .trim();
            return name || 'ag-grid-view';
        },
        _get_layout_export_payload: function() {
            var preset = this._find_layout_preset(this._active_layout_id);
            var name = preset && !this._layout_dirty ? preset.name :
                this._get_active_layout_name().replace(/\s+\*$/, '');
            return {
                type: 'sao-ag-grid-view',
                version: 1,
                model: this.screen && this.screen.model_name || null,
                viewId: this.view_id || null,
                name: name || Sao.i18n.gettext('AG Grid view'),
                exportedAt: new Date().toISOString(),
                state: this._capture_grid_state(),
            };
        },
        _export_current_layout: function() {
            var payload = this._get_layout_export_payload();
            var file_name = this._sanitize_layout_file_name(
                payload.model + '-' + payload.name) + '.json';
            Sao.common.download_file(
                JSON.stringify(payload, null, 2),
                file_name,
                {type: 'application/json;charset=utf-8'});
        },
        _decode_imported_layout_text: function(data) {
            if (typeof data == 'string') {
                return data;
            }
            if (data instanceof Uint8Array) {
                return new TextDecoder('utf-8').decode(data);
            }
            return String(data || '');
        },
        _import_layout_from_input: function(input) {
            var file = input && input.files && input.files[0];
            if (!file) {
                return;
            }
            Sao.common.get_file_data(file, data => {
                var payload;
                try {
                    payload = JSON.parse(this._decode_imported_layout_text(data));
                } catch (error) {
                    Sao.common.warning.run(
                        Sao.i18n.gettext('The selected file is not a valid AG Grid view.'),
                        Sao.i18n.gettext('Import view'));
                    return;
                }
                this._import_layout_payload(payload);
            });
        },
        _import_layout_payload: function(payload) {
            if (!payload || payload.type != 'sao-ag-grid-view' ||
                    !payload.state) {
                Sao.common.warning.run(
                    Sao.i18n.gettext('The selected file is not a valid AG Grid view.'),
                    Sao.i18n.gettext('Import view'));
                return;
            }
            if (payload.model && payload.model != this.screen.model_name) {
                Sao.common.warning.run(
                    Sao.i18n.gettext('This view is for another model.') +
                        ' ' + payload.model + ' / ' + this.screen.model_name,
                    Sao.i18n.gettext('Import view'));
                return;
            }
            var name = String(payload.name ||
                Sao.i18n.gettext('Imported view')).trim();
            if (!name) {
                name = Sao.i18n.gettext('Imported view');
            }
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            var existing_names = new Set((this._layout_store.presets || [])
                .map(preset => preset.name));
            var base_name = name;
            var suffix = 2;
            while (existing_names.has(name)) {
                name = base_name + ' ' + suffix;
                suffix += 1;
            }
            var now = new Date().toISOString();
            var preset = {
                id: this._new_layout_id(),
                name: name,
                importedAt: now,
                updatedAt: now,
                state: jQuery.extend(true, {}, payload.state),
            };
            this._layout_store.presets.push(preset);
            this._active_layout_id = preset.id;
            this._layout_dirty = false;
            this._layout_store.activePresetId = preset.id;
            this._layout_store.currentState = jQuery.extend(true, {}, preset.state);
            this._persisted_state = jQuery.extend(true, {}, preset.state);
            this._save_layout_store();
            this._apply_layout_state(preset.state, preset.id);
        },
        _save_layout_as: function() {
            var name = window.prompt(Sao.i18n.gettext('View name'));
            if (!name) {
                return;
            }
            name = String(name).trim();
            if (!name) {
                return;
            }
            var now = new Date().toISOString();
            var preset = {
                id: this._new_layout_id(),
                name: name,
                createdAt: now,
                updatedAt: now,
                state: this._capture_grid_state(),
            };
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            this._layout_store.presets.push(preset);
            this._active_layout_id = preset.id;
            this._layout_dirty = false;
            this._layout_store.activePresetId = preset.id;
            this._layout_store.currentState = jQuery.extend(true, {}, preset.state);
            if (!this._layout_store.defaultPresetId) {
                this._layout_store.defaultPresetId = preset.id;
            }
            this._persisted_state = jQuery.extend(true, {}, preset.state);
            this._trace_sao_value_filter_state('save_layout_as', preset.state);
            this._save_layout_store();
            this._render_layout_panel();
            this._update_layout_button();
        },
        _update_active_layout: function() {
            var preset = this._find_layout_preset(this._active_layout_id);
            if (!preset) {
                this._save_layout_as();
                return;
            }
            preset.state = this._capture_grid_state();
            this._trace_sao_value_filter_state('update_layout', preset.state);
            preset.updatedAt = new Date().toISOString();
            this._persisted_state = jQuery.extend(true, {}, preset.state);
            this._layout_dirty = false;
            this._layout_store.currentState = jQuery.extend(true, {}, preset.state);
            this._save_layout_store();
            this._render_layout_panel();
            this._update_layout_button();
        },
        _rename_layout: function(preset_id) {
            var preset = this._find_layout_preset(preset_id);
            if (!preset) {
                return;
            }
            var name = window.prompt(Sao.i18n.gettext('View name'), preset.name);
            if (!name) {
                return;
            }
            name = String(name).trim();
            if (!name) {
                return;
            }
            preset.name = name;
            preset.updatedAt = new Date().toISOString();
            this._save_layout_store();
            this._render_layout_panel();
            this._update_layout_button();
        },
        _delete_layout: function(preset_id) {
            var preset = this._find_layout_preset(preset_id);
            if (!preset) {
                return;
            }
            if (!window.confirm(Sao.i18n.gettext('Delete this view?'))) {
                return;
            }
            this._layout_store.presets = this._layout_store.presets
                .filter(item => item.id != preset_id);
            if (this._layout_store.defaultPresetId == preset_id) {
                this._layout_store.defaultPresetId = null;
            }
            if (this._active_layout_id == preset_id) {
                this._active_layout_id = null;
                this._layout_store.activePresetId = null;
                this._layout_dirty = false;
            }
            this._save_layout_store();
            this._render_layout_panel();
            this._update_layout_button();
        },
        _set_default_layout: function(preset_id) {
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            this._layout_store.defaultPresetId = preset_id || null;
            this._save_layout_store();
            this._render_layout_panel();
        },
        _reset_layout_state: function() {
            this._apply_standard_layout_state();
        },
        _render_layout_panel: function() {
            if (!this.layout_panel) {
                return;
            }
            this._layout_store = this._normalize_layout_store(
                this._layout_store || {});
            var presets = this._layout_store.presets || [];
            this.layout_panel.empty();

            jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-title',
                'text': Sao.i18n.gettext('Views'),
            }).appendTo(this.layout_panel);

            jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-current text-muted',
                'text': this._get_active_layout_name(),
            }).appendTo(this.layout_panel);

            var list = jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-list',
            }).appendTo(this.layout_panel);
            var default_active = this._is_standard_layout_active();
            var default_row = jQuery('<div/>', {
                'class': 'ag-grid-tree-layout-row' +
                    (default_active ? ' active' : ''),
            }).appendTo(list);
            jQuery('<button/>', {
                'class': 'btn btn-link ag-grid-tree-layout-apply',
                'type': 'button',
                'text': Sao.i18n.gettext('Default view'),
            }).click(evt => {
                evt.preventDefault();
                this._apply_standard_layout_state();
            }).appendTo(default_row);
            if (!presets.length) {
                jQuery('<div/>', {
                    'class': 'ag-grid-tree-layouts-empty text-muted',
                    'text': Sao.i18n.gettext('No saved view'),
                }).appendTo(list);
            }
            presets.forEach(preset => {
                var active = preset.id == this._active_layout_id;
                var row = jQuery('<div/>', {
                    'class': 'ag-grid-tree-layout-row' +
                        (active ? ' active' : ''),
                }).appendTo(list);
                jQuery('<button/>', {
                    'class': 'btn btn-link ag-grid-tree-layout-apply',
                    'type': 'button',
                    'text': preset.name +
                        (preset.id == this._layout_store.defaultPresetId ?
                            ' (' + Sao.i18n.gettext('default') + ')' : ''),
                }).click(evt => {
                    evt.preventDefault();
                    this._apply_layout_state(preset.state, preset.id);
                }).appendTo(row);
                jQuery('<button/>', {
                    'class': 'btn btn-default btn-xs',
                    'type': 'button',
                    'text': Sao.i18n.gettext('Default'),
                    'disabled': preset.id == this._layout_store.defaultPresetId,
                }).click(evt => {
                    evt.preventDefault();
                    this._set_default_layout(preset.id);
                }).appendTo(row);
                jQuery('<button/>', {
                    'class': 'btn btn-default btn-xs',
                    'type': 'button',
                    'text': Sao.i18n.gettext('Rename'),
                }).click(evt => {
                    evt.preventDefault();
                    this._rename_layout(preset.id);
                }).appendTo(row);
                jQuery('<button/>', {
                    'class': 'btn btn-default btn-xs',
                    'type': 'button',
                    'text': Sao.i18n.gettext('Delete'),
                }).click(evt => {
                    evt.preventDefault();
                    this._delete_layout(preset.id);
                }).appendTo(row);
            });

            var actions = jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-actions',
            }).appendTo(this.layout_panel);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Save as'),
            }).click(evt => {
                evt.preventDefault();
                this._save_layout_as();
            }).appendTo(actions);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Update'),
                'disabled': !this._active_layout_id,
            }).click(evt => {
                evt.preventDefault();
                this._update_active_layout();
            }).appendTo(actions);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Clear default'),
                'disabled': !this._layout_store.defaultPresetId,
            }).click(evt => {
                evt.preventDefault();
                this._set_default_layout(null);
            }).appendTo(actions);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Default view'),
            }).click(evt => {
                evt.preventDefault();
                this._apply_standard_layout_state();
            }).appendTo(actions);
            var transfer_actions = jQuery('<div/>', {
                'class': 'ag-grid-tree-layouts-transfer-actions',
            }).appendTo(actions);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm ag-grid-view-transfer-button',
                'type': 'button',
                'title': Sao.i18n.gettext('Export view'),
                'aria-label': Sao.i18n.gettext('Export view'),
                'text': Sao.i18n.gettext('Export'),
            }).click(evt => {
                evt.preventDefault();
                this._export_current_layout();
            }).appendTo(transfer_actions);
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm ag-grid-view-transfer-button',
                'type': 'button',
                'title': Sao.i18n.gettext('Import view'),
                'aria-label': Sao.i18n.gettext('Import view'),
                'text': Sao.i18n.gettext('Import'),
            }).click(evt => {
                evt.preventDefault();
                this.import_layout_input.val(null);
                this.import_layout_input[0].click();
            }).appendTo(transfer_actions);
        },
        _render_grouping_panel: function() {
            if (!this.grouping_panel) {
                return;
            }
            var grouping = this._get_grouping_state();
            var active_columns = grouping.groupBy.map(name => this._find_column_by_id(name))
                .filter(Boolean);
            var available_columns = this._list_groupable_columns().filter(column =>
                !~grouping.groupBy.indexOf(column.attributes.name));
            var aggregations = grouping.aggregations || {};
            this.grouping_panel.empty();

            jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-title',
                'text': Sao.i18n.gettext('Grouping'),
            }).appendTo(this.grouping_panel);

            var levels = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section',
            }).appendTo(this.grouping_panel);
            jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section-label',
                'text': Sao.i18n.gettext('Group by'),
            }).appendTo(levels);
            if (!active_columns.length) {
                jQuery('<div/>', {
                    'class': 'ag-grid-tree-grouping-empty text-muted',
                    'text': Sao.i18n.gettext('No grouping level'),
                }).appendTo(levels);
            } else {
                active_columns.forEach((column, index) => {
                    var row = jQuery('<div/>', {
                        'class': 'ag-grid-tree-grouping-level',
                    }).appendTo(levels);
                    jQuery('<span/>', {
                        'class': 'ag-grid-tree-grouping-level-text',
                        'text': String(index + 1) + '. ' +
                            (column.attributes.string || column.attributes.name),
                    }).appendTo(row);
                    jQuery('<button/>', {
                        'class': 'btn btn-default btn-xs',
                        'type': 'button',
                        'text': Sao.i18n.gettext('Up'),
                        'disabled': index === 0,
                    }).on('click', evt => {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this._move_grouping_level(index, -1);
                    }).appendTo(row);
                    jQuery('<button/>', {
                        'class': 'btn btn-default btn-xs',
                        'type': 'button',
                        'text': Sao.i18n.gettext('Down'),
                        'disabled': index === active_columns.length - 1,
                    }).on('click', evt => {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this._move_grouping_level(index, 1);
                    }).appendTo(row);
                    jQuery('<button/>', {
                        'class': 'btn btn-default btn-xs',
                        'type': 'button',
                        'text': Sao.i18n.gettext('Remove'),
                    }).on('click', evt => {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this._remove_grouping_level(column.attributes.name);
                    }).appendTo(row);
                });
            }
            var add_row = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-add',
            }).appendTo(levels);
            var add_select = jQuery('<select/>', {
                'class': 'form-control input-sm',
            }).appendTo(add_row);
            jQuery('<option/>', {
                'value': '',
                'text': Sao.i18n.gettext('Add level...'),
            }).appendTo(add_select);
            available_columns.forEach(column => {
                jQuery('<option/>', {
                    'value': column.attributes.name,
                    'text': column.attributes.string || column.attributes.name,
                }).appendTo(add_select);
            });
            jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Add'),
            }).click(() => {
                var current = this._get_grouping_state();
                var value = add_select.val();
                if (!value) {
                    return;
                }
                this._set_grouping_state(this._build_grouping_state(current, {
                    groupBy: current.groupBy.concat([value]),
                }));
            }).appendTo(add_row);

            var aggregates = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section',
            }).appendTo(this.grouping_panel);
            jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section-label',
                'text': Sao.i18n.gettext('Aggregations'),
            }).appendTo(aggregates);
            this._list_aggregatable_columns().forEach(column => {
                var name = column.attributes.name;
                var row = jQuery('<div/>', {
                    'class': 'ag-grid-tree-grouping-aggregate',
                }).appendTo(aggregates);
                jQuery('<span/>', {
                    'class': 'ag-grid-tree-grouping-aggregate-label',
                    'text': column.attributes.string || name,
                }).appendTo(row);
                var select = jQuery('<select/>', {
                    'class': 'form-control input-sm',
                }).appendTo(row);
                [
                    ['none', Sao.i18n.gettext('None')],
                    ['sum', Sao.i18n.gettext('Sum')],
                    ['avg', Sao.i18n.gettext('Average')],
                    ['count', Sao.i18n.gettext('Count')],
                    ['min', Sao.i18n.gettext('Min')],
                    ['max', Sao.i18n.gettext('Max')],
                ].forEach(option => {
                    jQuery('<option/>', {
                        'value': option[0],
                        'text': option[1],
                    }).appendTo(select);
                });
                select.val(aggregations[name] || 'none');
                select.change(evt => {
                    var current = this._get_grouping_state();
                    var operator = jQuery(evt.currentTarget).val();
                    var next = jQuery.extend({}, current.aggregations || {});
                    if (!operator || operator == 'none') {
                        delete next[name];
                    } else {
                        next[name] = operator;
                    }
                    this._set_grouping_state(this._build_grouping_state(current, {
                        aggregations: next,
                    }));
                });
            });

            var display = jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section',
            }).appendTo(this.grouping_panel);
            jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-section-label',
                'text': Sao.i18n.gettext('Display'),
            }).appendTo(display);
            [
                ['showSubtotals', Sao.i18n.gettext('Show subtotals')],
                ['showGrandTotal', Sao.i18n.gettext('Show grand total')],
                ['expandAll', Sao.i18n.gettext('Expand all groups')],
            ].forEach(entry => {
                jQuery('<label/>', {
                    'class': 'ag-grid-tree-grouping-check',
                }).append(jQuery('<input/>', {
                    'type': 'checkbox',
                    'class': 'ag-grid-tree-panel-checkbox',
                    'checked': grouping[entry[0]],
                }).change(evt => {
                    var current = this._get_grouping_state();
                    var next = this._build_grouping_state(current, {});
                    next[entry[0]] = jQuery(evt.currentTarget).prop('checked');
                    if (entry[0] == 'expandAll' && next.expandAll) {
                        next.expandedPaths = [];
                    }
                    this._set_grouping_state(next);
                })).append(' ' + entry[1]).appendTo(display);
            });

            jQuery('<div/>', {
                'class': 'ag-grid-tree-grouping-actions',
            }).append(jQuery('<button/>', {
                'class': 'btn btn-default btn-sm',
                'type': 'button',
                'text': Sao.i18n.gettext('Reset grouping'),
            }).click(() => {
                this._set_grouping_state(this._get_default_grouping_state());
            })).appendTo(this.grouping_panel);
        },
        _render_column_menu: function() {
            if (!this.column_menu_dropdown) {
                return;
            }
            var visibility = this._get_column_visibility_map();
            this.column_menu_dropdown.empty();
            this._list_toggleable_columns().forEach(column => {
                var name = column.attributes.name;
                var checked = visibility[name];
                if (checked === undefined) {
                    checked = !Boolean(parseInt(
                        column.attributes.optional || '0', 10));
                }
                jQuery('<li/>', {
                    'role': 'presentation',
                }).append(jQuery('<div/>', {
                    'class': 'ag-grid-tree-column-toggle',
                }).append(jQuery('<input/>', {
                    'type': 'checkbox',
                    'class': 'ag-grid-tree-column-toggle-input ag-grid-tree-panel-checkbox',
                    'checked': checked,
                }).change(evt => {
                    this._set_column_visible(
                        name, jQuery(evt.currentTarget).prop('checked'));
                })).append(jQuery('<span/>', {
                    'class': 'ag-grid-tree-column-toggle-text',
                    'text': column.attributes.string || name,
                })))
                    .appendTo(this.column_menu_dropdown);
            });
        },
        _set_column_visible: function(col_id, visible) {
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !column_api.applyColumnState) {
                return;
            }
            this._syncing_grid = true;
            column_api.applyColumnState({
                state: [{
                    colId: col_id,
                    hide: !visible,
                }],
            });
            this._syncing_grid = false;
            this._mark_grid_state_dirty();
            this._schedule_grid_layout_refresh();
        },
        _sync_optional_columns_from_grid_state: function(column_states) {
            if (!column_states || !column_states.length) {
                return;
            }
            var current_fields = jQuery.extend(
                {}, Sao.Screen.tree_column_optional[this.view_id] || {});
            var fields = jQuery.extend({}, current_fields);
            var has_optional = false;
            this.optionals.forEach(column => {
                if (!column.attributes.name) {
                    return;
                }
                has_optional = true;
                var column_state = column_states.find(state =>
                    state.colId == column.attributes.name);
                fields[column.attributes.name] = column_state ?
                    Boolean(column_state.hide) : false;
            });
            if (!has_optional) {
                return;
            }
            if (Sao.common.compare(fields, current_fields)) {
                return;
            }
            Sao.Screen.tree_column_optional[this.view_id] = fields;
            var tree_optional_model = new Sao.Model('ir.ui.view_tree_optional');
            tree_optional_model.execute('set_optional', [
                this.view_id, fields], {})
                .fail(() => {
                    Sao.Logger.warn(
                        'Unable to set optional columns for %s',
                        this.screen.model_name);
                });
        },
        _init_grid: function() {
            var compact_density = this._is_compact_density();
            var row_height = compact_density ?
                this._density_px('--tradon-density-ag-row-height', 25) : 38;
            var header_height = compact_density ?
                this._density_px('--tradon-density-ag-header-height', 28) : 42;
            var grid_options = {
                icons: {
                    sortAscending: '<span class="ag-custom-icon ag-custom-icon-sort">▲</span>',
                    sortDescending: '<span class="ag-custom-icon ag-custom-icon-sort">▼</span>',
                    sortUnSort: '<span class="ag-custom-icon ag-custom-icon-sort">⇅</span>',
                    menu: '<span class="ag-custom-icon ag-custom-icon-menu">▾</span>',
                    menuAlt: '<span class="ag-custom-icon ag-custom-icon-menu">▾</span>',
                    filter: '<span class="ag-custom-icon ag-custom-icon-filter">F</span>',
                    columns: '<span class="ag-custom-icon ag-custom-icon-menu">≡</span>',
                    smallDown: '<span class="ag-custom-icon ag-custom-icon-menu">▾</span>',
                    smallUp: '<span class="ag-custom-icon ag-custom-icon-menu">▴</span>',
                    smallLeft: '<span class="ag-custom-icon ag-custom-icon-menu">◂</span>',
                    smallRight: '<span class="ag-custom-icon ag-custom-icon-menu">▸</span>',
                    selectOpen: '<span class="ag-custom-icon ag-custom-icon-menu">▾</span>',
                    groupExpanded: '<span class="ag-custom-icon ag-custom-icon-menu">▾</span>',
                    groupContracted: '<span class="ag-custom-icon ag-custom-icon-menu">▸</span>',
                    radioButtonOff: '<span class="ag-custom-icon ag-custom-icon-radio">○</span>',
                    radioButtonOn: '<span class="ag-custom-icon ag-custom-icon-radio">◉</span>',
                    checkboxUnchecked: '<span class="ag-custom-icon ag-custom-icon-check">☐</span>',
                    checkboxChecked: '<span class="ag-custom-icon ag-custom-icon-check">☑</span>',
                    checkboxIndeterminate: '<span class="ag-custom-icon ag-custom-icon-check">◩</span>',
                },
                defaultColDef: {
                    editable: false,
                    resizable: true,
                    sortable: false,
                    filter: true,
                    minWidth: 120,
                    floatingFilter: false,
                    suppressHeaderMenuButton: false,
                    wrapHeaderText: true,
                    autoHeaderHeight: true,
                },
                animateRows: false,
                suppressRowClickSelection: false,
                alwaysShowHorizontalScroll: true,
                alwaysShowVerticalScroll: true,
                rowMultiSelectWithClick: false,
                ensureDomOrder: true,
                rowHeight: row_height,
                headerHeight: header_height,
                isRowSelectable: params =>
                    Boolean(params.data && params.data.__kind == 'record'),
                isExternalFilterPresent: () =>
                    this._has_active_sao_value_filters(),
                doesExternalFilterPass: node =>
                    this._does_sao_value_filter_pass(node),
                getRowClass: params => {
                    if (!params.data) {
                        return '';
                    }
                    if (params.data.__kind == 'group') {
                        return 'ag-grid-group-row ag-grid-group-depth-' +
                            String(params.data.__groupDepth || 0);
                    }
                    if (params.data.__kind == 'total') {
                        return 'ag-grid-total-row';
                    }
                    return '';
                },
                popupParent: document.body,
                postProcessPopup: params => {
                    if (!params || !params.ePopup || !params.eventSource) {
                        return;
                    }
                    var popup_parent = this.grid_host && this.grid_host[0];
                    if (!popup_parent) {
                        return;
                    }
                    window.requestAnimationFrame(() => {
                        var parent_rect = popup_parent.getBoundingClientRect();
                        var source_rect = params.eventSource.getBoundingClientRect();
                        var popup_rect = params.ePopup.getBoundingClientRect();
                        var left = source_rect.left - parent_rect.left;
                        var top = source_rect.bottom - parent_rect.top + 2;
                        var max_left = Math.max(
                            0, popup_parent.clientWidth - popup_rect.width - 8);
                        left = Math.max(0, Math.min(left, max_left));
                        params.ePopup.style.left = left + 'px';
                        params.ePopup.style.top = top + 'px';
                    });
                },
                rowSelection: this.selection_mode == Sao.common.SELECTION_SINGLE ?
                    'single' : 'multiple',
                getRowId: params => String(params.data.__id),
                onRowClicked: this._on_row_clicked.bind(this),
                onRowDoubleClicked: this._on_row_double_clicked.bind(this),
                onSelectionChanged: this._on_selection_changed.bind(this),
                onSortChanged: this._on_sort_changed.bind(this),
                onColumnHeaderClicked:
                    this._on_column_header_clicked.bind(this),
                onFilterChanged: () => {
                    this._schedule_state_save();
                    this._sync_selection_header_checkbox();
                    this._update_summary_bar();
                    this._render_active_filter_chips();
                },
                onFirstDataRendered: () => {
                    window.requestAnimationFrame(() => this._update_summary_bar());
                },
                onRowDataUpdated: () => {
                    window.requestAnimationFrame(() => this._update_summary_bar());
                },
                onColumnMoved: this._on_grid_column_layout_changed.bind(this),
                onColumnPinned: this._on_grid_column_layout_changed.bind(this),
                onColumnResized: this._on_grid_column_resized.bind(this),
                onColumnVisible: this._on_grid_column_layout_changed.bind(this),
                columnDefs: [],
                rowData: [],
            };

            var menu_icon = '<span class="ag-custom-icon ag-custom-icon-menu">' +
                '<svg viewBox="0 0 16 16" aria-hidden="true" focusable="false">' +
                '<path d="M2 4.25h12M4.5 8h7M7 11.75h2" />' +
                '</svg></span>';
            grid_options.icons = {
                sortAscending: '<span class="ag-custom-icon ag-custom-icon-sort">&#9650;</span>',
                sortDescending: '<span class="ag-custom-icon ag-custom-icon-sort">&#9660;</span>',
                sortUnSort: '',
                menu: menu_icon,
                menuAlt: menu_icon,
                filter: '',
                columns: menu_icon,
                smallDown: '<span class="ag-custom-icon ag-custom-icon-menu">&#9662;</span>',
                smallUp: '<span class="ag-custom-icon ag-custom-icon-menu">&#9652;</span>',
                smallLeft: '<span class="ag-custom-icon ag-custom-icon-menu">&#9666;</span>',
                smallRight: '<span class="ag-custom-icon ag-custom-icon-menu">&#9656;</span>',
                selectOpen: '<span class="ag-custom-icon ag-custom-icon-menu">&#9662;</span>',
                groupExpanded: '<span class="ag-custom-icon ag-custom-icon-menu">&#9662;</span>',
                groupContracted: '<span class="ag-custom-icon ag-custom-icon-menu">&#9656;</span>',
                radioButtonOff: '<span class="ag-custom-icon ag-custom-icon-radio">&#9675;</span>',
                radioButtonOn: '<span class="ag-custom-icon ag-custom-icon-radio">&#9673;</span>',
                checkboxUnchecked: '<span class="ag-custom-icon ag-custom-icon-check">&#9744;</span>',
                checkboxChecked: '<span class="ag-custom-icon ag-custom-icon-check">&#9745;</span>',
                checkboxIndeterminate: '<span class="ag-custom-icon ag-custom-icon-check">&#9705;</span>',
            };
            grid_options.postProcessPopup = params => {
                if (!params || !params.ePopup || !params.eventSource) {
                    return;
                }
                if (!params.eventSource.closest ||
                        !params.eventSource.closest('.ag-header-cell-menu-button')) {
                    return;
                }
                var popup_parent = this.grid_host && this.grid_host[0];
                if (!popup_parent) {
                    return;
                }
                var header_cell = params.eventSource.closest('.ag-header-cell');
                if (!header_cell) {
                    return;
                }
                window.requestAnimationFrame(() => {
                    var source_rect = header_cell.getBoundingClientRect();
                    var popup_rect = params.ePopup.getBoundingClientRect();
                    var left = source_rect.left + window.scrollX;
                    var top = source_rect.bottom + window.scrollY + 2;
                    var max_left = Math.max(
                        0, window.scrollX + document.documentElement.clientWidth -
                            popup_rect.width - 8);
                    left = Math.max(window.scrollX, Math.min(left, max_left));
                    params.ePopup.style.position = 'absolute';
                    params.ePopup.style.left = left + 'px';
                    params.ePopup.style.top = top + 'px';
                    this._install_filter_radio_bridge(
                        params.ePopup.closest ? params.ePopup.closest('.ag-popup') ||
                            params.ePopup : params.ePopup);
                });
            };

            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                delete grid_options.rowSelection;
                grid_options.suppressRowClickSelection = true;
            }

            if (window.agGrid && typeof window.agGrid.createGrid == 'function') {
                this.gridApi = window.agGrid.createGrid(
                    this.grid_host[0], grid_options);
                this.gridColumnApi = this.gridApi;
            } else if (window.agGrid && typeof window.agGrid.Grid == 'function') {
                new window.agGrid.Grid(this.grid_host[0], grid_options);
                this.gridApi = grid_options.api;
                this.gridColumnApi = grid_options.columnApi || grid_options.api;
            }
        },
        _build_column_defs: function() {
            var tree_column_optional = (
                Sao.Screen.tree_column_optional[this.view_id] || {});
            var column_defs = [];
            var grouping_active = this._is_grouping_active();
            if (this.selection_mode != Sao.common.SELECTION_NONE) {
                column_defs.push({
                    colId: '__selection__',
                    headerName: '',
                    width: 52,
                    minWidth: 52,
                    maxWidth: 52,
                    pinned: 'left',
                    lockPosition: true,
                    suppressMovable: true,
                    suppressHeaderMenuButton: true,
                    sortable: false,
                    resizable: false,
                    filter: false,
                    headerComponent: this._get_selection_header_component(),
                    cellRenderer: this._create_selection_cell_renderer.bind(this),
                });
            }
            this.columns.forEach((column, index) => {
                var name = column.attributes.name || ('__column_' + index);
                var optional = false;
                if (column.attributes.name &&
                        Object.prototype.hasOwnProperty.call(
                            tree_column_optional, column.attributes.name)) {
                    optional = tree_column_optional[column.attributes.name];
                } else if (column.attributes.optional) {
                    optional = Boolean(parseInt(
                        column.attributes.optional || '0', 10));
                }
                var invisible = false;
                if (column.attributes.tree_invisible) {
                    try {
                        invisible = Boolean(new Sao.PYSON.Decoder(
                            this.screen.context).decode(
                                column.attributes.tree_invisible));
                    } catch (error) {
                        Sao.Logger.warn(
                            'Unable to decode tree_invisible for %s',
                            name);
                    }
                }
                var is_hidden = invisible || optional ||
                    (column.attributes.name === this.screen.exclude_field);
                var default_width = this._get_default_column_width(column);
                var right_aligned = this._is_right_aligned_column(column);
                var badge_column = this._is_badge_column(column);
                var sort_comparator = this._get_column_sort_comparator(column);
                var col_def = {
                    colId: name,
                    field: name,
                    headerName: column.attributes.string || '',
                    type: right_aligned ? 'rightAligned' : null,
                    cellClass: badge_column ? 'ag-grid-badge-column-cell' :
                        (right_aligned ?
                            'ag-grid-right-aligned-cell ag-right-aligned-cell' : null),
                    cellStyle: right_aligned && !badge_column ?
                        {textAlign: 'right'} : null,
                    headerClass: badge_column ? 'ag-grid-badge-column-header' :
                        (right_aligned ?
                            'ag-grid-right-aligned-header ag-right-aligned-header' :
                            null),
                    sortable: Boolean(column.sortable),
                    comparator: sort_comparator,
                    filter: grouping_active ? false : this._get_column_filter(column),
                    filterParams: grouping_active ? null :
                        this._get_column_filter_params(column),
                    valueGetter: params => {
                        if (!params.data || !params.data.__record) {
                            if (params.data && params.data.__aggregates &&
                                    params.data.__aggregates[name]) {
                                return params.data.__aggregates[name].text;
                            }
                            return '';
                        }
                        return this._get_column_text_value(
                            column, params.data.__record);
                    },
                    filterValueGetter: params => {
                        if (!params.data || !params.data.__record) {
                            if (params.data && params.data.__groupColumn == name) {
                                return params.data.__groupLabel || '';
                            }
                            return '';
                        }
                        if (this._is_date_filter_column(column)) {
                            return this._get_column_date_filter_value(
                                column, params.data.__record);
                        }
                        return this._get_column_text_value(
                            column, params.data.__record);
                    },
                    hide: is_hidden,
                    minWidth: this._get_minimum_column_width(column),
                    resizable: true,
                    width: column.attributes.width ?
                        Math.max(parseInt(column.attributes.width, 10),
                            default_width) : default_width,
                    cellRenderer: params => {
                        if (!params.data) {
                            return '';
                        }
                        if (params.data.__kind && params.data.__kind != 'record') {
                            return this._render_special_row_cell(
                                column, params.data);
                        }
                        if (!params.data.__record) {
                            return '';
                        }
                        var rendered;
                        if (badge_column) {
                            rendered = this._render_badge_cell(
                                column, params.data.__record);
                        } else {
                            rendered = this._render_cell(
                                column, params.data.__record);
                        }
                        return this._make_filter_draggable_cell(
                            rendered, column, params.data.__record);
                    },
                    tooltipValueGetter: params => {
                        if (!params.data || !params.data.__record ||
                                !column.attributes.name) {
                            return '';
                        }
                        try {
                            return this._get_column_text_value(
                                column, params.data.__record);
                        } catch (error) {
                            return '';
                        }
                    },
                };
                this._debug_sort_column_definition(column, col_def);

                column_defs.push(col_def);
            });
            return column_defs;
        },
        _is_right_aligned_column: function(column) {
            if (!column || !column.attributes) {
                return false;
            }
            if (column instanceof Sao.View.Tree.IntegerColumn ||
                    column instanceof Sao.View.Tree.FloatColumn) {
                return true;
            }
            if (~['integer', 'float', 'numeric'].indexOf(
                    column.attributes.widget || '')) {
                return true;
            }
            var name = column.attributes.name || '';
            var label = column.attributes.string || name;
            return /amount|quantity|qty|price|premium|discount|tax|debit|credit|balance|solde|total/i.test(
                name + ' ' + label);
        },
        _debug_sort_enabled: function() {
            return true;
        },
        _debug_sort_column_payload: function(column) {
            var name = column && column.attributes && column.attributes.name || '';
            var field = name && this.screen && this.screen.model &&
                this.screen.model.fields[name];
            return {
                model: this.screen && this.screen.model_name,
                name: name,
                label: column && column.attributes &&
                    (column.attributes.string || '') || '',
                sortable: Boolean(column && column.sortable),
                serverSortable: column && column.server_sortable,
                widget: column && column.attributes &&
                    (column.attributes.widget || '') || '',
                type: field && field.description && field.description.type || '',
                searchable: field && field.description &&
                    field.description.searchable,
                sortableAttr: column && column.attributes &&
                    column.attributes.sortable,
            };
        },
        _debug_sort_column_definition: function(column, col_def) {
            if (!this._debug_sort_enabled() || !column || !col_def ||
                    !col_def.sortable) {
                return;
            }
            console.info('[AGGRID][SORT_DEBUG][COLUMN_DEF]', JSON.stringify(
                Object.assign(this._debug_sort_column_payload(column), {
                    colId: col_def.colId,
                    field: col_def.field,
                    headerName: col_def.headerName,
                    hasComparator: Boolean(col_def.comparator),
                    hasValueGetter: Boolean(col_def.valueGetter),
                    serverSortable: column && column.server_sortable,
                    filterType: col_def.filter || null,
                    hidden: Boolean(col_def.hide),
                })));
        },
        _debug_sort_value_payload: function(column, record, value) {
            return Object.assign(this._debug_sort_column_payload(column), {
                recordId: record && record.id,
                value: value,
                valueType: value === null ? 'null' : typeof value,
                text: record ? this._get_column_text_value(column, record) : null,
                raw: record ? this._get_column_raw_value(column, record) : null,
            });
        },
        _get_column_sort_value: function(column, record) {
            if (!column || !record || !column.attributes.name) {
                return null;
            }
            var field = this.screen.model.fields[column.attributes.name];
            var type = field && field.description && field.description.type;
            var value;
            switch (type) {
            case 'integer':
            case 'float':
            case 'numeric':
            case 'timedelta':
                value = this._get_column_raw_value(column, record);
                return value === null ? this._get_text_number_sort_value(
                    column, record) : (isNaN(value) ?
                        this._get_text_number_sort_value(column, record) : value);
            case 'date':
            case 'datetime':
                var date = this._get_column_date_filter_value(column, record);
                return date ? date.getTime() : this._get_text_date_sort_value(
                    column, record);
            default:
                return this._get_column_text_value(column, record);
            }
        },
        _get_text_number_sort_value: function(column, record) {
            var text = this._get_column_text_value(column, record);
            if (!text) {
                return null;
            }
            text = String(text).trim().replace(/\s|\u00a0/g, '');
            if (!text) {
                return null;
            }
            var last_comma = text.lastIndexOf(',');
            var last_dot = text.lastIndexOf('.');
            if (last_comma > -1 && last_dot > -1) {
                if (last_comma > last_dot) {
                    text = text.replace(/\./g, '').replace(',', '.');
                } else {
                    text = text.replace(/,/g, '');
                }
            } else if (last_comma > -1) {
                text = text.replace(',', '.');
            }
            text = text.replace(/[^0-9+\-.eE]/g, '');
            var value = Number(text);
            return isNaN(value) ? null : value;
        },
        _get_text_date_sort_value: function(column, record) {
            var text = this._get_column_text_value(column, record);
            if (!text) {
                return null;
            }
            text = String(text).trim();
            if (!text) {
                return null;
            }
            if (window.moment) {
                var parsed = window.moment(text, [
                    'L',
                    'DD/MM/YYYY',
                    'D/M/YYYY',
                    'YYYY-MM-DD',
                    'YYYY-MM-DD HH:mm:ss',
                ], true);
                if (parsed.isValid()) {
                    return parsed.toDate().getTime();
                }
            }
            var native_date = new Date(text);
            var value = native_date.getTime();
            return isNaN(value) ? text : value;
        },
        _compare_column_sort_values: function(a, b) {
            var empty_a = a === null || a === undefined || a === '';
            var empty_b = b === null || b === undefined || b === '';
            if (empty_a && empty_b) {
                return 0;
            }
            if (empty_a) {
                return 1;
            }
            if (empty_b) {
                return -1;
            }
            if (typeof a == 'number' && typeof b == 'number') {
                return a - b;
            }
            return String(a).localeCompare(String(b), undefined, {
                numeric: true,
                sensitivity: 'base',
            });
        },
        _get_column_sort_comparator: function(column) {
            if (!column || !column.attributes || !column.attributes.name) {
                return null;
            }
            return (value_a, value_b, node_a, node_b) => {
                if (this._is_grouping_active()) {
                    var index_a = node_a && node_a.data &&
                        node_a.data.__sortIndex;
                    var index_b = node_b && node_b.data &&
                        node_b.data.__sortIndex;
                    if (typeof index_a == 'number' &&
                            typeof index_b == 'number') {
                        var direction = 1;
                        var order = this.screen && this.screen.order || [];
                        if (order.length && order[0][0] == column.attributes.name &&
                                String(order[0][1] || '').toUpperCase() == 'DESC') {
                            direction = -1;
                        }
                        return (index_a - index_b) * direction;
                    }
                    return 0;
                }
                var record_a = node_a && node_a.data && node_a.data.__record;
                var record_b = node_b && node_b.data && node_b.data.__record;
                if (!record_a || !record_b) {
                    return this._compare_column_sort_values(value_a, value_b);
                }
                var sort_value_a = this._get_column_sort_value(column, record_a);
                var sort_value_b = this._get_column_sort_value(column, record_b);
                var result = this._compare_column_sort_values(
                    sort_value_a, sort_value_b);
                if (this._debug_sort_enabled()) {
                    var col_id = column.attributes.name;
                    this._sort_debug_compare_count =
                        this._sort_debug_compare_count || {};
                    var count = this._sort_debug_compare_count[col_id] || 0;
                    if (count < 8) {
                        this._sort_debug_compare_count[col_id] = count + 1;
                        console.info('[AGGRID][SORT_DEBUG][COMPARE]',
                            JSON.stringify({
                                column: this._debug_sort_column_payload(column),
                                a: this._debug_sort_value_payload(
                                    column, record_a, sort_value_a),
                                b: this._debug_sort_value_payload(
                                    column, record_b, sort_value_b),
                                result: result,
                            }));
                    }
                }
                return result;
            };
        },
        _get_record_sort_order: function() {
            var order = this.screen && this.screen.order || [];
            return (order || []).map(item => {
                var column = this._find_column_by_id(item[0]);
                if (!column) {
                    return null;
                }
                return {
                    column: column,
                    direction: String(item[1] || 'ASC').toUpperCase() == 'DESC' ?
                        -1 : 1,
                };
            }).filter(Boolean);
        },
        _is_server_sortable_column_id: function(col_id) {
            var column = this._find_column_by_id(col_id);
            if (!column) {
                return true;
            }
            return column.server_sortable !== false;
        },
        _sort_records_for_current_order: function(records) {
            var sort_order = this._get_record_sort_order();
            if (!sort_order.length) {
                return records.slice();
            }
            return records.slice().sort((record_a, record_b) => {
                for (const item of sort_order) {
                    var result = this._compare_column_sort_values(
                        this._get_column_sort_value(item.column, record_a),
                        this._get_column_sort_value(item.column, record_b));
                    if (result) {
                        return result * item.direction;
                    }
                }
                return String(record_a.id).localeCompare(String(record_b.id),
                    undefined, {numeric: true});
            });
        },
        _assign_row_sort_indices: function(rows) {
            rows.forEach((row, index) => {
                row.__sortIndex = index;
            });
            return rows;
        },
        _get_default_column_width: function(column) {
            var name = column && column.attributes && column.attributes.name || '';
            var label = column && column.attributes &&
                (column.attributes.string || column.attributes.name) || '';
            if (~['supplier', 'client', 'product'].indexOf(name) ||
                    ~['r_supplier', 'r_client', 'r_product'].indexOf(name)) {
                return 320;
            }
            if (~['purchase', 'sale', 'invoice', 'sale_invoice',
                    'purchase_invoice'].indexOf(name) ||
                    /purchase|sale|invoice|supplier|client|product/i.test(label)) {
                return 220;
            }
            if (/period|delivery|shipment|location|strategy|dimension/i.test(label)) {
                return 180;
            }
            if (/amount|quantity|price|premium|discount|tax|state|type/i.test(label)) {
                return 150;
            }
            return 140;
        },
        _get_minimum_column_width: function(column) {
            if (!column || !column.attributes) {
                return 80;
            }
            if (column instanceof Sao.View.Tree.ButtonColumn) {
                return 52;
            }
            switch (column.attributes.widget) {
            case 'integer':
            case 'float':
            case 'numeric':
                return 90;
            case 'boolean':
                return 72;
            default:
                return 80;
            }
        },
        _create_selection_cell_renderer: function(params) {
            if (!params.data || params.data.__kind != 'record') {
                return document.createTextNode('');
            }
            var checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.className = 'ag-custom-checkbox';
            checkbox.checked = Boolean(params.node && params.node.isSelected());

            var sync_checked = () => {
                checkbox.checked = Boolean(params.node && params.node.isSelected());
            };

            checkbox.addEventListener('click', event => {
                event.stopPropagation();
            });
            checkbox.addEventListener('change', event => {
                event.stopPropagation();
                this._syncing_grid = true;
                params.node.setSelected(checkbox.checked);
                this._syncing_grid = false;
                this._on_selection_changed();
            });

            if (params.node && params.node.addEventListener) {
                params.node.addEventListener('rowSelected', sync_checked);
            }
            return checkbox;
        },
        _get_selection_header_component: function() {
            var tree = this;
            var SelectionHeader = function() {};
            SelectionHeader.prototype.init = function() {
                this.gui = tree._create_selection_header_renderer();
            };
            SelectionHeader.prototype.getGui = function() {
                return this.gui;
            };
            SelectionHeader.prototype.destroy = function() {
                if (tree._selection_header_checkbox === this.gui) {
                    tree._selection_header_checkbox = null;
                }
                this.gui = null;
            };
            return SelectionHeader;
        },
        _create_selection_header_renderer: function() {
            if (this.selection_mode != Sao.common.SELECTION_MULTIPLE) {
                return document.createTextNode('');
            }
            var checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.className = 'ag-custom-checkbox ag-custom-header-checkbox';
            checkbox.title = Sao.i18n.gettext('Select all');
            checkbox.setAttribute('aria-label', Sao.i18n.gettext('Select all'));
            checkbox.addEventListener('click', event => {
                event.stopPropagation();
            });
            checkbox.addEventListener('change', event => {
                event.stopPropagation();
                this._set_visible_records_selection(checkbox.checked);
            });
            this._selection_header_checkbox = checkbox;
            window.requestAnimationFrame(() => {
                this._sync_selection_header_checkbox();
            });
            return checkbox;
        },
        _get_selectable_grid_nodes: function() {
            var nodes = [];
            if (!this.gridApi) {
                return nodes;
            }
            var collect = node => {
                if (node && node.data && node.data.__kind == 'record' &&
                        node.data.__record) {
                    nodes.push(node);
                }
            };
            if (this.gridApi.forEachNodeAfterFilter) {
                this.gridApi.forEachNodeAfterFilter(collect);
            } else {
                this.gridApi.forEachNode(collect);
            }
            return nodes;
        },
        _set_visible_records_selection: function(value) {
            if (this.selection_mode != Sao.common.SELECTION_MULTIPLE ||
                    !this.gridApi) {
                return;
            }
            var nodes = this._get_selectable_grid_nodes();
            this._syncing_grid = true;
            try {
                nodes.forEach(node => {
                    node.setSelected(Boolean(value));
                });
            } finally {
                this._syncing_grid = false;
            }
            this._on_selection_changed();
            this._sync_selection_header_checkbox();
        },
        _sync_selection_header_checkbox: function() {
            var checkbox = this._selection_header_checkbox;
            if (!checkbox ||
                    this.selection_mode != Sao.common.SELECTION_MULTIPLE) {
                return;
            }
            var nodes = this._get_selectable_grid_nodes();
            var selected_count = nodes.filter(node => node.isSelected()).length;
            checkbox.disabled = !nodes.length;
            checkbox.checked = Boolean(
                nodes.length && selected_count == nodes.length);
            checkbox.indeterminate = Boolean(
                selected_count && selected_count < nodes.length);
        },
        _get_column_filter: function(column) {
            if (column instanceof Sao.View.Tree.ButtonColumn) {
                return false;
            }
            switch (column.attributes.widget) {
            case 'integer':
            case 'float':
            case 'numeric':
                return 'agNumberColumnFilter';
            case 'date':
                return 'agDateColumnFilter';
            default:
                if (this._get_distinct_filter_values(column)) {
                    return Sao.View.AGGridValueSetFilter;
                }
                return 'agTextColumnFilter';
            }
        },
        _get_column_filter_params: function(column) {
            if (this._is_date_filter_column(column)) {
                return this._get_date_column_filter_params(column);
            }
            var values = this._get_distinct_filter_values(column);
            if (!values) {
                return null;
            }
            var name = column.attributes.name;
            return {
                colId: name,
                values: values,
                ownerDebugId: this._debug_id,
                trace: this._trace_aggrid_value_filter_component.bind(this),
                getLiveValues: () => this._get_live_filter_values(column, name),
                getNodeValue: node => this._get_filter_node_value(column, name, node),
                getSavedValueFilter: () => this._get_sao_value_filter_state(name),
                onSelectionChanged: this._set_sao_value_filter.bind(this),
                emptyLabel: Sao.i18n.gettext('(Empty)'),
                searchPlaceholder: Sao.i18n.gettext('Search'),
            };
        },
        _is_date_filter_column: function(column) {
            return Boolean(column && column.attributes &&
                column.attributes.widget == 'date');
        },
        _get_date_column_filter_params: function(column) {
            var relative_options = this._get_relative_date_filter_options();
            return {
                browserDatePicker: true,
                buttons: ['reset', 'apply'],
                closeOnApply: true,
                maxNumConditions: 1,
                defaultOption: 'empty',
                filterOptions: ['empty'].concat(relative_options).concat([
                    'inRange',
                    'equals',
                    'lessThan',
                    'greaterThan',
                    'blank',
                    'notBlank',
                ]),
                inRangeInclusive: true,
                comparator: function(filter_date, cell_value) {
                    if (!cell_value) {
                        return 0;
                    }
                    var cell_date = cell_value instanceof Date ? cell_value :
                        new Date(cell_value);
                    if (isNaN(cell_date.getTime())) {
                        return 0;
                    }
                    var cell_midnight = new Date(
                        cell_date.getFullYear(),
                        cell_date.getMonth(),
                        cell_date.getDate());
                    if (cell_midnight < filter_date) {
                        return -1;
                    }
                    if (cell_midnight > filter_date) {
                        return 1;
                    }
                    return 0;
                },
            };
        },
        _get_relative_date_filter_options: function() {
            var make_option = (key, label, range_getter) => {
                return {
                    displayKey: 'sao' + key,
                    displayName: Sao.i18n.gettext(label),
                    numberOfInputs: 0,
                    predicate: (filter_values, cell_value) => {
                        var cell_date = this._normalize_date_filter_value(cell_value);
                        if (!cell_date) {
                            return false;
                        }
                        var range = range_getter();
                        return cell_date >= range.start && cell_date < range.end;
                    },
                };
            };
            return [
                make_option('Today', 'Today', () => {
                    var start = this._start_of_today();
                    return {
                        start: start,
                        end: this._add_days(start, 1),
                    };
                }),
                make_option('Last7Days', 'Last 7 Days', () => {
                    var today = this._start_of_today();
                    return {
                        start: this._add_days(today, -7),
                        end: this._add_days(today, 1),
                    };
                }),
                make_option('Last30Days', 'Last 30 Days', () => {
                    var today = this._start_of_today();
                    return {
                        start: this._add_days(today, -30),
                        end: this._add_days(today, 1),
                    };
                }),
                make_option('LastMonth', 'Last Month', () => {
                    var today = this._start_of_today();
                    var start = new Date(
                        today.getFullYear(), today.getMonth() - 1, 1);
                    var end = new Date(today.getFullYear(), today.getMonth(), 1);
                    return {
                        start: start,
                        end: end,
                    };
                }),
                make_option('ThisMonth', 'This Month', () => {
                    var today = this._start_of_today();
                    var start = new Date(today.getFullYear(), today.getMonth(), 1);
                    var end = new Date(
                        today.getFullYear(), today.getMonth() + 1, 1);
                    return {
                        start: start,
                        end: end,
                    };
                }),
                make_option('YearToDate', 'Year to Date', () => {
                    var today = this._start_of_today();
                    return {
                        start: new Date(today.getFullYear(), 0, 1),
                        end: this._add_days(today, 1),
                    };
                }),
            ];
        },
        _start_of_today: function() {
            var now = new Date();
            return new Date(now.getFullYear(), now.getMonth(), now.getDate());
        },
        _add_days: function(date, days) {
            return new Date(
                date.getFullYear(), date.getMonth(), date.getDate() + days);
        },
        _normalize_date_filter_value: function(value) {
            if (!value) {
                return null;
            }
            var date = value instanceof Date ? value : new Date(value);
            if (isNaN(date.getTime())) {
                return null;
            }
            return new Date(date.getFullYear(), date.getMonth(), date.getDate());
        },
        _get_sao_value_filter_state: function(col_id) {
            var filter = this._sao_value_filters &&
                this._sao_value_filters[col_id];
            if (filter && filter.selected) {
                return {
                    selected: Array.from(filter.selected || []).sort(),
                    all: Array.from(filter.all || []).sort(),
                };
            }
            var restored = this._sao_value_filter_restore_guard &&
                this._restored_value_filter_state &&
                this._restored_value_filter_state.filters &&
                this._restored_value_filter_state.filters[col_id];
            if (restored && restored.selected) {
                return jQuery.extend(true, {}, restored);
            }
            var persisted = this._persisted_state &&
                this._persisted_state.valueFilters &&
                this._persisted_state.valueFilters.filters &&
                this._persisted_state.valueFilters.filters[col_id];
            return persisted && persisted.selected ?
                jQuery.extend(true, {}, persisted) : null;
        },
        _is_sao_value_filter_user_source: function(source) {
            return ~['checkbox-change', 'all-button', 'none-button',
                'clear-button', 'drag-drop'].indexOf(source);
        },
        _make_filter_draggable_cell: function(element, column, record) {
            if (!element || !column || !column.attributes ||
                    !column.attributes.name || !record) {
                return element;
            }
            var value = this._get_column_text_value(column, record);
            if (!value) {
                return element;
            }
            var wrapped = jQuery(element);
            wrapped.attr('draggable', 'true')
                .addClass('ag-grid-filter-draggable-cell')
                .on('dragstart', evt => {
                    var payload = {
                        colId: column.attributes.name,
                        label: this._get_column_label(column.attributes.name),
                        value: String(value),
                    };
                    this._filter_drag_payload = payload;
                    evt.originalEvent.dataTransfer.effectAllowed = 'copy';
                    evt.originalEvent.dataTransfer.setData(
                        'application/x-sao-ag-grid-filter',
                        JSON.stringify(payload));
                    evt.originalEvent.dataTransfer.setData(
                        'text/plain',
                        payload.label + ' = ' + payload.value);
                    this._set_filter_dropzone_active(true, payload);
                })
                .on('dragend', () => {
                    this._filter_drag_payload = null;
                    this._set_filter_dropzone_active(false);
                });
            return wrapped[0];
        },
        _set_filter_dropzone_active: function(active, payload) {
            if (!this.active_filter_chips) {
                return;
            }
            this.active_filter_chips
                .toggleClass('drag-active', Boolean(active))
                .removeClass('drag-over');
            if (active && !this._get_active_filter_chip_entries().length) {
                this.active_filter_chips.empty();
                jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-drop-hint',
                    'text': payload ?
                        Sao.i18n.gettext('Drop to filter %1')
                            .replace('%1', payload.label) :
                        Sao.i18n.gettext('Drop to filter'),
                }).appendTo(this.active_filter_chips);
                this.active_filter_chips.css('display', 'inline-flex');
            } else if (!active) {
                this._render_active_filter_chips();
            }
        },
        _get_filter_drop_payload: function(event) {
            var payload = this._filter_drag_payload;
            if (!payload && event && event.dataTransfer) {
                try {
                    payload = JSON.parse(event.dataTransfer.getData(
                        'application/x-sao-ag-grid-filter') || 'null');
                } catch (error) {
                    payload = null;
                }
            }
            if (!payload || !payload.colId || !payload.value) {
                return null;
            }
            return payload;
        },
        _apply_filter_drop: function(event) {
            var payload = this._get_filter_drop_payload(event);
            this._filter_drag_payload = null;
            if (!payload) {
                this._set_filter_dropzone_active(false);
                return;
            }
            var column = this._find_column_by_id(payload.colId);
            if (!column) {
                this._set_filter_dropzone_active(false);
                return;
            }
            var empty = Sao.i18n.gettext('(Empty)');
            var value = String(payload.value || '') || empty;
            var all_values = this._get_live_filter_values(
                column, payload.colId).map(item => String(item || empty));
            if (!~all_values.indexOf(value)) {
                all_values.push(value);
            }
            var existing = this._sao_value_filters &&
                this._sao_value_filters[payload.colId];
            var selected = existing && existing.selected ?
                Array.from(existing.selected).map(item => String(item)) : [];
            if (!selected.length) {
                selected = [value];
            } else if (!~selected.indexOf(value)) {
                selected.push(value);
            }
            this._set_sao_value_filter(
                payload.colId,
                selected,
                all_values,
                'drag-drop',
                {value: value});
            this._set_filter_dropzone_active(false);
        },
        _forget_sao_value_filter_saved_state: function(col_id) {
            var remove_filter = value_filters => {
                if (!value_filters || !value_filters.filters ||
                        !Object.prototype.hasOwnProperty.call(
                            value_filters.filters, col_id)) {
                    return false;
                }
                delete value_filters.filters[col_id];
                return true;
            };
            remove_filter(this._restored_value_filter_state);
            remove_filter(this._persisted_state &&
                this._persisted_state.valueFilters);
            remove_filter(this._layout_store &&
                this._layout_store.currentState &&
                this._layout_store.currentState.valueFilters);
            if (this._restored_value_filter_state &&
                    this._restored_value_filter_state.filters &&
                    !Object.keys(
                        this._restored_value_filter_state.filters).length) {
                this._restored_value_filter_state = null;
                this._sao_value_filter_restore_guard = false;
            }
        },
        _set_sao_value_filter: function(col_id, selected_values, all_values, source, meta) {
            var selected = new Set((selected_values || []).map(value => String(value)));
            var all = new Set((all_values || []).map(value => String(value)));
            var user_source = this._is_sao_value_filter_user_source(source);
            var existing = this._sao_value_filters &&
                this._sao_value_filters[col_id];
            var saved = this._get_sao_value_filter_state(col_id);
            if (!user_source && !selected.size &&
                    saved && saved.selected && saved.selected.length &&
                    !existing) {
                this._trace_sao_value_filter_change(
                    'ignore_initial_none_button_empty_selection',
                    col_id, selected, all, source, existing, saved, meta);
                return;
            }
            if (!user_source && this._sao_value_filter_restore_guard &&
                    !selected.size &&
                    saved && saved.selected && saved.selected.length) {
                this._trace_sao_value_filter_change(
                    'ignore_restore_guard_empty_selection',
                    col_id, selected, all, source, existing, saved, meta);
                return;
            }
            if (!user_source && source != 'none-button' && !selected.size &&
                    ((existing && existing.selected && existing.selected.size) ||
                    (saved && saved.selected && saved.selected.length))) {
                this._trace_sao_value_filter_change(
                    'ignore_non_user_empty_selection',
                    col_id, selected, all, source, existing, saved, meta);
                return;
            }
            if (user_source) {
                this._forget_sao_value_filter_saved_state(col_id);
            }
            this._trace_sao_value_filter_change(
                'set_value_filter', col_id, selected, all,
                source || 'lifecycle', existing, saved, meta);
            if (!all.size || selected.size == all.size) {
                delete this._sao_value_filters[col_id];
            } else {
                this._sao_value_filters[col_id] = {
                    selected: selected,
                    all: all,
                };
            }
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._render_active_filter_chips();
            this._schedule_state_save();
        },
        _trace_sao_value_filter_change: function(reason, col_id, selected, all,
                source, existing, saved, meta) {
            if (!window.console) {
                return;
            }
            console.info('[AGGRID][VALUE_FILTER_CHANGE_JSON]',
                JSON.stringify({
                    reason: reason,
                    instanceId: this._debug_id,
                    source: source || 'lifecycle',
                    colId: col_id,
                    selected: Array.from(selected || []).sort(),
                    allCount: all ? all.size : 0,
                    existingSelected: existing && existing.selected ?
                        Array.from(existing.selected).sort() : null,
                    savedSelected: saved && saved.selected ?
                        saved.selected : null,
                    meta: meta || null,
                    destroyed: Boolean(this._destroyed),
                    hostConnected: Boolean(
                        this.grid_host && this.grid_host[0] &&
                        this.grid_host[0].isConnected),
                }));
        },
        _apply_sao_value_filters: function() {
            var options = arguments.length ? arguments[0] || {} : {};
            if (!this.gridApi) {
                return;
            }
            if (options.refreshRowData !== false) {
                this._refresh_sao_value_filter_row_data();
            }
            if (this.gridApi.onFilterChanged) {
                this.gridApi.onFilterChanged();
            }
            window.requestAnimationFrame(() => {
                if (!this.gridApi) {
                    return;
                }
                if (this.gridApi.ensureIndexVisible &&
                        (!this.gridApi.getDisplayedRowCount ||
                        this.gridApi.getDisplayedRowCount() > 0)) {
                    this.gridApi.ensureIndexVisible(0, 'top');
                }
                if (this.gridApi.refreshCells) {
                    this.gridApi.refreshCells({force: true});
                }
                if (this.gridApi.getDisplayedRowCount &&
                        this.gridApi.getDisplayedRowCount() === 0 &&
                        this.group && this.group.length &&
                        this._has_active_sao_value_filters()) {
                    this._log_sao_value_filter_empty_result(this.group.slice());
                }
                this._resize_grid_host();
                this._update_summary_bar();
                this._schedule_grid_layout_refresh([0, 80]);
            });
        },
        _refresh_sao_value_filter_row_data: function() {
            if (!this.gridApi || !this.group) {
                return;
            }
            var value_filter_load = this._load_sao_value_filter_values();
            if (value_filter_load) {
                value_filter_load.then(
                    () => this._refresh_sao_value_filter_row_data());
                return;
            }
            var badge_load = this._load_badge_column_values();
            if (badge_load) {
                badge_load.then(
                    () => this._refresh_sao_value_filter_row_data());
                return;
            }
            this._reconcile_sao_value_filters_with_rows();
            this._sao_value_filter_empty_logged = false;
            var selected = this.get_selected_paths();
            var row_data = this._get_row_data();
            this._trace_aggrid_lifecycle('value-filter-row-data-refresh', {
                groupSize: this.group ? this.group.length : null,
                rowDataCount: row_data.length,
                activeValueFilters: this._describe_sao_value_filters(),
            });
            this._syncing_grid = true;
            try {
                if (this.gridApi.setGridOption) {
                    this.gridApi.setGridOption('rowData', row_data);
                } else {
                    this.gridApi.setRowData(row_data);
                }
            } finally {
                this._syncing_grid = false;
            }
            if (this.gridApi.onFilterChanged) {
                this.gridApi.onFilterChanged();
            }
            this._refresh_grid_layout_after_row_data(row_data);
            this._sync_selection(selected);
            this._update_copy_state();
            this._update_summary_bar();
        },
        _toggle_sao_value_filter_mode: function() {
            this._set_sao_value_filter_mode(
                this._sao_value_filter_mode == 'all' ? 'any' : 'all');
        },
        _set_sao_value_filter_mode: function(mode) {
            mode = mode == 'any' ? 'any' : 'all';
            if (this._sao_value_filter_mode == mode) {
                return;
            }
            this._sao_value_filter_mode = mode;
            this._update_sao_value_filter_mode_button();
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _new_sao_value_filter_group_id: function() {
            return 'filter_group_' + String(Date.now()) + '_' +
                String(Math.floor(Math.random() * 100000));
        },
        _set_sao_value_filter_group_mode: function(group_id, mode) {
            var logic = this._sao_value_filter_groups;
            if (!logic || !Array.isArray(logic.groups)) {
                return;
            }
            var group = logic.groups.find(item => item.id == group_id);
            if (!group) {
                return;
            }
            group.mode = mode == 'any' ? 'any' : 'all';
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _set_sao_value_filter_group_operator: function(mode) {
            if (!this._sao_value_filter_groups) {
                return;
            }
            this._sao_value_filter_groups.mode = mode == 'any' ? 'any' : 'all';
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _enable_sao_value_filter_groups: function() {
            var entries = this._get_active_sao_value_filter_entries();
            this._sao_value_filter_groups = {
                mode: 'any',
                groups: [{
                    id: this._new_sao_value_filter_group_id(),
                    mode: this._sao_value_filter_mode,
                    filters: entries.map(entry => entry.colId),
                }],
            };
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _disable_sao_value_filter_groups: function() {
            this._sao_value_filter_groups = null;
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _add_sao_value_filter_group: function() {
            if (!this._sao_value_filter_groups) {
                this._enable_sao_value_filter_groups();
                return;
            }
            this._sao_value_filter_groups.groups.push({
                id: this._new_sao_value_filter_group_id(),
                mode: 'all',
                filters: [],
            });
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _move_sao_value_filter_to_group: function(col_id, group_id) {
            if (!this._sao_value_filter_groups ||
                    !Array.isArray(this._sao_value_filter_groups.groups)) {
                return;
            }
            this._sao_value_filter_groups.groups.forEach(group => {
                group.filters = (group.filters || [])
                    .filter(filter_id => filter_id != col_id);
            });
            var target = this._sao_value_filter_groups.groups
                .find(group => group.id == group_id);
            if (target && !~(target.filters || []).indexOf(col_id)) {
                target.filters = target.filters || [];
                target.filters.push(col_id);
            }
            this._apply_sao_value_filters();
            this._render_filter_logic_panel();
            this._schedule_state_save();
        },
        _normalize_sao_value_filter_groups: function(active_filter_ids) {
            var logic = this._sao_value_filter_groups;
            if (!logic || !Array.isArray(logic.groups)) {
                return null;
            }
            var active = new Set(active_filter_ids || []);
            var assigned = new Set();
            logic.mode = logic.mode == 'any' ? 'any' : 'all';
            logic.groups = logic.groups.map(group => {
                group.mode = group.mode == 'any' ? 'any' : 'all';
                group.filters = (group.filters || []).filter(col_id => {
                    if (!active.has(col_id) || assigned.has(col_id)) {
                        return false;
                    }
                    assigned.add(col_id);
                    return true;
                });
                return group;
            });
            var first = logic.groups[0];
            if (!first) {
                first = {
                    id: this._new_sao_value_filter_group_id(),
                    mode: this._sao_value_filter_mode,
                    filters: [],
                };
                logic.groups.push(first);
            }
            active_filter_ids.forEach(col_id => {
                if (!assigned.has(col_id)) {
                    first.filters.push(col_id);
                    assigned.add(col_id);
                }
            });
            return logic;
        },
        _update_sao_value_filter_mode_button: function() {
            if (!this.value_filter_mode_button) {
                return;
            }
            var any = this._sao_value_filter_mode == 'any';
            this.value_filter_mode_button
                .toggleClass('active', any)
                .attr('title', Sao.i18n.gettext(any ?
                    'Rows matching at least one checklist filter' :
                    'Rows matching all checklist filters'));
            this.value_filter_mode_button
                .find('.ag-grid-value-filter-mode-label')
                .text(' ' + Sao.i18n.gettext(any ? 'Any filter' : 'All filters'));
        },
        _get_column_label: function(col_id) {
            var column = this._find_column_by_id(col_id);
            return column && column.attributes ?
                column.attributes.string || column.attributes.name || col_id :
                col_id;
        },
        _describe_grid_filter_model: function(model) {
            if (!model) {
                return {
                    operator: '',
                    value: '',
                };
            }
            var type = model.type || model.operator || '';
            var value = model.filter;
            if (value === undefined || value === null || value === '') {
                value = model.dateFrom || model.filterTo || '';
            }
            if (Array.isArray(model.values)) {
                return {
                    operator: '=',
                    value: model.values.length <= 2 ?
                    model.values.join(', ') :
                    Sao.i18n.gettext('%1 values').replace('%1', model.values.length),
                };
            }
            if (model.condition1 || model.condition2) {
                return {
                    operator: '',
                    value: Sao.i18n.gettext('complex'),
                };
            }
            var relative_date_labels = {
                saoToday: 'Today',
                saoLast7Days: 'Last 7 Days',
                saoLast30Days: 'Last 30 Days',
                saoLastMonth: 'Last Month',
                saoThisMonth: 'This Month',
                saoYearToDate: 'Year to Date',
            };
            if (relative_date_labels[type]) {
                return {
                    operator: '',
                    value: Sao.i18n.gettext(relative_date_labels[type]),
                };
            }
            var operators = {
                equals: '=',
                notEqual: '!=',
                contains: 'contains',
                notContains: 'not contains',
                startsWith: 'starts',
                endsWith: 'ends',
                lessThan: '<',
                lessThanOrEqual: '<=',
                greaterThan: '>',
                greaterThanOrEqual: '>=',
                inRange: 'between',
            };
            return {
                operator: operators[type] || type || '=',
                value: value,
            };
        },
        _get_active_filter_chip_entries: function() {
            var entries = [];
            Object.keys(this._sao_value_filters || {}).forEach(col_id => {
                var filter = this._sao_value_filters[col_id];
                if (!this._is_sao_value_filter_active(filter)) {
                    return;
                }
                var selected = Array.from(filter.selected || []).sort();
                var all = Array.from(filter.all || []).sort();
                var selected_lookup = new Set(selected);
                var excluded = all.filter(value => !selected_lookup.has(value));
                var display_values = excluded.length &&
                    excluded.length < selected.length ? excluded : selected;
                var operator = display_values === excluded ? '<>' : '=';
                var detail = display_values.length <= 2 ?
                    display_values.join(', ') :
                    Sao.i18n.gettext('%1 values').replace(
                        '%1', display_values.length);
                entries.push({
                    type: 'sao',
                    colId: col_id,
                    label: this._get_column_label(col_id),
                    operator: operator,
                    detail: detail,
                });
            });
            var filter_model = this.gridApi && this.gridApi.getFilterModel ?
                this._strip_sao_value_set_filter_model(
                    this.gridApi.getFilterModel()) : {};
            Object.keys(filter_model || {}).forEach(col_id => {
                var description =
                    this._describe_grid_filter_model(filter_model[col_id]);
                entries.push({
                    type: 'grid',
                    colId: col_id,
                    label: this._get_column_label(col_id),
                    operator: description.operator,
                    detail: description.value,
                });
            });
            return entries;
        },
        _open_filter_for_chip: function(entry) {
            if (entry.type == 'sao') {
                this._show_filter_logic_menu();
                return;
            }
            if (!this.gridApi) {
                return;
            }
            if (this.gridApi.showColumnFilter) {
                this.gridApi.showColumnFilter(entry.colId);
            } else if (this.gridApi.showColumnMenu) {
                this.gridApi.showColumnMenu(entry.colId);
            }
        },
        _remove_filter_chip: function(entry) {
            if (entry.type == 'sao') {
                delete this._sao_value_filters[entry.colId];
                this._forget_sao_value_filter_saved_state(entry.colId);
                this._apply_sao_value_filters();
                this._render_filter_logic_panel();
                this._schedule_state_save();
                this._render_active_filter_chips();
                return;
            }
            if (!this.gridApi || !this.gridApi.getFilterModel ||
                    !this.gridApi.setFilterModel) {
                return;
            }
            var model = jQuery.extend(true, {}, this.gridApi.getFilterModel() || {});
            delete model[entry.colId];
            this.gridApi.setFilterModel(model);
            if (this.gridApi.onFilterChanged) {
                this.gridApi.onFilterChanged();
            }
        },
        _render_active_filter_chips: function() {
            if (!this.active_filter_chips) {
                return;
            }
            var entries = this._get_active_filter_chip_entries();
            this.active_filter_chips.empty();
            if (!entries.length) {
                this.active_filter_chips.hide();
                return;
            }
            var visible = entries.slice(0, 4);
            visible.forEach(entry => {
                var text = entry.label + (entry.operator ? ' ' +
                    entry.operator : '') + (entry.detail ? ' ' +
                    entry.detail : '');
                var chip = jQuery('<button/>', {
                    'class': 'ag-grid-active-filter-chip',
                    'type': 'button',
                    'title': text,
                }).click(evt => {
                    evt.preventDefault();
                    this._open_filter_for_chip(entry);
                }).appendTo(this.active_filter_chips);
                jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-chip-name',
                    'text': entry.label,
                }).appendTo(chip);
                var value = jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-chip-value',
                }).appendTo(chip);
                jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-chip-operator',
                    'text': entry.operator || '=',
                }).appendTo(value);
                jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-chip-detail',
                    'text': entry.detail || '',
                }).appendTo(value);
                jQuery('<span/>', {
                    'class': 'ag-grid-active-filter-chip-remove',
                    'text': 'x',
                    'title': Sao.i18n.gettext('Remove filter'),
                }).click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._remove_filter_chip(entry);
                }).appendTo(chip);
            });
            if (entries.length > visible.length) {
                jQuery('<button/>', {
                    'class': 'ag-grid-active-filter-chip ag-grid-active-filter-chip-more',
                    'type': 'button',
                    'text': '+' + String(entries.length - visible.length),
                    'title': Sao.i18n.gettext('Show filter logic'),
                }).click(evt => {
                    evt.preventDefault();
                    this._show_filter_logic_menu();
                }).appendTo(this.active_filter_chips);
            }
            this.active_filter_chips.css('display', 'inline-flex');
        },
        _get_active_sao_value_filter_entries: function() {
            return Object.keys(this._sao_value_filters || {})
                .filter(col_id => this._is_sao_value_filter_active(
                    this._sao_value_filters[col_id]))
                .map(col_id => {
                    var column = this._find_column_by_id(col_id);
                    var filter = this._sao_value_filters[col_id];
                    return {
                        colId: col_id,
                        label: column && column.attributes ?
                            column.attributes.string || col_id : col_id,
                        selected: Array.from(filter.selected || []).sort(),
                        all: Array.from(filter.all || []).sort(),
                    };
                });
        },
        _render_filter_logic_panel: function() {
            if (!this.filter_logic_panel) {
                return;
            }
            var any = this._sao_value_filter_mode == 'any';
            var entries = this._get_active_sao_value_filter_entries();
            var active_ids = entries.map(entry => entry.colId);
            var logic = this._normalize_sao_value_filter_groups(active_ids);
            this.filter_logic_panel.empty();
            jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-title',
                'text': Sao.i18n.gettext('Filter logic'),
            }).appendTo(this.filter_logic_panel);
            var simple_mode = jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-mode',
            }).appendTo(this.filter_logic_panel);
            [
                ['all', Sao.i18n.gettext('Match all')],
                ['any', Sao.i18n.gettext('Match any')],
            ].forEach(entry => {
                jQuery('<button/>', {
                    'class': 'btn btn-default btn-xs' +
                        (!logic && this._sao_value_filter_mode == entry[0] ?
                            ' active' : ''),
                    'type': 'button',
                    'text': entry[1],
                }).click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._disable_sao_value_filter_groups();
                    this._set_sao_value_filter_mode(entry[0]);
                }).appendTo(simple_mode);
            });
            jQuery('<button/>', {
                'class': 'btn btn-default btn-xs' + (logic ? ' active' : ''),
                'type': 'button',
                'text': Sao.i18n.gettext('Use groups'),
            }).click(evt => {
                evt.preventDefault();
                evt.stopPropagation();
                if (logic) {
                    this._disable_sao_value_filter_groups();
                } else {
                    this._enable_sao_value_filter_groups();
                }
            }).appendTo(simple_mode);
            jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-help',
                'text': Sao.i18n.gettext(logic ?
                    'Groups let you combine column filters with nested AND/OR.' :
                    (any ?
                        'Rows matching at least one active column filter.' :
                        'Rows matching every active column filter.')),
            }).appendTo(this.filter_logic_panel);
            var list = jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-list',
            }).appendTo(this.filter_logic_panel);
            if (!entries.length) {
                jQuery('<div/>', {
                    'class': 'ag-grid-filter-logic-empty text-muted',
                    'text': Sao.i18n.gettext('No active checklist filter'),
                }).appendTo(list);
                return;
            }
            if (logic) {
                var group_controls = jQuery('<div/>', {
                    'class': 'ag-grid-filter-logic-group-controls',
                }).appendTo(list);
                jQuery('<span/>', {
                    'class': 'ag-grid-filter-logic-group-label',
                    'text': Sao.i18n.gettext('Combine groups'),
                }).appendTo(group_controls);
                [
                    ['all', Sao.i18n.gettext('AND')],
                    ['any', Sao.i18n.gettext('OR')],
                ].forEach(entry => {
                    jQuery('<button/>', {
                        'class': 'btn btn-default btn-xs' +
                            (logic.mode == entry[0] ? ' active' : ''),
                        'type': 'button',
                        'text': entry[1],
                    }).click(evt => {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this._set_sao_value_filter_group_operator(entry[0]);
                    }).appendTo(group_controls);
                });
                jQuery('<button/>', {
                    'class': 'btn btn-default btn-xs',
                    'type': 'button',
                    'text': Sao.i18n.gettext('Add group'),
                }).click(evt => {
                    evt.preventDefault();
                    evt.stopPropagation();
                    this._add_sao_value_filter_group();
                }).appendTo(group_controls);
                var entry_map = {};
                entries.forEach(entry => {
                    entry_map[entry.colId] = entry;
                });
                logic.groups.forEach((group, group_index) => {
                    if (group_index) {
                        jQuery('<div/>', {
                            'class': 'ag-grid-filter-logic-operator',
                            'text': Sao.i18n.gettext(
                                logic.mode == 'any' ? 'OR' : 'AND'),
                        }).appendTo(list);
                    }
                    var group_el = jQuery('<div/>', {
                        'class': 'ag-grid-filter-logic-group',
                    }).appendTo(list);
                    var header = jQuery('<div/>', {
                        'class': 'ag-grid-filter-logic-group-header',
                    }).appendTo(group_el);
                    jQuery('<span/>', {
                        'class': 'ag-grid-filter-logic-group-title',
                        'text': Sao.i18n.gettext('Group') + ' ' +
                            String(group_index + 1),
                    }).appendTo(header);
                    [
                        ['all', Sao.i18n.gettext('AND')],
                        ['any', Sao.i18n.gettext('OR')],
                    ].forEach(entry => {
                        jQuery('<button/>', {
                            'class': 'btn btn-default btn-xs' +
                                (group.mode == entry[0] ? ' active' : ''),
                            'type': 'button',
                            'text': entry[1],
                        }).click(evt => {
                            evt.preventDefault();
                            evt.stopPropagation();
                            this._set_sao_value_filter_group_mode(
                                group.id, entry[0]);
                        }).appendTo(header);
                    });
                    if (!group.filters.length) {
                        jQuery('<div/>', {
                            'class': 'ag-grid-filter-logic-empty text-muted',
                            'text': Sao.i18n.gettext('No filter in this group'),
                        }).appendTo(group_el);
                    }
                    group.filters.forEach((col_id, index) => {
                        var entry = entry_map[col_id];
                        if (!entry) {
                            return;
                        }
                        if (index) {
                            jQuery('<div/>', {
                                'class': 'ag-grid-filter-logic-operator',
                                'text': Sao.i18n.gettext(
                                    group.mode == 'any' ? 'OR' : 'AND'),
                            }).appendTo(group_el);
                        }
                        this._render_filter_logic_item(
                            group_el, entry, logic.groups, group.id);
                    });
                });
                return;
            }
            entries.forEach((entry, index) => {
                if (index) {
                    jQuery('<div/>', {
                        'class': 'ag-grid-filter-logic-operator',
                        'text': Sao.i18n.gettext(any ? 'OR' : 'AND'),
                    }).appendTo(list);
                }
                this._render_filter_logic_item(list, entry);
            });
        },
        _render_filter_logic_item: function(parent, entry, groups, current_group_id) {
            var item = jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-item',
            }).appendTo(parent);
            jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-item-title',
                'text': entry.label,
            }).appendTo(item);
            if (groups && groups.length > 1) {
                var select = jQuery('<select/>', {
                    'class': 'ag-grid-filter-logic-group-select form-control input-sm',
                }).appendTo(item);
                groups.forEach((group, index) => {
                    jQuery('<option/>', {
                        'value': group.id,
                        'text': Sao.i18n.gettext('Group') + ' ' +
                            String(index + 1),
                        'selected': group.id == current_group_id,
                    }).appendTo(select);
                });
                select.on('change', evt => {
                    this._move_sao_value_filter_to_group(
                        entry.colId, evt.target.value);
                });
            }
            var values = entry.selected.slice(0, 4).join(', ');
            if (entry.selected.length > 4) {
                values += ', +' + String(entry.selected.length - 4);
            }
            jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-item-values',
                'text': values || Sao.i18n.gettext('(Empty)'),
            }).appendTo(item);
            jQuery('<div/>', {
                'class': 'ag-grid-filter-logic-item-count',
                'text': String(entry.selected.length) + ' / ' +
                    String(entry.all.length),
            }).appendTo(item);
        },
        _has_active_sao_value_filters: function() {
            return Object.keys(this._sao_value_filters || {}).some(col_id => {
                return this._is_sao_value_filter_active(
                    this._sao_value_filters[col_id]);
            });
        },
        _is_sao_value_filter_active: function(filter) {
            if (!filter || !filter.all || !filter.selected || !filter.all.size) {
                return false;
            }
            return Array.from(filter.all).some(value =>
                !filter.selected.has(value));
        },
        _does_sao_value_filter_pass: function(node) {
            if (!this._has_active_sao_value_filters()) {
                return true;
            }
            if (!node || !node.data || node.data.__kind && node.data.__kind != 'record') {
                return true;
            }
            var active_filter_ids = Object.keys(this._sao_value_filters)
                .filter(col_id => {
                    return this._is_sao_value_filter_active(
                        this._sao_value_filters[col_id]);
                });
            var test = col_id => {
                var filter = this._sao_value_filters[col_id];
                var column = this._find_column_by_id(col_id);
                if (!column) {
                    return true;
                }
                var value = this._get_filter_node_value(column, col_id, node);
                value = value ? String(value) : Sao.i18n.gettext('(Empty)');
                return filter.selected.has(value);
            };
            var logic = this._normalize_sao_value_filter_groups(active_filter_ids);
            if (logic) {
                var group_results = logic.groups
                    .filter(group => group.filters && group.filters.length)
                    .map(group => {
                        return group.mode == 'any' ?
                            group.filters.some(test) :
                            group.filters.every(test);
                    });
                if (!group_results.length) {
                    return true;
                }
                return logic.mode == 'any' ?
                    group_results.some(Boolean) :
                    group_results.every(Boolean);
            }
            return this._sao_value_filter_mode == 'any' ?
                active_filter_ids.some(test) : active_filter_ids.every(test);
        },
        _get_records_matching_sao_value_filters: function(records) {
            records = records || [];
            if (!this._has_active_sao_value_filters()) {
                return records.slice();
            }
            var matched = records.filter(record =>
                this._does_sao_value_filter_pass({
                    data: this._get_record_row(record),
                }));
            if (!matched.length && records.length) {
                this._log_sao_value_filter_empty_result(records);
            }
            return matched;
        },
        _log_sao_value_filter_empty_result: function(records) {
            if (!window.console || this._sao_value_filter_empty_logged) {
                return;
            }
            this._sao_value_filter_empty_logged = true;
            var active_filter_ids = Object.keys(this._sao_value_filters || {})
                .filter(col_id => this._is_sao_value_filter_active(
                    this._sao_value_filters[col_id]));
            console.warn('[AGGRID][VALUE_FILTER_EMPTY_RESULT_JSON]',
                JSON.stringify({
                    viewId: this.view_id,
                    model: this.screen && this.screen.model_name,
                    groupSize: records.length,
                    mode: this._sao_value_filter_mode,
                    groups: this._sao_value_filter_groups,
                    filters: active_filter_ids.map(col_id => {
                        var filter = this._sao_value_filters[col_id];
                        return {
                            colId: col_id,
                            selected: Array.from(filter.selected || []).sort(),
                            all: Array.from(filter.all || []).sort(),
                        };
                    }),
                    sampleRows: records.slice(0, 8).map(record => {
                        var row = {id: record.id};
                        active_filter_ids.forEach(col_id => {
                            var column = this._find_column_by_id(col_id);
                            row[col_id] = column ?
                                this._get_column_text_value(column, record) : null;
                            row[col_id + ':loaded'] = record.is_loaded(col_id);
                        });
                        return row;
                    }),
                }));
        },
        _count_sao_value_filter_passes: function() {
            var rows = this._last_row_data || [];
            var count = 0;
            rows.forEach(row => {
                if (!row || row.__kind != 'record') {
                    return;
                }
                if (this._does_sao_value_filter_pass({data: row})) {
                    count++;
                }
            });
            return count;
        },
        _reconcile_sao_value_filters_with_rows: function(row_data) {
            var filters = this._sao_value_filters || {};
            var filter_keys = Object.keys(filters);
            if (filter_keys.length && (!this.group || !this.group.length)) {
                this._trace_aggrid_lifecycle('reconcile-skip-empty-group', {
                    activeValueFilters: this._describe_sao_value_filters(),
                    restoredKeys: Object.keys(
                        this._restored_value_filter_state &&
                        this._restored_value_filter_state.filters || {}),
                    rowDataCount: row_data && row_data.length || 0,
                });
                return false;
            }
            var changed = false;
            filter_keys.forEach(col_id => {
                var column = this._find_column_by_id(col_id);
                if (!column) {
                    delete filters[col_id];
                    changed = true;
                    return;
                }
                var all = new Set();
                this.group.forEach(record => {
                    var value = this._get_filter_node_value(
                        column, col_id, {data: this._get_record_row(record)});
                    value = value ? String(value) : Sao.i18n.gettext('(Empty)');
                    all.add(value);
                });
                if (!Sao.common.compare(
                        Array.from(filters[col_id].all || []).sort(),
                        Array.from(all).sort())) {
                    changed = true;
                }
                filters[col_id].all = all;
                if (!all.size) {
                    delete filters[col_id];
                    changed = true;
                    return;
                }
            });
            return changed;
        },
        _get_filter_node_value: function(column, name, node) {
            if (!node || !node.data) {
                return '';
            }
            if (node.data.__record) {
                var record_value = this._get_column_text_value(
                    column, node.data.__record);
                if (record_value) {
                    return record_value;
                }
            }
            if (Object.prototype.hasOwnProperty.call(node.data, name)) {
                return node.data[name];
            }
            if (node.data.__groupColumn == name) {
                return node.data.__groupLabel || '';
            }
            if (node.data.__aggregates && node.data.__aggregates[name]) {
                return node.data.__aggregates[name].text || '';
            }
            return '';
        },
        _get_live_filter_values: function(column, name) {
            var empty = Sao.i18n.gettext('(Empty)');
            var values = new Map();
            this.group.forEach(record => {
                var value = this._get_column_text_value(column, record) || empty;
                values.set(value, true);
            });
            if (!values.size) {
                (this._last_row_data || []).forEach(row => {
                    if (!row || row.__kind && row.__kind != 'record') {
                        return;
                    }
                    var value = row[name];
                    values.set(value ? String(value) : empty, true);
                });
            }
            return Array.from(values.keys());
        },
        _get_distinct_filter_values: function(column) {
            if (!column || column instanceof Sao.View.Tree.ButtonColumn ||
                    !column.attributes) {
                return null;
            }
            switch (column.attributes.widget) {
            case 'integer':
            case 'float':
            case 'numeric':
                return null;
            }
            var limit = 80;
            var empty = Sao.i18n.gettext('(Empty)');
            var values = new Map();
            for (var i = 0; i < this.group.length; i++) {
                var record = this.group[i];
                var value = this._get_column_text_value(column, record) || empty;
                values.set(value, true);
                if (values.size > limit) {
                    return null;
                }
            }
            if (!values.size) {
                return null;
            }
            return Array.from(values.keys()).sort(function(a, b) {
                return String(a).localeCompare(String(b), undefined, {
                    numeric: true,
                    sensitivity: 'base',
                });
            });
        },
        _get_group_key_text: function(column, record) {
            var text = this._get_column_text_value(column, record);
            return text || Sao.i18n.gettext('(Empty)');
        },
        _load_grouping_values: function() {
            if (!this._is_grouping_active()) {
                return null;
            }
            var grouping = this._get_grouping_state();
            var promises = [];
            grouping.groupBy.forEach(name => {
                this.group.forEach(record => {
                    if (record && !record.is_loaded(name)) {
                        promises.push(record.load(name, true, false));
                    }
                });
            });
            if (!promises.length) {
                return null;
            }
            return jQuery.when.apply(jQuery, promises);
        },
        _load_sao_value_filter_values: function() {
            if (!this._has_active_sao_value_filters()) {
                return null;
            }
            var names = Object.keys(this._sao_value_filters || {})
                .filter(col_id => this._is_sao_value_filter_active(
                    this._sao_value_filters[col_id]));
            var promises = [];
            names.forEach(name => {
                this.group.forEach(record => {
                    if (record && record.id >= 0 && !record.is_loaded(name)) {
                        promises.push(record.load(name, true, false));
                    }
                });
            });
            if (!promises.length) {
                return null;
            }
            return jQuery.when.apply(jQuery, promises);
        },
        _get_column_raw_value: function(column, record) {
            if (!column || !record || !column.attributes || !column.attributes.name) {
                return null;
            }
            var field = this.screen.model.fields[column.attributes.name];
            if (!field || record.id >= 0 && !record.get_loaded([column.attributes.name])) {
                return null;
            }
            var value = field.get(record);
            if (value && value.isTimeDelta) {
                return value.asSeconds();
            }
            if (value === null || value === undefined || value === '') {
                return null;
            }
            return Number(value);
        },
        _format_aggregate_value: function(column, value) {
            if (value === null || value === undefined || value === '') {
                return '';
            }
            var field = column && column.attributes && column.attributes.name ?
                this.screen.model.fields[column.attributes.name] : null;
            if (!field || !field.description) {
                return String(value);
            }
            if (field.description.type == 'timedelta') {
                var converter = field.converter ? field.converter(this.group) : null;
                return Sao.common.timedelta.format(
                    Sao.TimeDelta(null, value), converter);
            }
            var options = {};
            var factor = Number(column.factor || column.attributes.factor || 1);
            var digit_record = this.group && this.group.length ? this.group[0] : null;
            var digits = field.digits && digit_record ?
                field.digits(digit_record, factor) : null;
            if (digits) {
                options.minimumFractionDigits = digits[1];
                options.maximumFractionDigits = digits[1];
            }
            return (value * factor).toLocaleString(
                Sao.i18n.BC47(Sao.i18n.getlang()), options);
        },
        _compute_aggregates: function(records) {
            var grouping = this._get_grouping_state();
            var aggregations = grouping.aggregations || {};
            var result = {};
            Object.keys(aggregations).forEach(name => {
                var column = this._find_column_by_id(name);
                if (!column) {
                    return;
                }
                var operator = aggregations[name];
                var values = records.map(record =>
                    this._get_column_raw_value(column, record))
                    .filter(value => value !== null && !isNaN(value));
                var aggregate = null;
                switch (operator) {
                case 'sum':
                    aggregate = values.reduce((sum, value) => sum + value, 0);
                    break;
                case 'avg':
                    aggregate = values.length ?
                        values.reduce((sum, value) => sum + value, 0) /
                            values.length : null;
                    break;
                case 'count':
                    aggregate = records.length;
                    break;
                case 'min':
                    aggregate = values.length ? Math.min.apply(Math, values) : null;
                    break;
                case 'max':
                    aggregate = values.length ? Math.max.apply(Math, values) : null;
                    break;
                }
                result[name] = {
                    operator: operator,
                    value: aggregate,
                    text: aggregate === null ? '' :
                        (operator == 'count' ? aggregate.toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang())) :
                            this._format_aggregate_value(column, aggregate)),
                };
            });
            return result;
        },
        _get_column_text_value: function(column, record) {
            if (!column || !record || !column.attributes.name) {
                return '';
            }
            try {
                var text = column.get_textual_value(record);
                if (text === null || text === undefined) {
                    var string_key = column.attributes.name + ':string';
                    if (record._values &&
                            Object.prototype.hasOwnProperty.call(
                                record._values, string_key)) {
                        text = record._values[string_key];
                    } else if (record._values &&
                            Object.prototype.hasOwnProperty.call(
                                record._values, column.attributes.name)) {
                        text = record._values[column.attributes.name];
                    } else {
                        return '';
                    }
                }
                if (Array.isArray(text)) {
                    text = text.join(', ');
                } else if (typeof text == 'object') {
                    text = JSON.stringify(text);
                }
                return String(text);
            } catch (error) {
                var related_key = column.attributes.name + ':string';
                if (record._values &&
                        Object.prototype.hasOwnProperty.call(
                            record._values, related_key)) {
                    return String(record._values[related_key] || '');
                }
                if (record._values &&
                        Object.prototype.hasOwnProperty.call(
                            record._values, column.attributes.name)) {
                    return String(record._values[column.attributes.name] || '');
                }
                return '';
            }
        },
        _get_column_date_filter_value: function(column, record) {
            if (!column || !record || !column.attributes.name) {
                return null;
            }
            var name = column.attributes.name;
            var value = null;
            try {
                var field = record.model && record.model.fields &&
                    record.model.fields[name];
                if (field && field.get_client) {
                    value = field.get_client(record);
                }
            } catch (error) {
                value = null;
            }
            if (!value && record._values &&
                    Object.prototype.hasOwnProperty.call(record._values, name)) {
                value = record._values[name];
            }
            if (!value) {
                return null;
            }
            if (value instanceof Date) {
                return new Date(
                    value.getFullYear(), value.getMonth(), value.getDate());
            }
            if (value.isDate || value.isDateTime) {
                return new Date(value.year(), value.month(), value.date());
            }
            if (typeof value == 'string') {
                var date = moment(value, [
                    'YYYY-MM-DD',
                    Sao.common.date_format(
                        this.screen && this.screen.context &&
                        this.screen.context.date_format),
                ], true);
                return date.isValid() ?
                    new Date(date.year(), date.month(), date.date()) : null;
            }
            return null;
        },
        _is_badge_column: function(column) {
            if (!column || !column.attributes) {
                return false;
            }
            return column.attributes.widget == 'badge' ||
                column.attributes.badge ||
                column.attributes.badge_colors ||
                column.attributes.badge_color_field ||
                column.attributes.badge_background_field ||
                column.attributes.badge_foreground_field ||
                column.attributes.color_field ||
                column.attributes.background_color_field;
        },
        _get_badge_column_load_fields: function(column) {
            var attributes = column && column.attributes || {};
            var fields = [
                attributes.name,
                attributes.badge_color_field,
                attributes.badge_background_field,
                attributes.badge_foreground_field,
                attributes.color_field,
                attributes.background_color_field,
            ];
            return fields.filter((field, index) =>
                field && fields.indexOf(field) == index);
        },
        _is_record_field_loaded: function(record, name) {
            if (!record || !name) {
                return true;
            }
            if (record.get_loaded) {
                return record.get_loaded([name]);
            }
            if (record.is_loaded) {
                return record.is_loaded(name);
            }
            return true;
        },
        _load_badge_column_values: function() {
            if (this._badge_values_loading) {
                return null;
            }
            var columns = this.columns.filter(column =>
                this._is_badge_column(column));
            if (!columns.length || !this.group || !this.group.length) {
                return null;
            }
            var promises = [];
            var fields = [];
            columns.forEach(column => {
                var names = this._get_badge_column_load_fields(column);
                fields = fields.concat(names);
                this.group.forEach(record => {
                    names.forEach(name => {
                        if (record && record.id >= 0 &&
                                !this._is_record_field_loaded(record, name)) {
                            promises.push(record.load(name, true, false));
                        }
                    });
                });
            });
            if (!promises.length) {
                return null;
            }
            this._badge_values_loading = true;
            if (window.console) {
                console.info('[AGGRID][BADGE_LOAD_JSON]', JSON.stringify({
                    instanceId: this._debug_id,
                    viewId: this.view_id,
                    model: this.screen && this.screen.model_name,
                    fields: fields.filter((field, index) =>
                        field && fields.indexOf(field) == index),
                    promiseCount: promises.length,
                }));
            }
            return jQuery.when.apply(jQuery, promises).always(() => {
                this._badge_values_loading = false;
            });
        },
        _parse_badge_colors: function(colors) {
            var parsed = {};
            (colors || '').split(',').forEach(item => {
                var pair = item.split(':');
                if (pair.length < 2) {
                    return;
                }
                var key = pair.shift().trim();
                var value = pair.join(':').trim();
                if (key && value) {
                    parsed[key] = value;
                }
            });
            return parsed;
        },
        _normalize_badge_text_key: function(text) {
            text = text === null || text === undefined ? '' : String(text);
            return text.replace(/^[^A-Za-z0-9]+/, '').trim();
        },
        _get_badge_color_order: function(colors) {
            return (colors || '').split(',').map(item => {
                var pair = item.split(':');
                return pair.length < 2 ? '' :
                    this._normalize_badge_text_key(pair.shift());
            }).filter(value => value && value != '*');
        },
        _get_record_field_value: function(record, name) {
            if (!record || !name) {
                return '';
            }
            try {
                if (record.is_loaded && !record.is_loaded(name)) {
                    record.load(name, true, false);
                    return '';
                }
                return record.field_get(name);
            } catch (error) {
                if (record._values &&
                        Object.prototype.hasOwnProperty.call(
                            record._values, name)) {
                    return record._values[name];
                }
                return '';
            }
        },
        _normalize_badge_color: function(color) {
            color = color === null || color === undefined ? '' :
                String(color).trim();
            var palette = {
                red: '#ef4444',
                danger: '#ef4444',
                coral: '#ef4444',
                orange: '#f59e0b',
                warning: '#f59e0b',
                amber: '#f59e0b',
                yellow: '#eab308',
                green: '#16a34a',
                success: '#16a34a',
                teal: '#0f766e',
                blue: '#2563eb',
                info: '#2563eb',
                purple: '#8b5cf6',
                violet: '#8b5cf6',
                gray: '#6c757d',
                grey: '#6c757d',
                muted: '#6c757d',
            };
            return palette[color.toLowerCase()] || color;
        },
        _hex_to_rgb_badge_color: function(color) {
            color = this._normalize_badge_color(color);
            var match = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color || '');
            if (!match) {
                return null;
            }
            var hex = match[1];
            if (hex.length == 3) {
                hex = hex.split('').map(part => part + part).join('');
            }
            return {
                r: parseInt(hex.slice(0, 2), 16),
                g: parseInt(hex.slice(2, 4), 16),
                b: parseInt(hex.slice(4, 6), 16),
            };
        },
        _rgb_to_badge_color: function(rgb) {
            var part = value => Math.max(0, Math.min(255, Math.round(value)))
                .toString(16).padStart(2, '0');
            return '#' + part(rgb.r) + part(rgb.g) + part(rgb.b);
        },
        _mix_badge_color: function(color, mix_with, weight) {
            var source = this._hex_to_rgb_badge_color(color);
            var target = this._hex_to_rgb_badge_color(mix_with);
            if (!source || !target) {
                return '';
            }
            return this._rgb_to_badge_color({
                r: source.r * (1 - weight) + target.r * weight,
                g: source.g * (1 - weight) + target.g * weight,
                b: source.b * (1 - weight) + target.b * weight,
            });
        },
        _rgba_badge_color: function(color, alpha) {
            var rgb = this._hex_to_rgb_badge_color(color);
            if (!rgb) {
                return '';
            }
            return 'rgba(' + rgb.r + ', ' + rgb.g + ', ' + rgb.b + ', ' +
                alpha + ')';
        },
        _get_badge_colors: function(column, record, text) {
            var attributes = column.attributes || {};
            var color = '';
            var text_color = '';
            var mapped = this._parse_badge_colors(attributes.badge_colors);
            var normalized_text = this._normalize_badge_text_key(text);
            if (Object.prototype.hasOwnProperty.call(mapped, text)) {
                color = mapped[text];
            } else if (Object.prototype.hasOwnProperty.call(
                    mapped, normalized_text)) {
                color = mapped[normalized_text];
            } else if (Object.prototype.hasOwnProperty.call(
                    mapped, String(normalized_text).toLowerCase())) {
                color = mapped[String(normalized_text).toLowerCase()];
            } else if (Object.prototype.hasOwnProperty.call(mapped, '*')) {
                color = mapped['*'];
            }
            if (!color && attributes.badge_color_field) {
                color = this._get_record_field_value(
                    record, attributes.badge_color_field);
            }
            if (!color && attributes.badge_background_field) {
                color = this._get_record_field_value(
                    record, attributes.badge_background_field);
            }
            if (!color && attributes.background_color_field) {
                color = this._get_record_field_value(
                    record, attributes.background_color_field);
            }
            if (!color && attributes.color_field) {
                color = this._get_record_field_value(
                    record, attributes.color_field);
            }
            if (attributes.badge_foreground_field) {
                text_color = this._get_record_field_value(
                    record, attributes.badge_foreground_field);
            }
            return {
                background: this._normalize_badge_color(color),
                color: this._normalize_badge_color(text_color),
            };
        },
        _get_badge_display_colors: function(column, record, text) {
            var colors = this._get_badge_colors(column, record, text);
            var accent = colors.background || colors.color;
            if (!accent) {
                return colors;
            }
            var foreground = colors.color || accent;
            if (document.documentElement.getAttribute('data-theme-mode') ==
                    'dark') {
                return {
                    background: this._rgba_badge_color(accent, 0.30) ||
                        colors.background,
                    color: this._mix_badge_color(
                        foreground, '#ffffff', 0.56) || foreground,
                    accent: accent,
                };
            }
            return {
                background: this._mix_badge_color(accent, '#ffffff', 0.68) ||
                    colors.background,
                color: this._mix_badge_color(
                    foreground, '#000000', 0.18) || foreground,
                accent: accent,
            };
        },
        _render_badge_cell: function(column, record) {
            var text = this._get_column_text_value(column, record);
            var cell = jQuery('<div/>', {
                'class': 'cell ag-grid-badge-cell',
            });
            if (!text) {
                return cell[0];
            }
            var badge = jQuery('<span/>', {
                'class': 'ag-grid-badge',
                'text': text,
            });
            if (parseInt(column.attributes.badge_bold || '0', 10)) {
                badge.addClass('ag-grid-badge-bold');
            }
            var colors = this._get_badge_display_colors(column, record, text);
            if (colors.background) {
                badge.css('background-color', colors.background);
            }
            if (colors.color) {
                badge.css('color', colors.color);
            }
            cell.append(badge);
            return cell[0];
        },
        _render_cell: function(column, record) {
            var cell = jQuery('<div/>', {
                'class': 'cell',
            });

            if (column.prefixes) {
                for (const prefix of column.prefixes) {
                    cell.append(jQuery('<span/>', {
                        'class': 'prefix',
                    }).append(prefix.render(record)));
                }
            }

            var widget = jQuery('<span/>', {
                'class': 'widget',
            }).appendTo(cell);
            widget.append(column.render(record));

            if (column.suffixes) {
                for (const suffix of column.suffixes) {
                    cell.append(jQuery('<span/>', {
                        'class': 'suffix',
                    }).append(suffix.render(record)));
                }
            }
            return cell[0];
        },
        _render_special_row_cell: function(column, row) {
            var column_name = column.attributes.name;
            var cell = jQuery('<div/>', {
                'class': 'ag-grid-special-cell',
            });
            if (row.__kind == 'group') {
                if (row.__groupColumn == column_name) {
                    var expanded = Boolean(row.__expanded);
                    var button = jQuery('<button/>', {
                        'class': 'ag-grid-group-toggle',
                        'type': 'button',
                        'tabindex': -1,
                        'text': expanded ? '▾' : '▸',
                    }).css('margin-left', (row.__groupDepth * 16) + 'px');
                    button.on('click', evt => {
                        evt.preventDefault();
                        evt.stopPropagation();
                        this._toggle_group_row(row.__groupPath || []);
                    });
                    var label = row.__groupLabel + ' (' +
                        row.__recordCount.toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang())) + ')';
                    cell.append(button).append(jQuery('<span/>', {
                        'class': 'ag-grid-group-label',
                        'text': label,
                    }));
                    return cell[0];
                }
                if (this._get_grouping_state().showSubtotals &&
                        row.__aggregates && row.__aggregates[column_name]) {
                    cell.addClass('ag-grid-aggregate-cell').text(
                        row.__aggregates[column_name].text);
                }
                return cell[0];
            }
            if (row.__kind == 'total') {
                if (column_name == this._get_first_visible_data_column_id()) {
                    cell.append(jQuery('<span/>', {
                        'class': 'ag-grid-total-label',
                        'text': Sao.i18n.gettext('Grand Total'),
                    }));
                    return cell[0];
                }
                if (row.__aggregates && row.__aggregates[column_name]) {
                    cell.addClass('ag-grid-aggregate-cell').text(
                        row.__aggregates[column_name].text);
                }
                return cell[0];
            }
            return document.createTextNode('');
        },
        _get_first_visible_data_column_id: function() {
            var visible = this._list_toggleable_columns().find(column =>
                this._is_column_visible_for_copy(column.attributes.name));
            return visible ? visible.attributes.name : null;
        },
        _get_row_data_plain: function() {
            var records = this._get_records_matching_sao_value_filters(
                this.group.slice());
            return this._assign_row_sort_indices(records.map(record => {
                var row = {
                    __id: 'record:' + record.id,
                    __kind: 'record',
                    __record: record,
                };
                this.columns.forEach((column, index) => {
                    var name = column.attributes.name || ('__column_' + index);
                    row[name] = this._get_column_text_value(column, record);
                });
                return row;
            }));
        },
        _flatten_grouped_rows: function(records, level, path, rows, grouping) {
            var column_name = grouping.groupBy[level];
            var column = this._find_column_by_id(column_name);
            records = this._sort_records_for_current_order(records);
            if (!column) {
                records.forEach(record => rows.push(this._get_record_row(record)));
                return;
            }
            var groups = [];
            records.forEach(record => {
                var key = this._get_group_key_text(column, record);
                var bucket = groups.find(group => group.key == key);
                if (!bucket) {
                    bucket = {
                        key: key,
                        records: [],
                    };
                    groups.push(bucket);
                }
                bucket.records.push(record);
            });
            groups.forEach(group => {
                var group_path = path.concat([column_name + ':' + group.key]);
                var is_expanded = grouping.expandAll ||
                    grouping.expandedPaths.some(expanded_path =>
                        Sao.common.compare(expanded_path, group_path));
                rows.push({
                    __id: 'group:' + group_path.join('|'),
                    __kind: 'group',
                    __groupPath: group_path,
                    __groupColumn: column_name,
                    __groupDepth: level,
                    __groupLabel: group.key,
                    __recordCount: group.records.length,
                    __expanded: is_expanded,
                    __aggregates: this._compute_aggregates(group.records),
                });
                var before_children = rows.length;
                if (!is_expanded) {
                    if (window.console) {
                        console.log('[AGGRID][GROUP_ROW_COUNT_DEBUG_JSON]',
                            JSON.stringify({
                                viewId: this.view_id,
                                model: this.screen && this.screen.model_name,
                                groupPath: group_path,
                                groupBy: grouping.groupBy,
                                level: level,
                                key: group.key,
                                bucketCount: group.records.length,
                                expanded: false,
                                emittedRecordRows: 0,
                                emittedGroupRows: 0,
                                activeValueFilters:
                                    Object.keys(this._sao_value_filters || {}),
                            }));
                    }
                    return;
                }
                if (level + 1 < grouping.groupBy.length) {
                    this._flatten_grouped_rows(
                        group.records, level + 1, group_path, rows, grouping);
                } else {
                    group.records.forEach(record => {
                        rows.push(this._get_record_row(record));
                    });
                }
                if (window.console) {
                    var emitted = rows.slice(before_children);
                    console.log('[AGGRID][GROUP_ROW_COUNT_DEBUG_JSON]',
                        JSON.stringify({
                            viewId: this.view_id,
                            model: this.screen && this.screen.model_name,
                            groupPath: group_path,
                            groupBy: grouping.groupBy,
                            level: level,
                            key: group.key,
                            bucketCount: group.records.length,
                            expanded: true,
                            emittedRecordRows: emitted.filter(row =>
                                row.__kind == 'record').length,
                            emittedGroupRows: emitted.filter(row =>
                                row.__kind == 'group').length,
                            activeValueFilters:
                                Object.keys(this._sao_value_filters || {}),
                            sampleRecordIds: emitted.filter(row =>
                                row.__kind == 'record')
                                .slice(0, 8)
                                .map(row => row.__record && row.__record.id),
                        }));
                }
            });
        },
        _get_record_row: function(record) {
            var row = {
                __id: 'record:' + record.id,
                __kind: 'record',
                __record: record,
            };
            this.columns.forEach((column, index) => {
                var name = column.attributes.name || ('__column_' + index);
                row[name] = this._get_column_text_value(column, record);
            });
            return row;
        },
        _get_row_data: function() {
            if (!this._is_grouping_active()) {
                this._last_row_data = this._get_row_data_plain();
                return this._last_row_data;
            }
            var grouping = this._get_grouping_state();
            var rows = [];
            var records = this._get_records_matching_sao_value_filters(
                this.group.slice());
            this._flatten_grouped_rows(records, 0, [], rows, grouping);
            if (grouping.showGrandTotal &&
                    Object.keys(grouping.aggregations || {}).length) {
                rows.push({
                    __id: 'total:grand',
                    __kind: 'total',
                    __aggregates: this._compute_aggregates(records),
                });
            }
            this._assign_row_sort_indices(rows);
            this._last_row_data = rows;
            return rows;
        },
        _install_filter_radio_bridge: function(popup) {
            if (!popup || popup.__agRadioBridgeInstalled) {
                return;
            }
            popup.__agRadioBridgeInstalled = true;
            var sync_wrappers = () => {
                Array.from(popup.querySelectorAll('.ag-radio-button')).forEach(container => {
                    var input = container.querySelector('.ag-radio-button-input');
                    var wrapper = container.querySelector('.ag-radio-button-input-wrapper');
                    if (!input || !wrapper) {
                        return;
                    }
                    wrapper.classList.toggle('ag-checked', Boolean(input.checked));
                    container.classList.toggle('ag-checked', Boolean(input.checked));
                });
            };
            sync_wrappers();
            popup.addEventListener('change', event => {
                if (event.target && event.target.matches &&
                        event.target.matches('.ag-radio-button-input')) {
                    window.requestAnimationFrame(sync_wrappers);
                }
            }, true);
            var handler = event => {
                if (event.__agBridgeSynthetic) {
                    return;
                }
                var radio_container = event.target && event.target.closest ?
                    event.target.closest('.ag-radio-button') : null;
                if (!radio_container) {
                    return;
                }
                var wrapper = radio_container.querySelector(
                    '.ag-radio-button-input-wrapper');
                var radio = wrapper.querySelector('.ag-radio-button-input');
                if (!wrapper || !radio) {
                    return;
                }
                event.preventDefault();
                event.stopPropagation();
                var group = radio.name || '';
                Array.from(popup.querySelectorAll('.ag-radio-button-input')).forEach(input => {
                    if (input.name !== group) {
                        return;
                    }
                    input.checked = false;
                    var input_wrapper = input.closest('.ag-radio-button-input-wrapper');
                    var input_container = input.closest('.ag-radio-button');
                    if (input_wrapper) {
                        input_wrapper.classList.remove('ag-checked');
                    }
                    if (input_container) {
                        input_container.classList.remove('ag-checked');
                    }
                });
                radio.checked = true;
                wrapper.classList.add('ag-checked');
                radio_container.classList.add('ag-checked');
                if (radio.focus) {
                    radio.focus();
                }
                var input_event = new Event('input', {bubbles: true});
                input_event.__agBridgeSynthetic = true;
                var click_event = new Event('click', {bubbles: true});
                click_event.__agBridgeSynthetic = true;
                var change_event = new Event('change', {bubbles: true});
                change_event.__agBridgeSynthetic = true;
                radio.dispatchEvent(input_event);
                radio.dispatchEvent(click_event);
                radio.dispatchEvent(change_event);
                window.requestAnimationFrame(sync_wrappers);
            };
            popup.addEventListener('pointerdown', handler, true);
            popup.addEventListener('click', handler, true);
        },
        _on_row_clicked: function(event) {
            if (!event.data) {
                return;
            }
            if (event.data.__kind == 'group') {
                this._toggle_group_row(event.data.__groupPath || []);
                return;
            }
            if (!event.data.__record) {
                return;
            }
            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                this.record = event.data.__record;
            } else if (event.node.isSelected()) {
                this.record = event.data.__record;
            }
        },
        _on_row_double_clicked: function(event) {
            if (!event.data || !event.data.__record) {
                return;
            }
            this.record = event.data.__record;
            if (this.selection_mode != Sao.common.SELECTION_NONE) {
                this._syncing_grid = true;
                event.node.setSelected(true, true);
                this._syncing_grid = false;
            }
            this.screen.row_activate();
        },
        _toggle_group_row: function(group_path) {
            var grouping = this._get_grouping_state();
            var paths = grouping.expandedPaths.map(path => path.slice());
            var is_expanded = grouping.expandAll ||
                paths.some(path => Sao.common.compare(path, group_path));
            if (grouping.expandAll) {
                var visible_paths = (this._last_row_data || [])
                    .filter(row => row.__kind == 'group')
                    .map(row => row.__groupPath);
                paths = visible_paths.filter(path =>
                    !Sao.common.compare(path, group_path));
                grouping.expandAll = false;
            } else if (is_expanded) {
                paths = paths.filter(path => !Sao.common.compare(path, group_path));
            } else {
                paths.push(group_path.slice());
            }
            grouping.expandedPaths = paths;
            this._set_grouping_state(grouping);
        },
        _on_selection_changed: function() {
            if (this._syncing_grid || !this.gridApi) {
                return;
            }
            this._selected_ids = new Set();
            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                this._update_copy_state();
                return;
            }
            var selected_nodes = this.gridApi.getSelectedNodes();
            selected_nodes.forEach(node => {
                if (node.data && node.data.__record) {
                    this._selected_ids.add(node.data.__record.id);
                }
            });
            var current = selected_nodes.length ?
                selected_nodes[selected_nodes.length - 1].data.__record : null;
            this.screen.current_record = current;
            this._update_copy_state();
            this._sync_selection_header_checkbox();
        },
        _on_column_header_clicked: function(event) {
            if (!this._debug_sort_enabled()) {
                return;
            }
            var column = event && event.column;
            var col_id = column && column.getColId ? column.getColId() : null;
            var col_def = column && column.getColDef ? column.getColDef() : {};
            console.info('[AGGRID][SORT_DEBUG][HEADER_CLICK]', JSON.stringify({
                colId: col_id,
                headerName: col_def && col_def.headerName,
                sortable: col_def && col_def.sortable,
                sort: column && column.getSort ? column.getSort() : null,
                eventTargetClass: event && event.event && event.event.target &&
                    event.event.target.className || '',
            }));
        },
        _on_sort_changed: function() {
            if (this._syncing_grid || !this.gridApi) {
                return;
            }
            this._sort_debug_compare_count = {};
            var order = [];
            var filter_model = this.gridApi.getFilterModel ?
                this.gridApi.getFilterModel() : {};
            var column_api = this.gridColumnApi || this.gridApi;
            var column_state_debug = [];
            if (column_api.getColumnState) {
                column_api.getColumnState().forEach(state => {
                    column_state_debug.push({
                        colId: state.colId,
                        sort: state.sort || null,
                        sortIndex: state.sortIndex,
                        hide: state.hide,
                        width: state.width,
                    });
                    if (state.sort && state.colId != '__selection__') {
                        order.push([
                            state.colId,
                            state.sort.toUpperCase(),
                        ]);
                    }
                });
            }
            if (this._debug_sort_enabled()) {
                console.info('[AGGRID][SORT_DEBUG][SORT_CHANGED]',
                    JSON.stringify({
                        orderFromGrid: order,
                        previousScreenOrder: this.screen && this.screen.order,
                        defaultOrder: this.screen && this.screen.default_order,
                        groupingActive: this._is_grouping_active(),
                        fullResultLoaded: this._is_full_result_loaded(),
                        hasGridFilters: Boolean(filter_model &&
                            Object.keys(filter_model).length),
                        columnState: column_state_debug.filter(state =>
                            state.sort || /purchase|qt|qty|quantity|price|date/i
                                .test(String(state.colId || ''))),
                    }));
            }
            this._enforce_minimum_column_widths();
            if (!order.length) {
                this.screen.order = this.screen.default_order;
            } else {
                this.screen.order = [order[0]];
            }
            var server_sortable_order = !order.length ||
                this._is_server_sortable_column_id(order[0][0]);
            this._persist_grid_state({skipLayoutRefresh: true});
            var has_grid_filters = filter_model && Object.keys(filter_model).length;
            var unsaved_records = [];
            for (const unsaved_record of this.group) {
                if (unsaved_record.id < 0) {
                    unsaved_records = unsaved_record.group;
                }
            }
            if (this._is_grouping_active() &&
                    (this._is_full_result_loaded() ||
                    !jQuery.isEmptyObject(unsaved_records))) {
                this._refresh_grouped_row_data_after_sort();
                return;
            }
            if (!server_sortable_order) {
                this._update_summary_bar();
                this._sync_selection_header_checkbox();
                return;
            }
            if (has_grid_filters ||
                    this._is_full_result_loaded() ||
                    !jQuery.isEmptyObject(unsaved_records)) {
                this._update_summary_bar();
                this._sync_selection_header_checkbox();
                return;
            }
            var search_string = this.screen.screen_container.get_text();
            this.screen.search_filter(search_string);
        },
        _refresh_grouped_row_data_after_sort: function() {
            if (!this.gridApi) {
                return;
            }
            var row_data = this._get_row_data();
            this._syncing_grid = true;
            try {
                if (this.gridApi.setGridOption) {
                    this.gridApi.setGridOption('rowData', row_data);
                } else {
                    this.gridApi.setRowData(row_data);
                }
            } finally {
                this._syncing_grid = false;
            }
            this._resize_grid_host();
            this._refresh_grid_layout_after_row_data(row_data);
            this._sync_selection(this.get_selected_paths());
            this._update_summary_bar();
            this._sync_selection_header_checkbox();
        },
        _sync_selection: function(selected) {
            if (this.selection_mode == Sao.common.SELECTION_NONE ||
                    !this.gridApi) {
                return;
            }
            var selected_ids = new Set(
                (selected || []).map(path => path[path.length - 1]));
            if (!selected_ids.size && this.record) {
                selected_ids.add(this.record.id);
            }

            this._syncing_grid = true;
            this._selected_ids = new Set(selected_ids);
            this.gridApi.forEachNode(node => {
                if (!node.data || node.data.__kind != 'record' ||
                        !node.data.__record) {
                    node.setSelected(false);
                    return;
                }
                var selected_ = selected_ids.has(node.data.__record.id);
                node.setSelected(selected_);
            });
            this._syncing_grid = false;
            this._update_copy_state();
            this._sync_selection_header_checkbox();
        },
        _sync_sort_state: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !column_api.applyColumnState) {
                return;
            }
            var state = [];
            var order = this.screen.order || [];
            if (order.length) {
                state.push({
                    colId: order[0][0],
                    sort: (order[0][1] || '').toLowerCase(),
                });
            }
            this._syncing_grid = true;
            column_api.applyColumnState({
                state: state,
                defaultState: {sort: null},
            });
            this._syncing_grid = false;
        },
        _is_column_visible_for_copy: function(column_name) {
            var visibility = this._get_column_visibility_map();
            if (Object.prototype.hasOwnProperty.call(visibility, column_name)) {
                return visibility[column_name];
            }
            return true;
        },
        _is_full_result_loaded: function() {
            if (!this.group) {
                return false;
            }
            if (this.group.parent) {
                return true;
            }
            if (this.screen.search_count == this.group.length) {
                return true;
            }
            if (this.screen.limit === null || this.screen.limit === undefined) {
                return true;
            }
            return this.group.length < this.screen.limit;
        },
        _update_copy_state: function() {
            if (!this.copy_button) {
                return;
            }
            this.copy_button.prop('disabled', !this.selected_records.length);
            this.status.text(String(this.group.length) + ' records');
        },
        _get_visible_record_rows: function() {
            var rows = [];
            if (!this.gridApi) {
                return rows;
            }
            var collect = node => {
                if (node && node.data && node.data.__kind == 'record') {
                    rows.push(node.data);
                }
            };
            if (this.gridApi.forEachNodeAfterFilterAndSort) {
                this.gridApi.forEachNodeAfterFilterAndSort(collect);
            } else if (this.gridApi.forEachNodeAfterFilter) {
                this.gridApi.forEachNodeAfterFilter(collect);
            } else {
                (this._last_row_data || []).forEach(row => {
                    if (row && row.__kind == 'record') {
                        rows.push(row);
                    }
                });
            }
            return rows;
        },
        _get_displayed_column_map: function() {
            var visible_columns = {};
            var column_api = this.gridColumnApi || this.gridApi;
            if (column_api && column_api.getAllDisplayedColumns) {
                column_api.getAllDisplayedColumns().forEach(column => {
                    if (column && column.getColId) {
                        visible_columns[column.getColId()] = true;
                    }
                });
            }
            return visible_columns;
        },
        _get_viewport_column_map: function() {
            var visible_columns = {};
            var column_api = this.gridColumnApi || this.gridApi;
            var columns = null;
            if (column_api && column_api.getAllDisplayedVirtualColumns) {
                columns = column_api.getAllDisplayedVirtualColumns();
            } else if (column_api && column_api.getAllDisplayedColumns) {
                columns = column_api.getAllDisplayedColumns();
            }
            (columns || []).forEach(column => {
                if (column && column.getColId) {
                    visible_columns[column.getColId()] = true;
                }
            });
            return visible_columns;
        },
        _is_summary_column_visible: function(column, visible_columns) {
            var name = column && column.attributes && column.attributes.name;
            return name && (!Object.keys(visible_columns).length ||
                visible_columns[name]);
        },
        _get_summary_badge_columns: function() {
            return this.columns.filter(column => {
                var name = column.attributes && column.attributes.name;
                return name && column.attributes.widget == 'badge';
            });
        },
        _get_summary_sum_columns: function() {
            var columns = [];
            this.sum_widgets.forEach((sum_widget, column) => {
                if (column && column.attributes && column.attributes.name) {
                    columns.push(column);
                }
            });
            return columns;
        },
        _get_summary_variation_columns: function() {
            return this.columns.filter(column => {
                var attributes = column && column.attributes || {};
                return attributes.name &&
                    attributes.widget == 'variation' &&
                    attributes.variation_summary == '1' &&
                    attributes.variation_previous &&
                    attributes.variation_group_by;
            });
        },
        _render_summary_item: function(
                label, value, color, class_name, variation_text,
                variation_class) {
            var item = jQuery('<div/>', {
                'class': 'ag-grid-summary-item' +
                    (class_name ? ' ' + class_name : ''),
            });
            jQuery('<span/>', {
                'class': 'ag-grid-summary-label',
                'text': label,
            }).appendTo(item);
            if (variation_text) {
                jQuery('<span/>', {
                    'class': 'ag-grid-summary-variation-badge ' +
                        (variation_class || ''),
                    'text': variation_text,
                }).appendTo(item);
            }
            var value_wrap = jQuery('<span/>', {
                'class': 'ag-grid-summary-value-wrap',
            }).appendTo(item);
            var value_el = jQuery('<span/>', {
                'class': 'ag-grid-summary-value',
                'text': value,
            }).appendTo(value_wrap);
            if (color) {
                value_el.css('color', color);
            }
            return item;
        },
        _trace_summary_bar: function(
                rows, badge_columns, sum_columns, variation_columns, summary_items) {
            return;
            var describe = column => ({
                name: column && column.attributes && column.attributes.name || null,
                label: column && column.attributes &&
                    (column.attributes.string || column.attributes.name) || '',
                widget: column && column.attributes && column.attributes.widget || '',
                sum: column && column.attributes && column.attributes.sum || '',
                badge: Boolean(column && column.attributes &&
                    column.attributes.widget == 'badge'),
            });
            console.info('[AGGRID][SUMMARY_BAR_JSON]', JSON.stringify({
                instanceId: this._debug_id,
                viewId: this.view_id,
                model: this.screen && this.screen.model_name,
                rowCount: rows.length,
                viewportColumns: Object.keys(this._get_viewport_column_map()),
                displayedColumns: Object.keys(this._get_displayed_column_map()),
                badgeColumns: badge_columns.map(describe),
                sumColumns: sum_columns.map(describe),
                variationColumns: variation_columns.map(describe),
                sumWidgetColumns: Array.from(this.sum_widgets.keys()).map(describe),
                badgeLikeColumns: this.columns.filter(column =>
                    this._is_badge_column(column)).map(describe),
                summaryItems: summary_items.map(item => ({
                    label: item.label,
                    count: item.count,
                    className: item.className || '',
                })),
            }));
        },
        _get_summary_column_load_fields: function(column) {
            var attributes = column && column.attributes || {};
            var fields = [];
            if (attributes.name) {
                fields.push(attributes.name);
            }
            if (this._is_badge_column(column)) {
                fields = fields.concat(this._get_badge_column_load_fields(column));
            }
            if (attributes.widget == 'variation') {
                if (attributes.variation_previous) {
                    fields.push(attributes.variation_previous);
                }
                if (attributes.variation_group_by) {
                    fields.push(attributes.variation_group_by);
                }
            }
            if (attributes.sum_variation == '1' && attributes.sum_previous) {
                fields.push(attributes.sum_previous);
            }
            return fields.filter((field, index) =>
                field && fields.indexOf(field) == index);
        },
        _load_summary_column_values: function(rows, columns, reason) {
            if (this._summary_sum_loading) {
                return null;
            }
            var promises = [];
            var field_names = [];
            columns.forEach(column => {
                var names = this._get_summary_column_load_fields(column);
                field_names = field_names.concat(names);
                rows.forEach(row => {
                    var record = row && row.__record;
                    names.forEach(name => {
                        if (record && record.id >= 0 &&
                                !record.get_loaded([name])) {
                            promises.push(record.load(name, true, false));
                        }
                    });
                });
            });
            if (!promises.length) {
                return null;
            }
            this._summary_sum_loading = true;
            if (window.console) {
                console.info('[AGGRID][SUMMARY_SUM_LOAD_JSON]',
                    JSON.stringify({
                        instanceId: this._debug_id,
                        viewId: this.view_id,
                        model: this.screen && this.screen.model_name,
                        reason: reason || 'summary',
                        fields: field_names.filter((field, index) =>
                            field && field_names.indexOf(field) == index),
                        promiseCount: promises.length,
                    }));
            }
            return jQuery.when.apply(jQuery, promises).always(() => {
                this._summary_sum_loading = false;
            });
        },
        _load_summary_sum_values: function(rows, columns) {
            return this._load_summary_column_values(rows, columns, 'sum');
        },
        _load_summary_badge_values: function(rows, columns) {
            return this._load_summary_column_values(rows, columns, 'badge');
        },
        _append_sum_summary_item: function(summary_items, rows, column) {
            var sum = null;
            var name = column && column.attributes && column.attributes.name;
            var field = name ? this.screen.model.fields[name] : null;
            var previous_sum = null;
            var previous_name = column && column.attributes &&
                column.attributes.sum_previous;
            var with_variation = column && column.attributes &&
                column.attributes.sum_variation == '1' && previous_name;
            rows.forEach(row => {
                var record = row && row.__record;
                if (!field || !record || record.id >= 0 &&
                        !record.get_loaded([name])) {
                    return;
                }
                var value = field.get(record);
                if (value && value.isTimeDelta) {
                    value = value.asSeconds();
                }
                if (value !== null && value !== undefined && value !== '') {
                    sum = sum === null ? value : sum + value;
                }
                if (with_variation) {
                    var previous = this._get_record_raw_field_value(
                        record, previous_name);
                    if (previous !== null && previous !== undefined &&
                            previous !== '') {
                        previous = Number(previous);
                        if (isFinite(previous)) {
                            previous_sum = previous_sum === null ?
                                previous : previous_sum + previous;
                        }
                    }
                }
            });
            var variation = null;
            if (with_variation && sum !== null && previous_sum !== null &&
                    previous_sum !== 0) {
                variation = ((sum - previous_sum) /
                    Math.abs(previous_sum)) * 100;
            }
            summary_items.push({
                label: column.attributes.string || column.attributes.name,
                count: sum === null ? '-' :
                    this._format_aggregate_value(column, sum),
                color: '#145369',
                className: 'ag-grid-summary-item-sum',
                variationText: variation === null ? '' :
                    this._format_sum_variation_percent(variation),
                variationClass: variation === null ? '' :
                    (variation < 0 ? 'ag-grid-summary-variation-negative' :
                        'ag-grid-summary-variation-positive'),
            });
        },
        _get_record_raw_field_value: function(record, name) {
            if (!record || !name) {
                return null;
            }
            var field = this.screen.model.fields[name];
            if (!field || record.id >= 0 && !record.get_loaded([name])) {
                return null;
            }
            var value = field.get(record);
            if (value && value.isTimeDelta) {
                value = value.asSeconds();
            }
            if (value === null || value === undefined || value === '') {
                return null;
            }
            return value;
        },
        _get_record_text_field_value: function(record, name) {
            if (!record || !name) {
                return '';
            }
            var field = this.screen.model.fields[name];
            if (!field || record.id >= 0 && !record.get_loaded([name])) {
                return '';
            }
            try {
                return field.get_client(record);
            } catch (error) {
                var value = this._get_record_raw_field_value(record, name);
                return value === null || value === undefined ? '' : String(value);
            }
        },
        _format_variation_percent: function(value) {
            var locale = Sao.i18n.BC47(Sao.i18n.getlang());
            var sign = value > 0 ? '+' : '';
            return sign + value.toLocaleString(locale, {
                minimumFractionDigits: 2,
                maximumFractionDigits: 2,
            }) + '%';
        },
        _format_sum_variation_percent: function(value) {
            var locale = Sao.i18n.BC47(Sao.i18n.getlang());
            var sign = value >= 0 ? '+' : '';
            return sign + value.toLocaleString(locale, {
                minimumFractionDigits: 0,
                maximumFractionDigits: 2,
            }) + '%';
        },
        _append_variation_summary_items: function(summary_items, rows, column) {
            var attributes = column && column.attributes || {};
            var current_name = attributes.name;
            var previous_name = attributes.variation_previous;
            var group_name = attributes.variation_group_by;
            var groups = {};
            rows.forEach(row => {
                var record = row && row.__record;
                if (!record) {
                    return;
                }
                var current = Number(this._get_record_raw_field_value(
                    record, current_name));
                var previous = Number(this._get_record_raw_field_value(
                    record, previous_name));
                var group = this._get_record_text_field_value(
                    record, group_name) || Sao.i18n.gettext('(Empty)');
                if (!isFinite(current) || !isFinite(previous) || previous === 0) {
                    return;
                }
                if (!groups[group]) {
                    groups[group] = {
                        current: 0,
                        previous: 0,
                    };
                }
                groups[group].current += current;
                groups[group].previous += previous;
            });
            Object.keys(groups).sort((a, b) => String(a).localeCompare(
                String(b), undefined, {numeric: true, sensitivity: 'base'}))
                .forEach(group => {
                    var values = groups[group];
                    if (!values.previous) {
                        return;
                    }
                    var variation = ((values.current - values.previous) /
                        Math.abs(values.previous)) * 100;
                    summary_items.push({
                        label: group,
                        count: this._format_variation_percent(variation),
                        color: variation > 0 ? '#16a34a' :
                            variation < 0 ? '#dc2626' : '#64748b',
                        className: 'ag-grid-summary-item-variation' +
                            (variation > 0 ? ' ag-grid-summary-variation-up' :
                                variation < 0 ?
                                    ' ag-grid-summary-variation-down' :
                                    ' ag-grid-summary-variation-flat'),
                    });
                });
        },
        _get_default_badge_summary_color: function(value) {
            var key = String(value || '').toLowerCase();
            var palette = {
                open: '#2563eb',
                physic: '#8b5cf6',
                physical: '#8b5cf6',
                scheduled: '#f59e0b',
                shipped: '#16a34a',
                unshipped: '#ef4444',
                done: '#16a34a',
                cancelled: '#ef4444',
                canceled: '#ef4444',
                draft: '#64748b',
                quote: '#2563eb',
                confirmed: '#16a34a',
                processing: '#f59e0b',
            };
            return palette[key] || '#0f766e';
        },
        _append_badge_summary_items: function(summary_items, rows, column, source) {
            var name = source && source.name ||
                column && column.attributes && column.attributes.name;
            if (!name) {
                return false;
            }
            var counts = {};
            var samples = {};
            var order = [];
            var empty = Sao.i18n.gettext('(Empty)');
            rows.forEach(row => {
                var raw_value = '';
                if (column && row && row.__record) {
                    raw_value = this._get_column_text_value(
                        column, row.__record);
                }
                if (!raw_value && row) {
                    raw_value = row[name];
                }
                var value = raw_value ?
                    this._normalize_badge_text_key(raw_value) : empty;
                if (value == empty) {
                    return;
                }
                if (!Object.prototype.hasOwnProperty.call(counts, value)) {
                    counts[value] = 0;
                    samples[value] = row;
                    order.push(value);
                }
                counts[value] += 1;
            });
            var color_order = this._get_badge_color_order(
                column && column.attributes && column.attributes.badge_colors);
            var color_order_lower = color_order.map(value =>
                String(value).toLowerCase());
            order.sort((a, b) => {
                var a_index = color_order.indexOf(a);
                var b_index = color_order.indexOf(b);
                if (a_index < 0) {
                    a_index = color_order_lower.indexOf(
                        String(a).toLowerCase());
                }
                if (b_index < 0) {
                    b_index = color_order_lower.indexOf(
                        String(b).toLowerCase());
                }
                if (a_index >= 0 || b_index >= 0) {
                    return (a_index < 0 ? 999 : a_index) -
                        (b_index < 0 ? 999 : b_index);
                }
                return counts[b] - counts[a] ||
                    String(a).localeCompare(String(b), undefined, {
                        numeric: true,
                        sensitivity: 'base',
                    });
            });
            order.forEach(value => {
                var colors = column ? this._get_badge_display_colors(
                    column,
                    samples[value] && samples[value].__record,
                    value) : {};
                summary_items.push({
                    label: value,
                    count: counts[value],
                    color: colors.color || colors.accent ||
                        this._get_default_badge_summary_color(value),
                });
            });
            return Boolean(order.length);
        },
        _update_summary_bar: function() {
            if (!this.summary_bar || !this.summary_bar.length) {
                return;
            }
            var rows = this._get_visible_record_rows();
            var locale = Sao.i18n.BC47(Sao.i18n.getlang());
            this.summary_bar.empty();
            this.summary_bar.append(this._render_summary_item(
                Sao.i18n.gettext('Rows'),
                rows.length.toLocaleString(locale),
                '#145369'));
            var summary_items = [];
            var sum_columns = this._get_summary_sum_columns();
            var badge_columns = this._get_summary_badge_columns();
            var variation_columns = this._get_summary_variation_columns();
            var summary_load = this._load_summary_column_values(
                rows, badge_columns.concat(sum_columns, variation_columns),
                'summary');
            if (summary_load) {
                summary_load.then(() => {
                    if (this.gridApi && this.gridApi.refreshCells) {
                        this.gridApi.refreshCells({force: true});
                    }
                    this._update_summary_bar();
                });
            }
            badge_columns.forEach(column => {
                this._append_badge_summary_items(
                    summary_items, rows, column);
            });
            sum_columns.forEach(column => {
                this._append_sum_summary_item(summary_items, rows, column);
            });
            variation_columns.forEach(column => {
                this._append_variation_summary_items(
                    summary_items, rows, column);
            });
            this._trace_summary_bar(
                rows, badge_columns, sum_columns, variation_columns,
                summary_items);
            summary_items.forEach(item => {
                this.summary_bar.append(this._render_summary_item(
                    item.label,
                    typeof item.count == 'number' ?
                        item.count.toLocaleString(locale) : item.count,
                    item.color,
                    item.className,
                    item.variationText,
                    item.variationClass));
            });
            if (this.grid_host && this.grid_host.length && !this._resizing_summary_bar) {
                this._resizing_summary_bar = true;
                window.requestAnimationFrame(() => {
                    this._resizing_summary_bar = false;
                    this._resize_grid_host();
                });
            }
        },
        on_copy: function() {
            var data = [];
            this.selected_records.forEach(record => {
                var values = [];
                this.columns.forEach(col => {
                    if (!col.attributes.name ||
                            !this._is_column_visible_for_copy(
                                col.attributes.name) ||
                            col instanceof Sao.View.Tree.ButtonColumn) {
                        return;
                    }
                    var text;
                    if (!record.is_loaded(col.attributes.name)) {
                        try {
                            record.load(col.attributes.name, false, false);
                            text = col.get_textual_value(record);
                        } catch (error) {
                            text = Sao.i18n.gettext('#ERROR');
                        }
                    } else {
                        text = col.get_textual_value(record);
                    }
                    values.push('"' + String(text).replace('"', '""') + '"');
                });
                data.push(values.join('\t'));
            });
            if (navigator.clipboard) {
                navigator.clipboard.writeText(data.join('\n'));
            }
        },
        display: function(selected) {
            if (!this.gridApi) {
                return jQuery.when();
            }
            this._trace_aggrid_lifecycle('display-start', {
                groupSize: this.group ? this.group.length : null,
                selectedArgCount: selected ? selected.length : null,
                activeValueFilters: this._describe_sao_value_filters(),
            });
            var previous_selected_ids = new Set(this._selected_ids || []);
            if ((selected === undefined || selected === null) &&
                    previous_selected_ids.size) {
                selected = Array.from(previous_selected_ids)
                    .map(id => [id]);
            }
            var grouping_load = this._load_grouping_values();
            if (grouping_load) {
                return grouping_load.then(() => this.display(selected));
            }
            var value_filter_load = this._load_sao_value_filter_values();
            if (value_filter_load) {
                return value_filter_load.then(() => this.display(selected));
            }
            var badge_load = this._load_badge_column_values();
            if (badge_load) {
                return badge_load.then(() => this.display(selected));
            }
            this._grid_columns = this._build_column_defs();
            this._reconcile_sao_value_filters_with_rows();
            this._sao_value_filter_empty_logged = false;
            var row_data = this._get_row_data();
            this._trace_aggrid_lifecycle('display-row-data', {
                groupSize: this.group ? this.group.length : null,
                rowDataCount: row_data.length,
                activeValueFilters: this._describe_sao_value_filters(),
                sampleRows: row_data.slice(0, 8).map(row => {
                    var sample = {
                        id: row.__record && row.__record.id || row.__id,
                        kind: row.__kind,
                    };
                    Object.keys(this._sao_value_filters || {}).forEach(col_id => {
                        sample[col_id] = row[col_id];
                    });
                    return sample;
                }),
            });
            if (this._force_rowdata_reset) {
                this._syncing_grid = true;
                try {
                    if (this.gridApi.deselectAll) {
                        this.gridApi.deselectAll();
                    }
                    if (this.gridApi.setGridOption) {
                        this.gridApi.setGridOption('rowData', []);
                    } else {
                        this.gridApi.setRowData([]);
                    }
                } finally {
                    this._syncing_grid = false;
                }
                this._force_rowdata_reset = false;
            }
            this._syncing_grid = true;
            try {
                this._trace_aggrid_column_resize('display-before-set-column-defs');
                if (this.gridApi.setGridOption) {
                    this.gridApi.setGridOption('columnDefs', this._grid_columns);
                    this.gridApi.setGridOption('rowData', row_data);
                } else {
                    this.gridApi.setColumnDefs(this._grid_columns);
                    this.gridApi.setRowData(row_data);
                }
                this._trace_aggrid_column_resize('display-after-set-column-defs');
            } finally {
                this._syncing_grid = false;
            }
            this._resize_grid_host();
            this._refresh_grid_layout_after_row_data(row_data);
            var restored_state = this._apply_persisted_grid_state();
            this._state_restore_done = this._state_restore_done || restored_state;
            this._sync_sort_state();
            this._apply_grouping_column_order();
            this._enforce_minimum_column_widths();
            this._sync_selection(selected);
            this._render_column_menu();
            this._render_grouping_panel();
            this._render_layout_panel();
            this._update_layout_button();
            this._render_filter_logic_panel();
            this._update_copy_state();
            this._update_summary_bar();
            if (this._has_active_sao_value_filters()) {
                this._apply_sao_value_filters({refreshRowData: false});
            }
            this._trace_aggrid_lifecycle('display-end', {
                rowDataCount: row_data.length,
                displayedRowCount: this.gridApi && this.gridApi.getDisplayedRowCount ?
                    this.gridApi.getDisplayedRowCount() : null,
                activeValueFilters: this._describe_sao_value_filters(),
            });
            if (this._sao_value_filter_restore_guard) {
                window.setTimeout(() => {
                    this._sao_value_filter_restore_guard = false;
                    this._restored_value_filter_state = null;
                }, 500);
            }
            return jQuery.when();
        },
        _resize_grid_host: function() {
            if (!this.grid_host || !this.grid_host.length || !window) {
                return;
            }
            var rect = this.grid_host[0].getBoundingClientRect();
            var content_box = this.grid_host.closest('.content-box.ag-grid-content-box');
            var screen_container = this.grid_host.closest('.screen-container');
            var modal_body = this.grid_host.closest('.modal-body');
            var toolbar_height = this.toolbar && this.toolbar.length ?
                Math.ceil(this.toolbar.outerHeight(true)) : 0;
            var summary_height = this.summary_bar && this.summary_bar.length ?
                Math.ceil(this.summary_bar.outerHeight(true)) : 0;
            var compact_density = this._is_compact_density();
            var external_scroll_height = compact_density ? 16 : 20;
            var bottom_margin = compact_density ? 50 : 96;
            var available = window.innerHeight - rect.top -
                external_scroll_height - bottom_margin;
            if (content_box.length) {
                var content_rect = content_box[0].getBoundingClientRect();
                available = Math.min(
                    available,
                    window.innerHeight - content_rect.top - toolbar_height -
                        summary_height - external_scroll_height - bottom_margin);
            }
            var embedded_auto_height = this.is_embedded_one2many &&
                !this._embedded_height;
            var compact_embedded_max = this._density_px(
                '--tradon-density-ag-embedded-max-height', 225);
            var compact_min_height = (modal_body.length || embedded_auto_height) ?
                (compact_density ? 117 : 170) : (compact_density ? 198 : 260);
            var readable_min_height = modal_body.length ? 240 :
                (embedded_auto_height ? (compact_density ? 144 : 220) :
                    (compact_density ? 270 : 420));
            var maximum_height = modal_body.length ? 360 :
                (embedded_auto_height ?
                    (compact_density ? compact_embedded_max : 300) :
                    Math.max(readable_min_height, available));
            var height;
            if (embedded_auto_height) {
                height = this._embedded_grid_auto_height();
            } else if (this.is_embedded_one2many && this._embedded_height) {
                height = Math.max(80, this._embedded_height - toolbar_height -
                    summary_height - external_scroll_height);
            } else if (available >= compact_min_height) {
                height = Math.max(
                    compact_min_height,
                    Math.min(maximum_height, available));
            } else {
                height = Math.max(
                    readable_min_height,
                    Math.min(maximum_height,
                        window.innerHeight - rect.top - bottom_margin));
            }
            var width_right = window.innerWidth - 12;
            if (screen_container.length) {
                width_right = Math.min(
                    width_right,
                    screen_container[0].getBoundingClientRect().right);
            }
            var available_width = Math.max(320, Math.floor(width_right - rect.left));
            this.grid_host.css({
                height: height + 'px',
                minHeight: height + 'px',
                flexBasis: height + 'px',
                flexShrink: 0,
                width: available_width + 'px',
                maxWidth: available_width + 'px',
            });
            var container_height = height + toolbar_height + summary_height +
                external_scroll_height;
            this.el.css({
                height: container_height + 'px',
                minHeight: container_height + 'px',
                flexBasis: container_height + 'px',
                flexShrink: 0,
                width: available_width + 'px',
                maxWidth: available_width + 'px',
            });
            if (content_box.length) {
                content_box.css({
                    height: container_height + 'px',
                    minHeight: container_height + 'px',
                    flexBasis: container_height + 'px',
                    flexShrink: 0,
                    width: available_width + 'px',
                    maxWidth: available_width + 'px',
                });
            }
            if (this.horizontal_scroll && this.horizontal_scroll.length) {
                this.horizontal_scroll.css({
                    width: available_width + 'px',
                    maxWidth: available_width + 'px',
                });
            }
        },
        set_embedded_height: function(height) {
            height = parseInt(height, 10);
            if (!this.is_embedded_one2many || isNaN(height) || height <= 0) {
                return;
            }
            this._embedded_height = height;
            this._resize_grid_host();
            if (this.gridApi && this.gridApi.doLayout) {
                this.gridApi.doLayout();
            }
        },
        _refresh_grid_layout_after_row_data: function(row_data) {
            if (!this.gridApi) {
                return;
            }
            var refresh = () => {
                if (!this.gridApi) {
                    return;
                }
                if (this.gridApi.resetRowHeights) {
                    this.gridApi.resetRowHeights();
                }
                if (this.gridApi.refreshCells) {
                    this.gridApi.refreshCells({force: true});
                }
                if (this.gridApi.redrawRows) {
                    this.gridApi.redrawRows();
                }
                if (this.gridApi.doLayout) {
                    this.gridApi.doLayout();
                }
                this._force_horizontal_scroll_width();
            };
            refresh();
            window.setTimeout(() => {
                this._resize_grid_host();
                refresh();
            }, 0);
            window.setTimeout(() => {
                this._resize_grid_host();
                refresh();
            }, 120);
        },
        _enforce_minimum_column_widths: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !column_api.getColumnState ||
                    !column_api.applyColumnState) {
                return;
            }
            var adjusted = [];
            column_api.getColumnState().forEach(state => {
                if (!state || state.colId == '__selection__') {
                    return;
                }
                var column = this._find_column_by_id(state.colId);
                if (!column) {
                    return;
                }
                var minimum = this._get_minimum_column_width(column);
                if (state.width < minimum) {
                    adjusted.push({
                        colId: state.colId,
                        width: minimum,
                    });
                }
            });
            if (!adjusted.length) {
                return;
            }
            this._trace_aggrid_column_resize('enforce-minimum-column-widths',
                null, {adjusted: adjusted});
            this._syncing_grid = true;
            column_api.applyColumnState({
                state: adjusted,
                applyOrder: false,
            });
            this._syncing_grid = false;
        },
        _sync_external_horizontal_scroll: function() {
            if (this._syncing_external_horizontal_scroll ||
                    !this.horizontal_scroll || !this.horizontal_scroll.length ||
                    !this.grid_host || !this.grid_host.length) {
                return;
            }
            var left = this.horizontal_scroll[0].scrollLeft;
            var targets = [
                '.ag-body-horizontal-scroll-viewport',
                '.ag-center-cols-viewport',
            ];
            this._syncing_external_horizontal_scroll = true;
            targets.forEach(selector => {
                var node = this.grid_host[0].querySelector(selector);
                if (!node) {
                    return;
                }
                node.scrollLeft = left;
                try {
                    node.dispatchEvent(new Event('scroll', {bubbles: true}));
                } catch (error) {
                    var event = document.createEvent('Event');
                    event.initEvent('scroll', true, true);
                    node.dispatchEvent(event);
                }
            });
            this._syncing_external_horizontal_scroll = false;
        },
        _force_horizontal_scroll_width: function() {
            var column_api = this.gridColumnApi || this.gridApi;
            if (!column_api || !this.grid_host || !this.grid_host.length) {
                return;
            }
            var displayed_columns = column_api.getAllDisplayedColumns ?
                column_api.getAllDisplayedColumns() : [];
            if (!displayed_columns.length) {
                return;
            }
            var minimum_total = 0;
            displayed_columns.forEach(column => {
                if (!column || !column.getColId) {
                    return;
                }
                var col_id = column.getColId();
                minimum_total += column.getActualWidth ?
                    column.getActualWidth() : (col_id == '__selection__' ? 48 : 140);
            });
            minimum_total += 72;
            var viewport = this.grid_host[0].querySelector(
                '.ag-center-cols-viewport');
            var body_viewport = this.grid_host[0].querySelector(
                '.ag-body-viewport');
            var horizontal_container = this.grid_host[0].querySelector(
                '.ag-body-horizontal-scroll-container');
            var horizontal_viewport = this.grid_host[0].querySelector(
                '.ag-body-horizontal-scroll-viewport');
            var viewport_width = viewport ? viewport.clientWidth :
                this.grid_host[0].clientWidth;
            if (horizontal_container && minimum_total > viewport_width) {
                horizontal_container.style.width = minimum_total + 'px';
                horizontal_container.style.minWidth = minimum_total + 'px';
            }
            if (viewport && minimum_total > viewport_width) {
                viewport.style.minWidth = '0px';
            }
            if (body_viewport && minimum_total > viewport_width) {
                body_viewport.style.overflowX = 'hidden';
            }
            if (horizontal_viewport) {
                horizontal_viewport.style.overflowX = 'scroll';
            }
            var show_external_scroll = minimum_total > viewport_width + 1;
            if (this.horizontal_scroll && this.horizontal_scroll.length &&
                    this.horizontal_scroll_inner && this.horizontal_scroll_inner.length) {
                this.horizontal_scroll.toggleClass(
                    'ag-grid-tree-external-horizontal-scroll-visible',
                    show_external_scroll);
                this.horizontal_scroll_inner.css({
                    width: Math.max(minimum_total, viewport_width) + 'px',
                    minWidth: Math.max(minimum_total, viewport_width) + 'px',
                });
                if (horizontal_viewport) {
                    this.horizontal_scroll[0].scrollLeft =
                        horizontal_viewport.scrollLeft || 0;
                }
            }
            if (this.gridApi && this.gridApi.doLayout) {
                this.gridApi.doLayout();
            }
        },
    });

    Sao.View.AGGridTree.get_support_diagnostics = function(
            view_id, screen, xml, children_field) {
        var diagnostics = {
            viewId: view_id,
            model: screen && screen.model_name || null,
            useAgGrid: Boolean(Sao.config.use_ag_grid),
            hasChildrenField: Boolean(children_field),
            agGridLoaded: Boolean(window.agGrid &&
                (typeof window.agGrid.createGrid == 'function' ||
                    typeof window.agGrid.Grid == 'function')),
            editable: false,
            xmlEditable: null,
            screenReadonly: Boolean(
                screen && screen.attributes && screen.attributes.readonly),
            excludeField: screen && screen.exclude_field || null,
            preValidate: Boolean(
                screen && (screen.pre_validate ||
                    (screen.attributes && screen.attributes.pre_validate))),
            searchWindow: Boolean(
                screen && screen.attributes && screen.attributes.search_window),
            relationWidget: screen && screen.attributes &&
                screen.attributes.relation_widget || null,
            hasParentGroup: Boolean(
                screen && screen.group && screen.group.parent),
            childName: screen && screen.group && screen.group.child_name || null,
            parentName: screen && screen.group && screen.group.parent_name || null,
            embeddedContext: false,
            selectedRenderer: 'legacy_tree',
            fallbackReason: null,
        };
        var attributes = {};
        var node = xml && xml.children ? xml.children()[0] : null;
        if (node && node.attributes) {
            for (const attribute of node.attributes) {
                attributes[attribute.name] = attribute.value;
            }
        }
        diagnostics.xmlEditable = parseInt(attributes.editable || 0, 10);
        diagnostics.editable = Boolean(
            diagnostics.xmlEditable && !diagnostics.screenReadonly);
        diagnostics.embeddedContext = Boolean(
            diagnostics.hasParentGroup ||
            diagnostics.excludeField ||
            diagnostics.preValidate ||
            diagnostics.childName);
        if (!diagnostics.useAgGrid) {
            diagnostics.fallbackReason = 'config_disabled';
        } else if (diagnostics.hasChildrenField) {
            diagnostics.fallbackReason = 'children_field';
        } else if (!diagnostics.agGridLoaded) {
            diagnostics.fallbackReason = 'ag_grid_not_loaded';
        } else if (diagnostics.searchWindow) {
            diagnostics.fallbackReason = 'search_window';
        } else if (diagnostics.relationWidget) {
            diagnostics.fallbackReason = diagnostics.relationWidget +
                '_relation_widget';
        } else if (diagnostics.embeddedContext && diagnostics.editable) {
            diagnostics.fallbackReason = 'embedded_relation_tree';
        } else if (diagnostics.editable) {
            diagnostics.fallbackReason = 'editable_view';
        } else {
            diagnostics.selectedRenderer = 'ag_grid';
            diagnostics.fallbackReason = null;
        }
        return diagnostics;
    };

    Sao.View.AGGridTree.is_supported = function(screen, xml, children_field) {
        var diagnostics = Sao.View.AGGridTree.get_support_diagnostics(
            null, screen, xml, children_field);
        return diagnostics.selectedRenderer == 'ag_grid';
    };

    Sao.View.get_tree_view = function(view_id, screen, xml, children_field) {
        var diagnostics = Sao.View.AGGridTree &&
            Sao.View.AGGridTree.get_support_diagnostics ?
            Sao.View.AGGridTree.get_support_diagnostics(
                view_id, screen, xml, children_field) : null;
        if (diagnostics && diagnostics.selectedRenderer == 'ag_grid') {
            return new Sao.View.AGGridTree(
                view_id, screen, xml, children_field);
        }
        return new Sao.View.Tree(view_id, screen, xml, children_field);
    };

    Sao.View.Tree = Sao.class_(Sao.View, {
        view_type: 'tree',
        xml_parser: Sao.View.TreeXMLViewParser,
        draggable: false,
        display_size: Sao.config.display_size,
        init: function(view_id, screen, xml, children_field) {
            this.children_field = children_field;
            this.optionals = [];
            this.sum_widgets = new Map();
            this.columns = [];
            this.selection_mode = (screen.attributes.selection_mode ||
                Sao.common.SELECTION_MULTIPLE);
            this.el = jQuery('<div/>', {
                'class': 'tree-container',
            })
            // Prevent Chrome based browser to compute a min-content
            // such that only this table has scrollbar if needed
                .css('display', 'grid');
            this.scrollbar = jQuery('<div/>')
                .appendTo(jQuery('<div/>', {
                    'class': 'scrollbar responsive',
                }).appendTo(this.el));
            this.treeview = jQuery('<div/>', {
                'class': 'treeview responsive'
            }).appendTo(this.el);

            // Synchronize both scrollbars
            this.treeview.scroll(() => {
                this.scrollbar.parent().scrollLeft(this.treeview.scrollLeft());
            });
            this.scrollbar.parent().scroll(() => {
                this.treeview.scrollLeft(this.scrollbar.parent().scrollLeft());
            });

            this.expanded = new Set();

            Sao.View.Tree._super.init.call(this, view_id, screen, xml);

            // Table of records
            this.rows = [];
            this.edited_row = null;
            this.table = jQuery('<table/>', {
                'class': 'tree table table-hover table-condensed'
            });
            if (this.editable) {
                this.table.addClass('table-bordered');
            }
            this.treeview.append(this.table);
            this.colgroup = jQuery('<colgroup/>').appendTo(this.table);
            var col = jQuery('<col/>', {
                'class': 'selection-state',
            }).appendTo(this.colgroup);
            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                col.css('width', 0);
            }
            this.thead = jQuery('<thead/>').appendTo(this.table);
            var tr = jQuery('<tr/>');
            var th = jQuery('<th/>', {
                'class': 'selection-state'
            });
            this.selection = jQuery('<input/>', {
                'type': 'checkbox',
            });
            this.selection.change(this.selection_changed.bind(this));
            if (view_id != 1081){
                th.append(this.selection);
            }
            tr.append(th);
            this.thead.append(tr);

            this.tfoot = null;
            var sum_row;
            if (this.sum_widgets.size) {
                sum_row = jQuery('<tr/>');
                sum_row.append(jQuery('<th/>'));
                this.tfoot = jQuery('<tfoot/>');
                this.tfoot.append(sum_row);
                // insert before thead to not hide drop-down from thead
                this.table.prepend(this.tfoot);
            }

            if (this.children_field) {
                this.expander = jQuery('<span/>', {
                    'class': 'expander',
                }).append(jQuery('<img/>', {
                    'tabindex': 0,
                    'class': 'icon',
                }));
                this.update_expander('more');
                this.expander.on('click keypress',
                    Sao.common.click_press(this.unfold.bind(this)));
                Sao.common.ICONFACTORY.get_icon_url(
                    'tryton-unfold-' + this.expander.action)
                    .then(url => {
                        this.expander.children().attr('src', url);
                    });
            }

            for (const column of this.columns) {
                col = jQuery('<col/>', {
                    'class': column.attributes.widget,
                }).appendTo(this.colgroup);
                th = jQuery('<th/>', {
                    'class': column.attributes.widget,
                });
                th.uniqueId();
                var label = jQuery('<label/>')
                    .text(column.attributes.string)
                    .attr('title', column.attributes.string);
                if (this.editable) {
                    if (column.attributes.required) {
                        label.addClass('required');
                    }
                    if (!column.attributes.readonly) {
                        label.addClass('editable');
                    }
                }
                if (column.attributes.help) {
                    label.attr('title', column.attributes.help);
                }
                if (column.sortable) {
                    var arrow = jQuery('<img/>', {
                        'class': 'icon',
                    });
                    label.append(arrow);
                    column.arrow = arrow;
                    th.click(column, this.sort_model.bind(this));
                    label.addClass('sortable');
                }
                if (view_id === 1081){
                    tr.append(th);
                }
                else {
                    tr.append(th.append(label));
                }
                column.header = th;
                column.col = col;

                column.footers = [];
                if (this.sum_widgets.size) {
                    var total_cell = jQuery('<th/>', {
                        'class': column.class_,
                    });
                    if (this.sum_widgets.has(column)) {
                        var sum_label = this.sum_widgets.get(column)[0];
                        var sum_value = this.sum_widgets.get(column)[1];
                        total_cell.append(sum_label);
                        total_cell.append(sum_value);
                    }
                    sum_row.append(total_cell);
                    column.footers.push(total_cell);
                }
            }
            this.tbody = jQuery('<tbody/>');
            this.table.append(this.tbody);

            this.colgroup.prepend(jQuery('<col/>', {
                'class': 'tree-menu',
            }));
            this.thead.children().prepend(jQuery('<th/>', {
                'class': 'tree-menu',
            }));
            if (this.tfoot) {
                this.tfoot.children().prepend(jQuery('<th/>'));
            }

            this.set_drag_and_drop();

            th = this.thead.find('th').first();
            if (this.optionals.length) {
                th.addClass('optional');
            }
            var menu = jQuery('<ul/>', {
                'class': 'dropdown-menu',
            }).click(function(evt) {
                evt.stopImmediatePropagation();
            });
            var dropdown = jQuery('<div/>', {
                'class': 'dropdown',
            }).append(jQuery('<a/>', {
                'data-toggle': 'dropdown',
                'aria-haspopup': true,
                'aria-expanded': false,
                'title': Sao.i18n.gettext("Menu"),
            }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-menu'))
                .click(menu, this.tree_menu.bind(this))
            ).append(menu);
            dropdown.on('hide.bs.dropdown', () => {
                this.save_optional(true);
                Sao.common.set_overflow(th, 'hide');
            });
            dropdown.on('show.bs.dropdown', () => {
                Sao.common.set_overflow(th, 'show');
            });
            if (view_id != 1081){
                th.append(dropdown);
            }
        },
        tree_menu: function(evt) {
            const toggle = evt => {
                var column = evt.data;
                column.set_visible(jQuery(evt.delegateTarget).prop('checked'));
                this.save_optional();
                this.display();
                for (const row of this.rows) {
                    row.update_visible();
                }
            };
            var menu = evt.data;
            menu.empty();
            for (const optional of this.optionals) {
                menu.append(jQuery('<li/>', {
                    'role': 'presentation',
                }).append(jQuery('<a/>', {
                    'role': 'menuitem',
                }).append(jQuery('<label/>', {
                    'class': 'checkbox',
                }).append(jQuery('<input/>', {
                    'type': 'checkbox',
                    'checked': optional.get_visible(),
                }).change(optional, toggle))
                    .append(' ' + optional.attributes.string))));
            }
            if (this.optionals.length) {
                menu.append(jQuery('<li/>', {
                    'role': 'separator',
                    'class': 'divider hidden-xs',
                }));
            }
            menu.append(jQuery('<li/>', {
                'role': 'presentation',
                'class': (
                    this.selected_records.length || !navigator.clipboard?
                    '' : 'disabled'),
            }).append(jQuery('<a/>', {
                'role': 'menuitem',
                'href': '#',
                'tabindex': -1,
            }).text(' ' + Sao.i18n.gettext("Copy Selected Rows"))
                .prepend(
                    Sao.common.ICONFACTORY.get_icon_img('tryton-copy', {
                        'aria-hidden': 'true',
                    }))
                .click(evt => {
                    evt.preventDefault();
                    this.on_copy();
                    menu.dropdown('toggle');
                })));
        },
        save_optional: function(store=true) {
            if (!this.optionals.length) {
                return;
            }
            var fields = {};
            for (const column of this.optionals) {
                fields[column.attributes.name] = !column.get_visible();
            }
            if (store) {
                var tree_optional_model = new Sao.Model(
                    'ir.ui.view_tree_optional');
                tree_optional_model.execute('set_optional', [
                    this.view_id, fields], {});
            }
            Sao.Screen.tree_column_optional[this.view_id] = fields;
        },
        reset: function() {
            this.display_size = Sao.config.display_size;
        },
        get editable() {
            return (parseInt(this.attributes.editable || 0, 10) &&
                !this.screen.attributes.readonly);
        },
        get creatable() {
            if (this.editable) {
                if (this.attributes.creatable) {
                    return Boolean(parseInt(this.attributes.creatable, 10));
                } else {
                    return true;
                }
            } else {
                return false;
            }
        },
        unfold: function() {
            if (!this.expander) {
                return;
            }
            var action, unfold;
            if (this.expander.action == 'more') {
                unfold = function(row) {
                    if (row.is_selected() && !row.is_expanded()) {
                        row.expand_row();
                    }
                    row.rows.forEach(unfold);
                };
                action = 'less';
            } else {
                unfold = function(row) {
                    if (row.is_selected() && row.is_expanded()) {
                        row.collapse_row();
                    }
                    row.rows.forEach(unfold);
                };
                action = 'more';
            }
            this.rows.forEach(unfold);
            this.update_expander(action);
        },
        update_expander: function(action) {
            if (!this.expander) {
                return;
            }
            if (action) {
                this.expander.action = action;
            }
            Sao.common.ICONFACTORY.get_icon_url(
                'tryton-unfold-' + this.expander.action)
                .then(url => {
                    this.expander.children().attr('src', url);
                });
            if (jQuery.isEmptyObject(this.selected_records)) {
                this.expander.css('visibility', 'hidden');
            } else {
                this.expander.css('visibility', 'visible');
            }
        },
        sort_model: function(e){
            var column = e.data;
            var arrow = column.arrow;
            for (const col of this.columns) {
                if (col.arrow){
                    if (col != column && col.arrow.attr('src')) {
                        col.arrow.attr('src', '');
                    }
                }
            }
            this.screen.order = this.screen.default_order;
            if (arrow.data('order') == 'ASC') {
                arrow.data('order', 'DESC');
                Sao.common.ICONFACTORY.get_icon_url('tryton-arrow-up')
                    .then(function(url) {
                        arrow.attr('src', url);
                    });
                this.screen.order = [[column.attributes.name, 'DESC']];
            } else if (arrow.data('order') == 'DESC') {
                arrow.data('order', '');
                arrow.attr('src', '');
            } else {
                arrow.data('order', 'ASC');
                Sao.common.ICONFACTORY.get_icon_url('tryton-arrow-down')
                    .then(function(url) {
                        arrow.attr('src', url);
                    });
                this.screen.order = [[column.attributes.name, 'ASC']];
            }
            var unsaved_records = [];
            for (const unsaved_record of this.group) {
                if (unsaved_record.id < 0) {
                    unsaved_records = unsaved_record.group;
                }
            }
            var search_string = this.screen.screen_container.get_text();
            if ((!jQuery.isEmptyObject(unsaved_records)) ||
                    (this.screen.search_count == this.group.length) ||
                    (this.group.parent)) {
                this.screen.search_filter(search_string, true).then(
                ids => {
                    this.group.sort(function(a, b) {
                        a = ids.indexOf(a.id);
                        a = a < 0 ? ids.length : a;
                        b = ids.indexOf(b.id);
                        b = b < 0 ? ids.length : b;
                        if (a < b) {
                            return -1;
                        } else if (a > b) {
                            return 1;
                        } else {
                            return 0;
                        }
                    });
                    this.screen.display();
                });
            } else {
                this.screen.search_filter(search_string);
            }
        },
        update_arrow: function() {
            var order = this.screen.order,
                name = null,
                direction = null,
                icon = '';
            if (order && (order.length == 1)) {
                name = order[0][0];
                direction = order[0][1];
                if (direction) {
                    direction = direction.trim().split(' ', 1)[0];
                    icon = {
                        'ASC': 'tryton-arrow-down',
                        'DESC': 'tryton-arrow-up',
                    }[direction];
                }
            }
            this.columns.forEach(function(col) {
                var arrow = col.arrow;
                if (arrow) {
                    if (col.attributes.name != name) {
                        arrow.data('order', '');
                        arrow.attr('src', '');
                    } else {
                        arrow.data('order', direction);
                        Sao.common.ICONFACTORY.get_icon_url(icon)
                            .then(function(url) {
                                arrow.attr('src', url);
                            });
                    }
                }
            });
        },
        on_copy: function() {
            var data = [];
            this.selected_records.forEach((record) => {
                var values = [];
                this.columns.forEach((col) => {
                    if (!col.get_visible() || !col.attributes.name ||
                        col instanceof Sao.View.Tree.ButtonColumn) {
                        return;
                    }
                    var text;
                    if (!record.is_loaded(col.attributes.name)) {
                        try {
                            record.load(col.attributes.name, false, false);
                            text = col.get_textual_value(record);
                        } catch (e) {
                            Sao.Logger.error(
                                "Error loading " +
                                `${this.attributes.name} for ${record}`);
                            text = Sao.i18n.gettext('#ERROR');
                        }
                    } else {
                        text = col.get_textual_value(record);
                    }
                    values.push('"' + String(text).replace('"', '""') + '"');
                });
                data.push(values.join('\t'));
            });
            navigator.clipboard.writeText(data.join('\n'));
        },
        _add_drag_n_drop: function() {
            Sortable.create(this.tbody[0], {
                handle: '.draggable-handle',
                ghostClass: 'dragged-row'
            });
            this.tbody.on('dragstart', this.drag_data_get.bind(this));
            this.tbody.on('drop', this.drag_data_received.bind(this));
        },
        set_drag_and_drop: function() {
            var dnd = false;
            var children, parent_name;
            if (this.children_field) {
                children = this.screen.model.fields[this.children_field];
                if (children) {
                    parent_name = children.description.relation_field;
                    dnd = Boolean(this.widgets[parent_name]);
                }
            } else if (this.attributes.sequence) {
                dnd = true;
            }
            if (this.screen.readonly) {
                dnd = false;
            }

            this.draggable = dnd;
            if (dnd) {
                this.thead.find('th').first().addClass('draggable-handle');
                this._add_drag_n_drop();
            }
        },
        drag_data_get: function(evt) {
            var row_position = 0;
            var row_leaves = [];
            var set_dragged_row = function(row) {
                if (row.el[0] === evt.target) {
                    evt.originalEvent.dataTransfer.setData('path', row.path);
                    evt.originalEvent.dataTransfer.setData(
                        'position', row_position);
                }
                if (row.rows.length === 0) {
                    row_leaves.push(row);
                }
                row_position += 1;
                row.rows.forEach(set_dragged_row.bind(this));
            };
            this.rows.forEach(set_dragged_row.bind(this));
        },
        drag_data_received: function(evt) {
            var dataTransfer = evt.originalEvent.dataTransfer;
            var origin_path = dataTransfer.getData('path').split('.');
            if (origin_path.length === 0) {
                return ;
            }

            var row = this;
            while (origin_path.length > 0) {
                row = row.rows[origin_path[0]];
                origin_path = origin_path.slice(1);
            }
            var record = row.record;

            var parent_row = null;
            var dest_position;
            if ((evt.ctrlKey || evt.metaKey) && this.children_field) {
                parent_row = this._find_row(row.el.prev());
                dest_position = (parent_row || this).rows.length;
            } else {
                var sibling_row;
                if (evt.shiftKey) {
                    sibling_row = this._find_row(row.el.prev());
                    if (sibling_row) {
                        parent_row = sibling_row.parent_;
                        dest_position = (
                            (parent_row || this).rows.indexOf(sibling_row) + 1);
                    } else {
                        parent_row = null;
                        dest_position = 0;
                    }
                } else {
                    sibling_row = this._find_row(row.el.next());
                    if (sibling_row) {
                        parent_row = sibling_row.parent_;
                        dest_position = (
                            (parent_row || this).rows.indexOf(sibling_row));
                    } else {
                        parent_row = null;
                        dest_position = this.rows.length;
                    }
                }
            }

            var current_row = parent_row;
            while (current_row && (current_row != row)) {
                current_row = current_row.parent_;
            }
            if (current_row) {
                // There is a recursion cancel the drop
                // by moving the row at its previous place
                var original_position = dataTransfer.getData('position');
                var successor = jQuery(
                    this.tbody.children()[original_position]);
                successor.before(row.el);
                return;
            }

            var previous_row = row;
            var move_child = function(child_row) {
                previous_row.el.after(child_row.el);
                previous_row = child_row;
                child_row.rows.forEach(move_child);
            };
            row.rows.forEach(move_child);

            var dest_group_prm;
            var origin_group, origin_position;
            origin_group = record.group;
            origin_position = row.group_position;
            if (parent_row) {
                dest_group_prm = parent_row.record.children_group(
                    this.children_field);
            } else {
                dest_group_prm = jQuery.Deferred().resolve(this.group);
            }

            dest_group_prm.then(dest_group => {
                var origin_rows, dest_rows;
                if (row.parent_) {
                    origin_rows = row.parent_.rows;
                } else {
                    origin_rows = this.rows;
                }
                if (parent_row) {
                    dest_rows = parent_row.rows;
                } else {
                    dest_rows = this.rows;
                }

                if (origin_group === dest_group) {
                    if (origin_position < dest_position) {
                        dest_position -= 1;
                    }
                    origin_group.splice(origin_position, 1);
                    origin_group.splice(dest_position, 0, record);
                    origin_group.record_modified();
                } else {
                    origin_group.remove(record, true, true);
                    // Don't remove record from previous group
                    // as the new parent will change the parent
                    // This prevents concurrency conflict
                    origin_group.record_removed.splice(
                        origin_group.record_removed.indexOf(record));
                    dest_group.add(record, dest_position);
                    if (!record.parent_name) {
                        record.modified_fields[origin_group.parent_name] = true;
                        record._values[origin_group.parent_name] = null;
                    } else {
                        record.modified_fields[origin_group.parent_name] = true;
                    }
                }
                dest_rows.splice(dest_position, 0, row);
                origin_rows.splice(origin_position, 1);

                row.parent_ = parent_row;
                row.record.group = dest_group;
                for (const r of dest_rows.slice(dest_position)) {
                    r.reset_path();
                }
                for (const r of origin_rows.slice(origin_position)) {
                    r.reset_path();
                }

                var selected = this.get_selected_paths();
                row.redraw(selected);
                var child_redraw = function(child_row) {
                    child_row.redraw(selected);
                    child_row.rows.forEach(child_redraw);
                };
                row.rows.forEach(child_redraw);

                if (this.attributes.sequence) {
                    row.record.group.set_sequence(
                        this.attributes.sequence, this.screen.new_position);
                }
            });
        },
        get_fields: function() {
            return Object.keys(this.widgets);
        },
        get_buttons: function() {
            var buttons = [];
            for (const column of this.columns) {
                if (column instanceof Sao.View.Tree.ButtonColumn) {
                    buttons.push(column);
                }
            }
            return buttons;
        },
        display: function(selected, expanded) {
            var current_record = this.record;
            if (jQuery.isEmptyObject(selected) && current_record) {
                selected = this.get_selected_paths();
                if (this.selection.prop('checked') &&
                    !this.selection.prop('indeterminate')) {
                    for (const record of this.screen.group.slice(
                        this.rows.length, this.display_size)) {
                        selected.push([record.id]);
                    }
                } else {
                    var current_path = current_record.get_path(this.group);
                    current_path = current_path.map(function(e) {
                        return e[1];
                    });
                    if (!Sao.common.contains(selected, current_path)) {
                        selected = [current_path];
                    }
                }
            }
            expanded = expanded || this.get_expanded_paths();

            if (this.selection_mode == Sao.common.SELECTION_MULTIPLE) {
                this.selection.show();
            } else {
                this.selection.hide();
            }

            const group_records = (group, root) => {
                var records = [];
                for (const record of group) {
                    records.push(record);
                    let path = root.concat([record.id]);
                    if (Sao.common.contains(expanded, path)) {
                        const children = record.field_get_client(
                            this.children_field);
                        Array.prototype.push.apply(
                            records, group_records(children, path));
                    }
                }
                return records;
            };

            const row_records = rows => {
                var records = [];
                for (const row of rows) {
                    records.push(row.record);
                    if (row.is_expanded()) {
                        Array.prototype.push.apply(
                            records, row_records(row.rows));
                    }
                }
                return records;
            };
            var min_display_size = Math.min(
                    this.group.length, this.display_size);
            if (this.children_field) {
                if (!Sao.common.compare(
                    group_records(this.group.slice(0, min_display_size), []),
                    row_records(this.rows))) {
                    this.construct();
                }
            } else if ((min_display_size > this.rows.length) &&
                Sao.common.compare(
                    this.group.slice(0, this.rows.length),
                    row_records(this.rows))) {
                this.construct(true);
            } else if ((min_display_size != this.rows.length) ||
                !Sao.common.compare(
                    this.group.slice(0, this.rows.length),
                    row_records(this.rows))){
                this.construct();
            }

            // Set column visibility depending on attributes and domain
            // start at 2 because of checkbox and option headers
            var visible_columns = 2;
            var domain = [];
            if (!jQuery.isEmptyObject(this.screen.domain)) {
                domain.push(this.screen.domain);
            }
            var tab_domain = this.screen.screen_container.get_tab_domain();
            if (!jQuery.isEmptyObject(tab_domain)) {
                domain.push(tab_domain);
            }
            var inversion = new Sao.common.DomainInversion();
            domain = inversion.simplify(domain);
            var decoder = new Sao.PYSON.Decoder(this.screen.context);
            var min_width = [];
            var tree_column_optional = (
                Sao.Screen.tree_column_optional[this.view_id] || {});
            for (const column of this.columns) {
                visible_columns += 1;
                var name = column.attributes.name;
                if (!name) {
                    return;
                }
                var optional;
                if (Object.prototype.hasOwnProperty.call(
                    tree_column_optional, name)) {
                    optional = tree_column_optional[name];
                } else {
                    optional = Boolean(parseInt(
                        column.attributes.optional || '0', 10));
                }
                var invisible = decoder.decode(
                    column.attributes.tree_invisible || '0');
                if (invisible || optional) {
                    visible_columns -= 1;
                    column.set_visible(false);
                } else if (name === this.screen.exclude_field) {
                    visible_columns -= 1;
                    column.set_visible(false);
                } else if (name in this.screen.model.fields) {
                    const field = this.screen.model.fields[name];
                    var inv_domain = inversion.domain_inversion(domain, name);
                    if (typeof inv_domain != 'boolean') {
                        inv_domain = inversion.simplify(inv_domain);
                    }
                    var unique = inversion.unique_value(
                        inv_domain, field._single_value)[0];
                    if (unique && jQuery.isEmptyObject(this.children_field)) {
                        visible_columns -= 1;
                        column.set_visible(false);
                    } else {
                        column.set_visible(true);
                    }
                }

                if (!column.get_visible()) {
                    column.col.css('width', 0);
                    column.col.hide();
                } else if (!column.col.hasClass('draggable-handle') &&
                    !column.col.hasClass('optional') &&
                    !column.col.hasClass('selection-state') &&
                    !column.col.hasClass('favorite')) {
                    var width, c_width;
                    if (column.attributes.width) {
                        width = c_width = column.attributes.width;
                        min_width.push(width + 'px');
                    } else {
                        width = {
                            'integer': 8,
                            'selection': 9,
                            'reference': 20,
                            'one2many': 5,
                            'many2many': 5,
                            'boolean': 3,
                            'binary': 20,
                        }[column.attributes.widget] || 10;
                        if (column.attributes.symbol) {
                            width += 2;
                        }
                        var factor = 1;
                        if (column.attributes.expand) {
                            factor += parseInt(column.attributes.expand, 10);
                        }
                        c_width = width * 100 * factor  + '%';
                        min_width.push(width + 'em');
                    }
                    column.col.css('width', c_width);
                    column.col.show();
                }
            }
            if (this.children_field) {
                this.columns.every(column => {
                    if (column.col.hasClass('draggable-handle') ||
                        column.header.hasClass('invisible')) {
                        return true;
                    } else {
                        if (this.expander.parent()[0] !== column.header[0]) {
                            column.header.prepend(this.expander);
                        }
                        return false;
                    }
                });
            }
            this.table.css('min-width', 'calc(' + min_width.join(' + ') + ')');
            this.scrollbar.css('min-width', this.table.css('min-width'));

            if (!this.table.hasClass('no-responsive') &
                (this.columns.filter(function(c) {
                    return c.get_visible();
                }).length > 1)) {
                this.table.addClass('responsive');
                this.table.addClass('responsive-header');
            } else {
                this.table.removeClass('responsive');
                this.table.removeClass('responsive-header');
            }

            this.update_arrow();

            this.activeFilters = this.activeFilters || {};

            this.columns.forEach((col, index) => {
                if (!col.attributes.name || !col.header) return;
                if (col.header.data('filter-attached')) return;

                // Nettoyage du header + extraction du texte
                const rawHeaderText = col.header.text().trim();
                col.header.empty().css('position', 'relative');

                // Création conteneur flex : icône + texte
                const container = jQuery('<div/>', {
                    css: {
                        display: 'flex',
                        alignItems: 'center',
                        gap: '4px',
                        justifyContent: 'flex-start'
                    }
                }).appendTo(col.header);

                // Icône SVG filtre
                // Icône SVG filtre
                const filterSvg = jQuery(`
                    <svg class="filter-icon" xmlns="http://www.w3.org/2000/svg"
                        width="14" height="14" viewBox="0 0 24 24"
                        fill="none" stroke="#ccc" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"
                        style="cursor:pointer; flex-shrink:0">
                        <polygon points="3 4 21 4 14 12 14 20 10 20 10 12 3 4"></polygon>
                    </svg>
                `).appendTo(container);

                const labelSpan = jQuery('<span/>', {
                    class: 'label-text',
                    text: rawHeaderText
                }).appendTo(container);

                const filterMenu = jQuery('<div/>', {
                    class: 'filter-menu',
                    css: {
                        position: 'absolute',
                        background: '#fff',
                        border: '1px solid #ccc',
                        padding: '5px',
                        display: 'none',
                        zIndex: 1000,
                        boxShadow: '0 2px 6px rgba(0,0,0,0.2)'
                    }
                }).appendTo('body');

                const matchType = jQuery('<select/>', {
                    class: 'form-control input-sm',
                    css: { marginBottom: '5px' }
                }).append(
                    jQuery('<option/>', { value: 'like', text: 'Contient' }),
                    jQuery('<option/>', { value: '=', text: 'Égal à' })
                );

                const input = jQuery('<input/>', {
                    type: 'text',
                    class: 'form-control input-sm',
                    placeholder: 'Valeur...'
                });

                const applyBtn = jQuery('<button/>', {
                    text: 'Filtrer',
                    class: 'btn btn-sm btn-primary btn-block',
                    click: () => {
                        const op = matchType.val();
                        let val = input.val();
                        if (!val) return;
                        filterMenu.hide();

                        const colLabel = labelSpan.clone().children().remove().end().text().trim();
                        this.activeFilters[colLabel] = val;

                        const searchString = Object.entries(this.activeFilters)
                            .map(([colName, val]) => `${colName}: ${val}`)
                            .join(' ');

                        this.screen.search_filter(searchString);

                        filterSvg.css('stroke', val.length > 0 ? '#007bff' : '#ccc');
                        if (!val) delete this.activeFilters[colLabel];
                    }
                });

                filterMenu.append(matchType, input, applyBtn);

                filterSvg.on('click', (e) => {
                    e.stopPropagation();
                    const offset = filterSvg.offset();
                    filterMenu.css({
                        top: offset.top + filterSvg.outerHeight(),
                        left: offset.left
                    }).show();

                    const colLabel = labelSpan.clone().children().remove().end().text().trim();
                    input.val(this.activeFilters[colLabel] || '');
                });

                jQuery(document).on('click', (e) => {
                    if (!jQuery(e.target).closest('.filter-menu').length &&
                        !jQuery(e.target).is(filterSvg)) {
                        filterMenu.hide();
                    }
                });

                col.header.data('filter-attached', true);
            });


            return this.redraw(selected, expanded).then(() => {
                var tbody = this.table.children('tbody');
                if (!tbody.length) {
                    this.table.append(this.tbody);
                } else if (tbody !== this.tbody) {
                    tbody.replaceWith(this.tbody);
                }
                this.tbody.append(this.rows.filter(function(row) {
                    return !row.el.parent().length;
                }).map(function(row) {
                    return row.el;
                }));
                this.update_selection(); // update after new rows has been added
                if ((this.display_size < this.group.length) &&
                    (!this.tbody.children().last().hasClass('more-row'))) {
                    var more_row = jQuery('<tr/>', {
                        'class': 'more-row',
                    });
                    var more_cell = jQuery('<td/>', {
                        'colspan': visible_columns,
                    });
                    var more_button = jQuery('<button/>', {
                        'class': 'btn btn-default btn-block',
                        'type': 'button',
                        'title': Sao.i18n.gettext("More"),
                    }).text(Sao.i18n.gettext('More')
                    ).click(() => {
                        var height = this.table.height();
                        this.display_size += Sao.config.display_size;
                        this.display();
                        height -= this.treeview.height();
                        height -= 50;
                        if (this.tfoot) {
                            height -= this.tfoot.height();
                        }
                        this.treeview[0].scroll({
                            'top': height,
                        });
                    });
                    more_cell.append(more_button);
                    more_row.append(more_cell);
                    this.tbody.append(more_row);
                    if (moreObserver) {
                        moreObserver.observe(more_button[0]);
                    }
                }
            }).done(
                Sao.common.debounce(this.update_sum.bind(this), 250));
        },
        construct: function(extend) {
            if (!extend) {
                this.rows = [];
                // The new tbody is added to the DOM
                // after the rows have been rendered
                // to minimize browser reflow
                this.tbody = jQuery('<tbody/>');
                if (this.draggable) {
                    this._add_drag_n_drop();
                }
                this.edited_row = null;
            } else {
                this.tbody.find('tr.more-row').remove();
            }
            // The rows are added to tbody after being rendered
            // to minimize browser reflow
            for (const record of this.group.slice(
                this.rows.length, this.display_size)) {
                var RowBuilder;
                if (this.editable) {
                    RowBuilder = Sao.View.Tree.RowEditable;
                } else {
                    RowBuilder = Sao.View.Tree.Row;
                }
                this.rows.push(new RowBuilder(this, record, this.rows.length));
            }
        },
        redraw: function(selected, expanded) {
            return redraw_async(this.rows, selected, expanded);
        },
        switch_: function(path) {
            this.screen.row_activate();
        },
        select_changed: function(record) {
            if (this.edited_row) {
                record = this.edited_row.record;
                this.edited_row.set_selection(true);
            }
            this.record = record;
            // TODO update_children
        },
        update_sum: function() {
            for (const [column, sum_widget] of this.sum_widgets) {
                var name = column.attributes.name;
                var selected_records = this.selected_records;
                var aggregate = '-';
                var sum_label = sum_widget[0];
                var sum_value = sum_widget[1];
                var sum_ = null;
                var selected_sum = null;
                var loaded = true;
                var digit = 0;
                var field = this.screen.model.fields[name];
                var i, record;
                var records_ids = selected_records.map(function(record){
                    return record.id;
                });
                for (i=0; i < this.group.length; i++) {
                    record = this.group[i];
                    if (!record.get_loaded([name]) && record.id >=0){
                        loaded = false;
                        break;
                    }
                    var value = field.get(record);
                    if (value && value.isTimeDelta) {
                        value = value.asSeconds();
                    }
                    if (value !== null){
                        if (sum_ === null){
                            sum_ = value;
                        }else {
                            sum_ += value;
                        }
                        if (~records_ids.indexOf(record.id) ||
                            !selected_records){
                            if (selected_sum === null){
                                selected_sum = value;
                            }else {
                                selected_sum += value;
                            }
                        }
                        if (field.digits) {
                            var fdigits = field.digits(record);
                            if (fdigits && digit !== null){
                                digit = Math.max(fdigits[1], digit);
                            } else {
                                digit = null;
                            }
                        }
                    }
                }
                if (loaded) {
                    if (field.description.type == 'timedelta'){
                        var converter = field.converter(this.group);
                        selected_sum =  Sao.common.timedelta.format(
                            Sao.TimeDelta(null, selected_sum), converter);
                        sum_ = Sao.common.timedelta.format(
                            Sao.TimeDelta(null, sum_), converter);
                    } else if (digit !== null){
                        var options = {};
                        options.minimumFractionDigits = digit;
                        options.maximumFractionDigits = digit;
                        selected_sum = (selected_sum || 0).toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang()), options);
                        sum_ = (sum_ || 0).toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang()), options);
                    } else {
                        selected_sum = (selected_sum || 0).toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang()));
                        sum_ = (sum_ || 0).toLocaleString(
                            Sao.i18n.BC47(Sao.i18n.getlang()));
                    }
                    aggregate = selected_sum + '\n' + sum_;
                }
                sum_value.text(aggregate);
                sum_value.parent().attr(
                    'title', sum_label.text() + '\n' + sum_value.text());
            }
        },
        get selected_records() {
            if (this.selection_mode == Sao.common.SELECTION_NONE) {
                return [];
            }
            var records = [];
            var add_record = function(row) {
                if (row.is_selected()) {
                    records.push(row.record);
                }
                row.rows.forEach(add_record);
            };
            this.rows.forEach(add_record);
            if (this.selection.prop('checked') &&
                    !this.selection.prop('indeterminate')) {
                for (const record of this.group.slice(this.rows.length)) {
                    records.push(record);
                }
            }
            return records;
        },
        get listed_records() {
            if (!this.children_field) {
                return this.group.slice();
            }

            const get_listed_records = start => {
                var records = [];
                var row = this.find_row(start);
                var children_rows = row ? row.rows : this.rows;
                for (var idx = 0, len = this.n_children(row);
                    idx < len; idx++) {
                    var path = start.concat([idx]);
                    row = children_rows[idx];
                    if (row) {
                        var record = row.record;
                        records.push(record);
                        if (row.is_expanded()) {
                            records = records.concat(get_listed_records(path));
                        }
                    }
                }
                return records;
            };
            return get_listed_records([]).concat(this.group.slice(this.rows.length));
        },
        get_listed_paths: function() {
            if (!this.children_field) {
                return this.group.map(function(record) {
                    return [record.id];
                });
            }

            const get_listed_paths = (start, start_path) => {
                var paths = [];
                var row = this.find_row(start);
                var children_rows = row ? row.rows : this.rows;
                for (var idx = 0, len = this.n_children(row);
                    idx < len; idx++) {
                    var path = start.concat([idx]);
                    row = children_rows[idx];
                    if (row) {
                        var record = row.record;
                        var id_path = start_path.concat([record.id]);
                        paths.push(id_path);
                        if (row.is_expanded()) {
                            paths = paths.concat(get_listed_paths(path, id_path));
                        }
                    }
                }
                return paths;
            };
            return get_listed_paths([], []).concat(
                this.group.slice(this.rows.length).map(function(record) {
                    return [record.id];
                }));
        },
        select_records: function(from, to) {
            if (!from && to) {
                from = this.rows[0].record;
            }
            if (from && to) {
                var from_idx = from.get_index_path(this.screen.group);
                var to_idx = to.get_index_path(this.screen.group);
                var max_len = Math.min(from_idx.length, to_idx.length);
                var tmp;
                for (var i=0; i < max_len; i++) {
                    if (from_idx[i] > to_idx[i]) {
                        tmp = from;
                        from = to;
                        to = tmp;
                        break;
                    }
                }
                if (!tmp && (from_idx.length > to_idx.length)) {
                    tmp = from;
                    from = to;
                    to = tmp;
                }
            }
            var value = this.rows[0].record === from;
            var select_record = function(row) {
                var record = row.record;
                if (record === from) {
                    value = true;
                }
                row.set_selection(value);
                if (record === to) {
                    value = false;
                }
                row.rows.forEach(select_record);
            };
            this.rows.forEach(select_record);
        },
        selection_changed: function() {
            var value = this.selection.prop('checked');
            var set_checked = function(row) {
                row.set_selection(value);
                row.rows.forEach(set_checked);
            };
            this.rows.forEach(set_checked);
            if (value && this.rows[0]) {
                this.select_changed(this.rows[0].record);
            } else {
                this.select_changed(null);
            }
            this.update_expander(value? 'more' : null);
            this.update_sum();
        },
        update_selection: function() {
            this.update_sum();
            var selected_records = this.selected_records;
            this.selection.prop('indeterminate', false);
            if (jQuery.isEmptyObject(selected_records)) {
                this.selection.prop('checked', false);
            } else if (
                this.rows.every((row) => row.is_selected()) &&
                (selected_records.length >= this.tbody.children().length)) {
                this.selection.prop('checked', true);
            } else {
                this.selection.prop('indeterminate', true);
                // Set checked to go first unchecked after first click
                this.selection.prop('checked', true);
            }
            this.update_expander();
        },
        get_selected_paths: function() {
            var selected_paths = [];
            function get_selected(row, path) {
                var i, r, len, r_path;
                for (i = 0, len = row.rows.length; i < len; i++) {
                    r = row.rows[i];
                    r_path = path.concat([r.record.id]);
                    if (r.is_selected()) {
                        selected_paths.push(r_path);
                    }
                    get_selected(r, r_path);
                }
            }
            get_selected(this, []);
            return selected_paths;
        },
        get_expanded_paths: function(
            starting_path=[], starting_id_path=[]) {
            var id_path, id_paths, row, children_rows, path;
            id_paths = [];
            row = this.find_row(starting_path);
            children_rows = row ? row.rows : this.rows;
            for (var path_idx = 0, len = this.n_children(row) ;
                    path_idx < len ; path_idx++) {
                path = starting_path.concat([path_idx]);
                row = children_rows[path_idx];
                if (row && row.is_expanded()) {
                    id_path = starting_id_path.concat(row.record.id);
                    id_paths.push(id_path);
                    id_paths = id_paths.concat(this.get_expanded_paths(path,
                                id_path));
                }
            }
            return id_paths;
        },
        find_row: function(path) {
            var index;
            var row = null;
            var group = this.rows;
            for (var i=0, len=path.length; i < len; i++) {
                index = path[i];
                if (!group || index >= group.length) {
                    return null;
                }
                row = group[index];
                group = row.rows;
                if (!this.children_field) {
                    break;
                }
            }
            return row;
        },
        n_children: function(row) {
            if (!row || !this.children_field) {
                return this.rows.length;
            }
            if (row.record.is_loaded(this.children_field)) {
                return row.record.field_get_client(this.children_field).length;
            } else {
                return 0;
            }
        },
        set_cursor: function(new_, reset_view) {
            var path, row, column;
            var td, prm;

            if (!this.record) {
                return;
            }
            path = this.record.get_index_path(this.group);
            if (this.rows.length <= path[0]) {
                this.display_size = this.group.length;
                this.display();
            }
            if (path.length > 1) {
                prm = this.rows[path[0]].expand_to_path(
                    path.slice(1),
                    [this.record.get_path(this.group).map(function(value) {
                        return value[1];
                    })]);
            }

            const focus = () => {
                row = this.find_row(path);
                if (row) {
                    column = row.next_column(null, new_);
                    if (column !== null) {
                        td = row._get_column_td(column);
                        if (this.editable && new_) {
                            td.trigger('click');
                        }
                        var child = Sao.common.find_focusable_child(td);
                        if (child) {
                            child.focus();
                        }
                    }
                }
            };

            if (prm) {
                prm.then(focus);
            } else {
                focus();
            }
        },
        save_row: function() {
            var i, prm, edited_row = this.edited_row;
            if (!this.editable || !this.edited_row) {
                return jQuery.when();
            }
            if (!this.edited_row.record.validate(
                    this.get_fields(), false, false, true)) {
                var focused = false;
                var invalid_fields = this.edited_row.record.invalid_fields();
                for (i = 0; i < this.columns.length; i++) {
                    var col = this.columns[i];
                    if (col.attributes.name in invalid_fields) {
                        var td = this.edited_row._get_column_td(i);
                        var editable_el = this.edited_row.get_editable_el(td);
                        var widget = editable_el.data('widget');
                        widget.display(this.edited_row.record, col.field);
                        if (!focused) {
                            widget.focus();
                            focused = true;
                        }
                    }
                }
                return;
            }
            if (!this.group.parent) {
                prm = this.edited_row.record.save();
            } else if (this.screen.attributes.pre_validate) {
                prm = this.record.pre_validate();
            } else {
                prm = jQuery.when();
            }
            prm.fail(() => {
                if (this.edited_row != edited_row) {
                    this.edit_row(null);
                    edited_row.set_selection(true);
                    edited_row.selection_changed();
                    this.edit_row(edited_row);
                }
            });
            return prm;
        },
        edit_row: function(row) {
            if (!this.editable || this.edited_row == row) {
                return;
            }
            if (this.edited_row) {
                this.edited_row.unset_editable();
            }
            if (row) {
                row.set_editable();
            }
            this.edited_row = row;
        },
        _find_row: function(tr) {
            var row = null;
            var find_row = function(r) {
                if (r.el[0] == tr[0]) {
                    row = r;
                    return;
                }
                r.rows.forEach(find_row);
            };
            this.rows.forEach(find_row);
            return row;
        }
    });

    function redraw_async(rows, selected, expanded) {
        var dfd= jQuery.Deferred(),
            i = 0;
        var redraw = function() {
            for (; i < rows.length; i++) {
                var row = rows[i];
                var record = row.record;
                var field_name;
                for (var j=0; j < row.tree.columns.length; j++) {
                    var column = row.tree.columns[j];
                    if (column.type == 'field') {
                        field_name = column.attributes.name;
                        break;
                    }
                }
                if (field_name && !record.is_loaded(field_name)) {
                    // Prefetch the first field to prevent promises in
                    // Cell.render
                    record.load(field_name, true, false).done(redraw);
                    return;
                } else {
                    row.redraw(selected, expanded);
                }
            }
            dfd.resolve();
        };
        redraw();
        return dfd.promise();
    }

    Sao.View.Tree.Row = Sao.class_(Object, {
        init: function(tree, record, pos, parent) {
            this.tree = tree;
            this.current_column = null;
            this.rows = [];
            this.record = record;
            this.parent_ = parent;
            this.children_field = tree.children_field;
            this.expander = null;
            this._group_position = null;
            this._path = null;
            this._drawed_record = null;
            this.el = jQuery('<tr/>');
            this.el.on('click', this.select_row.bind(this));
            this._construct();
        },
        get group_position() {
            if (this._group_position === null) {
                this._group_position = this.record.group.indexOf(this.record);
            }
            return this._group_position;
        },
        get path() {
            if (!this._path) {
                var path;
                if (this.parent_) {
                    path = jQuery.extend([], this.parent_.path.split('.'));
                } else {
                    path = [];
                }
                path.push(this.group_position);
                this._path = path.join('.');
            }
            return this._path;
        },
        reset_path: function() {
            this._group_position = null;
            this._path = null;
            for (const row of this.rows) {
                row.reset_path();
            }
        },
        is_expanded: function() {
            return this.tree.expanded.has(this);
        },
        get_id_path: function() {
            if (!this.parent_) {
                return [this.record.id];
            }
            return this.parent_.get_id_path().concat([this.record.id]);
        },
        _construct: function() {
            var td;
            this.tree.el.uniqueId();
            td = jQuery('<td/>');
            if (this.tree.draggable) {
                td.addClass('draggable-handle');
                td.append(Sao.common.ICONFACTORY.get_icon_img('tryton-drag'));
            }
            if (this.tree.optionals.length) {
                td.addClass('optional');
            }
            this.el.append(td);
            td = jQuery('<td/>', {
                'class': 'selection-state',
            }).click(event_ => {
                event_.stopPropagation();
                this.selection.click();
            });
            this.el.append(td);
            this.selection = jQuery('<input/>', {
                'type': 'checkbox',
                'name': 'tree-selection-' + this.tree.el.attr('id'),
            });
            this.selection.click(function(event_) {
                event_.stopPropagation();
            });
            this.selection.change(this.selection_changed.bind(this));
            td.append(this.selection);

            const on_click = event_ => {
                if (this.expander && !this.is_expanded() &&
                    (this.tree.n_children(this) <= Sao.config.limit)) {
                    this.toggle_row();
                }
                this.select_column(event_.data.index);
            };

            if (this.children_field) {
                this.expander = jQuery('<span/>', {
                    'class': 'expander',
                }).append('<img/>', {
                    'tabindex': 0,
                    'class': 'icon',
                });
                this.expander.children().html('&nbsp;');
                this.expander.on('click keypress',
                        Sao.common.click_press(this.toggle_row.bind(this)));
                this.expander.dblclick((evt) => {
                    // prevent calling switch_row on td
                    evt.preventDefault();
                    evt.stopImmediatePropagation();
                });
            }

            for (var i = 0; i < this.tree.columns.length; i++) {
                var column = this.tree.columns[i];
                if (column instanceof Sao.View.Tree.ButtonColumn) {
                    td = jQuery('<td>');
                } else {
                    td = jQuery('<td/>', {
                        'data-title': column.attributes.string +
                        Sao.i18n.gettext(': ')
                    }).append(jQuery('<span/>', { // For responsive min-height
                        'aria-hidden': true
                    }));
                }
                td.attr('headers', column.header.attr('id'));
                td.on('click keypress', {'index': i}, on_click);
                if (!this.tree.editable) {
                    td.dblclick(this.switch_row.bind(this));
                } else {
                    if (column.attributes.required) {
                        td.addClass('required');
                    }
                    if (!column.attributes.readonly) {
                        td.addClass('editable');
                    }
                }
                var cell = jQuery('<div>', {
                    'class': 'cell',
                });
                td.append(cell);
                if (column.prefixes) {
                    for (let j = 0; j < column.prefixes.length; j++) {
                        cell.append(jQuery('<span/>', {
                            'class': 'prefix'
                        }));
                    }
                }
                cell.append(jQuery('<span/>', {
                    'class': 'widget'
                }));
                if (column.suffixes) {
                    for (let j = 0; j < column.suffixes.length; j++) {
                        cell.append(jQuery('<span/>', {
                            'class': 'suffix'
                        }));
                    }
                }

                this.el.append(td);
            }
        },
        _get_column_td: function(column_index, row) {
            row = row || this.el;
            // take into account the selection and option column
            return jQuery(row.children()[column_index + 2]);
        },
        redraw: function(selected, expanded) {
            selected = selected || [];
            expanded = expanded || [];

            switch(this.tree.selection_mode) {
                case Sao.common.SELECTION_NONE:
                    this.selection.hide();
                    break;
                case Sao.common.SELECTION_SINGLE:
                    this.selection.attr('type', 'radio');
                    this.selection.show();
                    break;
                case Sao.common.SELECTION_MULTIPLE:
                    this.selection.attr('type', 'checkbox');
                    this.selection.show();
                    break;
            }

            function apply_visual(el, visual) {
                for (const name of ['muted', 'success', 'warning', 'danger']) {
                    var klass = name == 'muted' ? 'text-muted' : name;
                    if (name == visual) {
                        el.addClass(klass);
                    } else {
                        el.removeClass(klass);
                    }
                }
            }

            if (this._drawed_record !== this.record.identity) {
                for (var i = 0; i < this.tree.columns.length; i++) {
                    var column = this.tree.columns[i];
                    var td = this._get_column_td(i);
                    var cell = td.find('.cell');
                    var item;
                    if (column.prefixes) {
                        for (var j = 0; j < column.prefixes.length; j++) {
                            var prefix = column.prefixes[j];
                            var prefix_el = jQuery(cell.children('.prefix')[j]);
                            item = prefix_el.children();
                            if (item.length) {
                                prefix.render(this.record, item);
                            } else {
                                prefix_el.empty().append(prefix.render(this.record));
                            }
                        }
                    }
                    var widget = cell.children('.widget');
                    item = widget.children();
                    if (item.length) {
                        column.render(this.record, item);
                    } else {
                        widget.empty().append(column.render(this.record));
                    }
                    if (column.suffixes) {
                        for (var k = 0; k < column.suffixes.length; k++) {
                            var suffix = column.suffixes[k];
                            var suffix_el = jQuery(cell.children('.suffix')[k]);
                            item = suffix_el.children();
                            if (item.length) {
                                suffix.render(this.record, item);
                            } else {
                                suffix_el.empty().append(suffix.render(this.record));
                            }
                        }
                    }
                    let visual = this.record.expr_eval(column.attributes.visual);
                    if (this.record.deleted || this.record.removed) {
                        visual = 'muted';
                    }
                    apply_visual(td, visual);
                }
                this.update_visible();
            }
            if (this.children_field) {
                this.tree.columns.every((column, i) => {
                    if (column.col.hasClass('draggable-handle') ||
                        column.header.hasClass('invisible')) {
                        return true;
                    } else {
                        var td = this._get_column_td(i);
                        var cell = td.find('.cell');
                        if (this.expander.parent()[0] !== cell[0]) {
                            cell.prepend(this.expander);
                        }
                        return false;
                    }
                });
            }
            this._drawed_record = this.record.identity;

            var row_id_path = this.get_id_path();
            this.set_selection(Sao.common.contains(selected, row_id_path));
            if (this.children_field) {
                var depth = this.path.split('.').length;
                var margin = 'margin-left';
                if (Sao.i18n.rtl) {
                    margin = 'margin-right';
                }
                this.expander.children().css(margin, (depth - 1) + 'em');

                const update_expander = () => {
                    var length = this.record.field_get_client(
                        this.children_field).length;
                    if (length && (
                        this.is_expanded() ||
                        Sao.common.contains(expanded, row_id_path))) {
                        this.expander.css('visibility', 'visible');
                        this.tree.expanded.add(this);
                        this.expand_children(selected, expanded);
                        this.update_expander(true);
                    } else {
                        this.expander.css('visibility',
                            length ? 'visible' : 'hidden');
                        this.update_expander(false);
                    }
                };
                if (!this.record.is_loaded(this.children_field)) {
                    this.record.load(this.children_field, true, false)
                        .done(update_expander);
                } else {
                    update_expander();
                }
            }
            let visual = this.record.expr_eval(this.tree.attributes.visual);
            if (this.record.deleted || this.record.removed) {
                if (this.record.deleted) {
                    this.el.css('text-decoration', 'line-through');
                }
                visual = 'muted';
            } else {
                this.el.css('text-decoration', 'inherit');
            }
            apply_visual(this.el, visual);
        },
        update_visible: function() {
            var thead_visible = this.tree.thead.is(':visible');
            for (var i = 0; i < this.tree.columns.length; i++) {
                var column = this.tree.columns[i];
                var td = this._get_column_td(i);
                if ((column.header.is(':hidden') && thead_visible) ||
                    column.header.css('display') == 'none') {
                    td.hide();
                    td.addClass('invisible');
                } else {
                    td.show();
                    td.removeClass('invisible');
                }
            }
        },
        toggle_row: function() {
            if (this.is_expanded()) {
                this.collapse_row();
            } else {
                this.expand_row();
            }
            return false;
        },
        expand_row: function() {
            if (this.tree.n_children(this) > Sao.config.limit) {
                this.tree.record = this.record;
                this.tree.screen.switch_view('form');
            } else {
                this.update_expander(true);
                this.tree.expanded.add(this);
                this.expand_children();
            }
        },
        collapse_row: function() {
            for (const row of this.rows) {
                if (row.exception) {
                    row.record.cancel();
                }
            }
            this.update_expander(false);
            this.tree.expanded.delete(this);
            this.collapse_children();
        },
        update_expander: function(expanded) {
            var icon;
            if (expanded) {
                icon = 'tryton-arrow-down';
            } else {
                icon = 'tryton-arrow-right';
            }
            Sao.common.ICONFACTORY.get_icon_url(icon)
                .then(url => {
                    this.expander.children().attr('src', url);
                });
        },
        collapse_children: function() {
            for (const row of this.rows) {
                row.collapse_children();
                row.el.remove();
            }
            this.rows = [];
        },
        expand_children: function(selected, expanded) {
            return this.record.load(
            this.children_field, true, false).done(() => {
                if (this.rows.length === 0) {
                    var children = this.record.field_get_client(
                        this.children_field);
                    children.forEach((record, pos, group) => {
                        // The rows are added to the tbody after being rendered
                        // to minimize browser reflow
                        this.rows.push(new this.Class(
                            this.tree, record, pos, this));
                    });
                }
                redraw_async(this.rows, selected, expanded).then(() => {
                    this.el.after(this.rows.filter(function(row) {
                        return !row.el.parent().length;
                    }).map(function(row) {
                        return row.el;
                    }));
                    this.tree.update_selection();
                });
            });
        },
        switch_row: function() {
            if (window.getSelection) {
                if (window.getSelection().empty) {  // Chrome
                    window.getSelection().empty();
                } else if (window.getSelection().removeAllRanges) {  // Firefox
                    window.getSelection().removeAllRanges();
                }
            } else if (document.selection) {  // IE?
                document.selection.empty();
            }
            if (this.tree.selection_mode != Sao.common.SELECTION_NONE) {
                this.set_selection(true);
                this.selection_changed();
                if (!this.is_selected()) {
                    return;
                }
            }
            this.tree.switch_(this.path);
        },
        select_column: function(index) {
        },
        select_row: function(event_) {
            if (this.tree.selection_mode == Sao.common.SELECTION_NONE) {
                this.tree.select_changed(this.record);
                this.switch_row();
            } else {
                var current_record;
                if (event_.shiftKey &&
                    this.tree.selection_mode != Sao.common.SELECTION_SINGLE) {
                    current_record = this.tree.screen.current_record;
                    this.tree.select_records(current_record, this.record);
                } else {
                    if (!(event_.ctrlKey || event_.metaKey) ||
                        this.tree.selection_mode ==
                        Sao.common.SELECTION_SINGLE) {
                        this.tree.select_records(null, null);
                    }
                    this.set_selection(!this.is_selected());
                }
                this.selection_changed();
                if (current_record) {
                    // Keep original current record with shift select
                    this.tree.screen.current_record = current_record;
                }
            }
        },
        is_selected: function() {
            if (this.tree.selection_mode == Sao.common.SELECTION_NONE) {
                return false;
            }
            return this.selection.prop('checked');
        },
        set_selection: function(value) {
            if (this.tree.selection_mode == Sao.common.SELECTION_NONE) {
                return;
            }
            this.selection.prop('checked', value);
            if (value) {
                this.el.addClass('selected');
            } else {
                this.el.removeClass('selected');
            }
            if (!value) {
                this.tree.selection.prop('checked', false);
            }
        },
        selection_changed: function() {
            var is_selected = this.is_selected();
            this.set_selection(is_selected);
            if (is_selected) {
                this.tree.select_changed(this.record);
                if (this.expander) {
                    if (this.is_expanded()) {
                        this.tree.update_expander('less');
                    } else if (this.expander.css('visibility') == 'visible') {
                        this.tree.update_expander('more');
                    }
                }
            } else {
                this.tree.select_changed(
                        this.tree.selected_records[0] || null);
            }
            this.tree.update_selection();
        },
        expand_to_path: function(path, selected) {
            if (path.length &&
                this.record.field_get_client(this.children_field).length) {
                this.expander.css('visibility', 'visible');
                this.tree.expanded.add(this);
                this.update_expander(true);
                return this.expand_children(selected).done(() => {
                    return this.rows[path[0]].expand_to_path(path.slice(1), selected);
                });
            }
        },
        next_column: function(path, editable, sign) {
            var i, readonly, invisible;
            var column, column_index, state_attrs;

            sign = sign || 1;
            if ((path === null) && (sign > 0)) {
                path = -1;
            } else if (path === null) {
                path = 0;
            }
            column_index = 0;
            for (i = 0; i < this.tree.columns.length; i++) {
                column_index = ((path + (sign * (i + 1))) %
                        this.tree.columns.length);
                // javascript modulo returns negative number for negative
                // numbers
                if (column_index < 0) {
                    column_index += this.tree.columns.length;
                }
                column = this.tree.columns[column_index];
                if (!column.field) {
                    continue;
                }
                state_attrs = column.field.get_state_attrs(this.record);
                invisible = state_attrs.invisible;
                if (column.header.is(':hidden')) {
                    invisible = true;
                }
                if (editable) {
                    var EditableBuilder = Sao.View.EditableTree.WIDGETS[
                        column.attributes.widget];
                    readonly = (column.attributes.readonly ||
                        state_attrs.readonly ||
                        !EditableBuilder);
                } else {
                    readonly = false;
                }
                if (!(invisible || readonly)) {
                    return column_index;
                }
            }
        }
    });

    Sao.View.Tree.RowEditable = Sao.class_(Sao.View.Tree.Row, {
        init: function(tree, record, pos, parent) {
            Sao.View.Tree.RowEditable._super.init.call(this, tree, record, pos,
                parent);
            this.edited_column = null;
            this.el.on('keypress', event_ => {
                if ((event_.which == Sao.common.RETURN_KEYCODE) &&
                    (this.tree.edited_row != this)) {
                    event_.preventDefault();
                    this.tree.edit_row(this);
                }
            });
        },
        redraw: function(selected, expanded) {
            var i, cell, widget;

            Sao.View.Tree.RowEditable._super.redraw.call(this, selected,
                    expanded);
            const display_callback = widget => {
                var record = this.record;
                return function() {
                    var field = record.model.fields[widget.field_name];
                    field.set_state(record);
                    widget.display(record, field);
                };
            };
            // The autocompletion widget do not call display thus we have to
            // call it when redrawing the row
            for (i = 0; i < this.tree.columns.length; i++) {
                var column = this.tree.columns[i];
                cell = this._get_column_td(i).children('.cell');
                widget = jQuery(cell.children('.widget-editable')).data('widget');
                if (widget) {
                    var callback = display_callback(widget);
                    if (!this.record.is_loaded(column.attributes.name)) {
                        this.record.load(column.attributes.name, true, false)
                            .done(callback);
                    } else {
                        callback();
                    }
                }
            }
        },
        select_column: function(index) {
            this.edited_column = index;
        },
        select_row: function(event_) {
            var body, listener;
            event_.stopPropagation();
            if (this.tree.edited_row &&
                    (event_.currentTarget == this.tree.edited_row.el[0])) {
                return;
            }

            var current_record = this.tree.screen.current_record;
            if ((this.record != current_record) &&
                current_record && !current_record.validate(
                    this.tree.get_fields(), false, false, true)) {
                return;
            }

            body = listener = jQuery(document.body);
            if (body.hasClass('modal-open')) {
                listener = this.tree.el.parents('.modal').last();
            }
            const handler = event_ => {
                if ((event_.currentTarget == body[0]) &&
                    body.hasClass('modal-open')) {
                    return;
                }

                if (!this.tree.save_row()) {
                    event_.preventDefault();
                    event_.stopPropagation();
                    return;
                }
                body.off('click.sao.editabletree');
                this.tree.edit_row(null);
                return true;
            };
            if (!handler(event_)) {
                return;
            }
            listener.on('click.sao.editabletree', handler);

            Sao.View.Tree.RowEditable._super.select_row.call(this, event_);

            if (!event_.shiftKey && !(event_.ctrlKey || event_.metaKey) &&
                (this.record === current_record)) {
                this.tree.edit_row(this);
            }
        },
        unset_editable: function() {
            this.tree.columns.forEach((col, idx) => {
                var td = this._get_column_td(idx);
                var static_el = this.get_static_el(td);
                static_el.empty().append(col.render(this.record)).show();
                this.get_editable_el(td)
                    .empty()
                    .data('widget', null)
                    .hide()
                    .parents('.treeview td').addBack().removeClass('edited');
            });
        },
        set_editable: function() {
            var focus_widget = null;
            for (var i = 0, len=this.tree.columns.length; i < len; i++) {
                var td = this._get_column_td(i);
                var col = this.tree.columns[i];
                if (!col.field) {
                    continue;
                }
                var EditableBuilder = Sao.View.EditableTree.WIDGETS[
                    col.attributes.widget];
                if (!col.attributes.readonly && EditableBuilder) {
                    var widget = new EditableBuilder(
                        this.tree, col.attributes);
                    widget.el.on('keydown', this.key_press.bind(this));

                    var editable_el = this.get_editable_el(td);
                    editable_el.append(widget.el);
                    editable_el.data('widget', widget);
                    widget.display(this.record, col.field);

                    var static_el = this.get_static_el(td);
                    static_el.hide();
                    editable_el.show();
                    editable_el.parents('.treeview td').addBack()
                        .addClass('edited');

                    if (this.edited_column == i) {
                        focus_widget = widget;
                    }
                }
            }
            if (focus_widget) {
                focus_widget.focus();
            }
        },
        get_static_el: function(td) {
            td = td || this.get_active_td();
            return td.find('.widget');
        },
        get_editable_el: function(td) {
            td = td || this.get_active_td();
            var editable = td.find('.widget-editable');
            if (!editable.length) {
                editable = jQuery('<span/>', {
                        'class': 'widget-editable'
                    }).insertAfter(td.find('.widget'));
            }
            return editable;
        },
        get_active_td: function() {
            return this._get_column_td(this.edited_column);
        },
        key_press: function(event_) {
            var next_column, next_idx, i;

            if (((event_.which != Sao.common.TAB_KEYCODE) &&
                    (event_.which != Sao.common.UP_KEYCODE) &&
                    (event_.which != Sao.common.DOWN_KEYCODE) &&
                    (event_.which != Sao.common.ESC_KEYCODE) &&
                    (event_.which != Sao.common.RETURN_KEYCODE)) ||
                jQuery(event_.currentTarget)
                .find('.dropdown-menu:visible').length) {
                return;
            }
            event_.preventDefault();
            var td = this._get_column_td(this.edited_column);
            var editable_el = this.get_editable_el(td);
            var widget = editable_el.data('widget');
            widget.focus_out();
            var column = this.tree.columns[this.edited_column];
            if (column.field.validate(this.record)) {
                if (event_.which == Sao.common.TAB_KEYCODE) {
                    var sign = 1;
                    if (event_.shiftKey) {
                        sign = -1;
                    }
                    next_idx = this.next_column(this.edited_column, true, sign);
                    if (next_idx !== null) {
                        this.edited_column = next_idx;
                        td = this._get_column_td(next_idx);
                        editable_el = this.get_editable_el(td);
                        widget = editable_el.data('widget');
                        widget.focus();
                    }
                } else if (event_.which == Sao.common.UP_KEYCODE ||
                    event_.which == Sao.common.DOWN_KEYCODE ||
                    event_.which == Sao.common.RETURN_KEYCODE) {
                    next_column = this.edited_column;
                    this.record.validate(this.tree.get_fields())
                        .then(validate => {
                            if (!validate) {
                                var invalid_fields =
                                    this.record.invalid_fields();
                                for (i = 0; i < this.tree.columns.length; i++) {
                                    var col = this.tree.columns[i];
                                    if (col.attributes.name in invalid_fields) {
                                        next_column = i;
                                        break;
                                    }
                                }
                                this._get_column_td(next_column)
                                    .find(':input,[tabindex=0]').focus();
                            } else {
                                var prm = jQuery.when();
                                if (!this.tree.screen.group.parent) {
                                    prm = this.record.save();
                                } else if (this.tree.screen.attributes.pre_validate) {
                                    prm = this.record.pre_validate();
                                }
                                prm.fail(function() {
                                    widget.focus();
                                });
                                var next_row;
                                if (event_.which == Sao.common.UP_KEYCODE) {
                                    next_row = this.el.prev('tr');
                                } else if (event_.which == Sao.common.DOWN_KEYCODE) {
                                    next_row = this.el.next('tr');
                                } else {
                                    if (this.tree.screen.new_position == -1) {
                                        next_row = this.el.next('tr');
                                    } else {
                                        next_row = this.el.prev('tr');
                                    }
                                }
                                if (!next_row.length &&
                                    ((event_.which == Sao.common.RETURN_KEYCODE) ||
                                        ((event_.which == Sao.common.UP_KEYCODE) &&
                                            (this.tree.screen.new_position == 0)) ||
                                        ((event_.which == Sao.common.DOWN_KEYCODE) &&
                                            (this.tree.screen.new_position == -1)))) {
                                    var model = this.tree.screen.group;
                                    var access = Sao.common.MODELACCESS.get(
                                        this.tree.screen.model_name);
                                    var limit = ((this.tree.screen.size_limit !== null) &&
                                        (model.length >= this.tree.screen.size_limit));
                                    if (this.tree.creatable && access.create && !limit) {
                                        prm.then(() => this.tree.screen.new_())
                                            .then(record => {
                                                var sequence = this.tree.attributes.sequence;
                                                if (sequence) {
                                                    record.group.set_sequence(
                                                        sequence, this.tree.screen.new_position);
                                                }
                                            });
                                    }
                                } else {
                                    prm.then(() => {
                                        this._get_column_td(
                                            next_column, next_row)
                                            .trigger('click')
                                            .find(':input,[tabindex=0]')
                                            .focus();
                                    });
                                }
                            }
                        });
                } else if (event_.which == Sao.common.ESC_KEYCODE) {
                    this.tree.edit_row(null);
                    this.get_static_el().show().find('[tabindex=0]').focus();
                }
            } else {
                widget.display(this.record, column.field);
            }
        }
    });

    Sao.View.Tree.Affix = Sao.class_(Object, {
        init: function(attributes, protocol) {
            this.attributes = attributes;
            this.protocol = protocol || null;
            this.icon = attributes.icon;
            if (this.protocol && !this.icon) {
                this.icon = 'tryton-public';
            }
        },
        get_cell: function() {
            var cell;
            if (this.protocol) {
                cell = jQuery('<a/>', {
                    'target': '_blank',
                    'rel': 'noreferrer noopener',
                });
                cell.append(jQuery('<img/>'));
                cell.click({'cell': cell}, this.clicked.bind(this));
            } else if (this.icon) {
                cell = jQuery('<img/>');
            } else {
                cell = jQuery('<span/>');
                cell.attr('tabindex', 0);
            }
            cell.addClass('column-affix');
            return cell;
        },
        render: function(record, cell) {
            if (!cell) {
                cell = this.get_cell();
            }
            const render = () => {
                var value;
                var field = record.model.fields[this.attributes.name];
                field.set_state(record, ['invisible']);
                var invisible = field.get_state_attrs(record).invisible;
                if (invisible) {
                    cell.hide();
                } else {
                    cell.show();
                }
                if (this.protocol) {
                    value = field.get(record);
                    if (!jQuery.isEmptyObject(value)) {
                        switch (this.protocol) {
                            case 'email':
                                value = 'mailto:' + value;
                                break;
                            case 'callto':
                                value = 'callto:' + value;
                                break;
                            case 'sip':
                                value = 'sip:' + value;
                                break;
                        }
                    }
                    cell.attr('href', value);
                }
                if (this.icon) {
                    if (this.icon in record.model.fields) {
                        var icon_field = record.model.fields[this.icon];
                        value = icon_field.get_client(record);
                    }
                    else {
                        value = this.icon;
                    }
                    var img_tag;
                    if (cell.children('img').length) {
                        img_tag = cell.children('img');
                    } else {
                        img_tag = cell;
                    }
                    if (this.attributes.icon_type == 'url') {
                        if (value) {
                            if (this.attributes.url_size) {
                                var url = new URL(value, window.location);
                                url.searchParams.set(
                                    this.attributes.url_size, 20);
                                value = url.href;
                            }
                            img_tag.attr('src', value);
                        } else {
                            img_tag.removeAttr('src');
                        }
                    } else {
                        Sao.common.ICONFACTORY.get_icon_url(value)
                            .done(url => {
                                if (url) {
                                    img_tag.attr('src', url);
                                } else {
                                    img_tag.removeAttr('src');
                                }
                            });
                    }
                    switch (this.attributes.border) {
                        case 'rounded':
                            img_tag.addClass('img-rounded');
                            break;
                        case 'circle':
                            img_tag.addClass('img-circle');
                            break;
                        default:
                            break;
                    }
                } else {
                    value = this.attributes.string || '';
                    if (!value) {
                        value = field.get_client(record) || '';
                    }
                    cell.text(value);
                }
            };
            if (!record.is_loaded(this.attributes.name)) {
                record.load(this.attributes.name, true, false).done(render);
            } else {
                render();
            }
            return cell;
        },
        clicked: function(event) {
            event.stopPropagation();  // prevent edition
        }
    });

    Sao.View.Tree.Symbol = Sao.class_(Object, {
        class_: 'column-symbol',
        init: function(attributes, position) {
            this.attributes = attributes;
            this.position = position;
        },
        get_cell: function() {
            var cell = jQuery('<span/>', {
                'class': this.class_,
                'tabindex': 0
            });
            return cell;
        },
        render: function(record, cell) {
            if (!cell) {
                cell = this.get_cell();
            }
            const render = () => {
                var field = record.model.fields[this.attributes.name];
                field.set_state(record, ['invisible']);
                var invisible = field.get_state_attrs(record).invisible;
                if (invisible) {
                    cell.text('');
                    cell.hide();
                    return;
                }
                var result = field.get_symbol(record, this.attributes.symbol);
                var symbol = result[0],
                    position = result[1];
                if (Math.round(position) === this.position) {
                    cell.text(symbol);
                    cell.show();
                } else {
                    cell.text('');
                    cell.hide();
                }
            };
            if (!record.is_loaded(this.attributes.name)) {
                record.load(this.attributes.name, true, false).done(render);
            } else {
                render();
            }
            return cell;
        },
    });

    Sao.View.Tree.CharColumn = Sao.class_(Object, {
        class_: 'column-char',
        init: function(model, attributes) {
            this.type = 'field';
            this.model = model;
            this.field = model.fields[attributes.name];
            this.tree = null;
            this.attributes = attributes;
            this.prefixes = [];
            this.suffixes = [];
            this.header = null;
            this.footers = [];
        },
        get field_name() {
            return this.attributes.name;
        },
        get model_name() {
            return this.model.name;
        },
        get_cell: function() {
            var cell = jQuery('<div/>', {
                'class': this.class_,
                'tabindex': 0
            });
            return cell;
        },
        get_textual_value: function(record) {
            return this.field.get_client(record);
        },
        update_text: function(cell, record) {
            var text = this.get_textual_value(record);
            cell.text(text).attr('title', text);
        },
        render: function(record, cell) {
            if (!cell) {
                cell = this.get_cell();
            }
            const render = () => {
                this.update_text(cell, record);
                this.field.set_state(record);
                var state_attrs = this.field.get_state_attrs(record);
                if (state_attrs.invisible) {
                    cell.hide();
                } else {
                    cell.show();
                }
            };
            const render_error = () => {
                var text = Sao.i18n.gettext('#ERROR');
                cell.text(text).attr('title', text);
            };

            if (!record.is_loaded(this.attributes.name)) {
                record.load(this.attributes.name, true, false)
                    .done(render)
                    .fail(render_error);
            } else {
                if (record.exception) {
                    render_error();
                } else {
                    render();
                }
            }
            return cell;
        },
        set_visible: function(visible) {
            var cells = this.footers.slice();
            cells.push(this.header);
            for (const cell of cells) {
                if (visible) {
                    cell.show();
                    cell.removeClass('invisible');
                } else {
                    cell.hide();
                    cell.addClass('invisible');
                }
            }
        },
        get_visible: function() {
            return !this.header.hasClass('invisible');
        },
    });

    Sao.View.Tree.TextColum = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-text'
    });

    Sao.View.Tree.IntegerColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-integer',
        init: function(model, attributes) {
            Sao.View.Tree.IntegerColumn._super.init.call(this, model, attributes);
            this.factor = Number(attributes.factor || 1);
            this.grouping = Boolean(Number(attributes.grouping || 1));
        },
        get_cell: function() {
            return Sao.View.Tree.IntegerColumn._super.get_cell.call(this);
        },
        get_textual_value: function(record) {
            return this.field.get_client(record, this.factor, this.grouping);
        },
    });

    Sao.View.Tree.FloatColumn = Sao.class_(Sao.View.Tree.IntegerColumn, {
        class_: 'column-float'
    });

    Sao.View.Tree.BooleanColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-boolean',
        get_cell: function() {
            return jQuery('<input/>', {
                'type': 'checkbox',
                'class': this.class_,
                'tabindex': 0
            });
        },
        update_text: function(cell, record) {
            cell.prop('checked', this.field.get(record));
        },
        render: function(record, cell) {
            var new_cell = !cell;
            cell = Sao.View.Tree.BooleanColumn._super.render.call(
                this, record, cell);
            var disabled = true;
            if (this.tree.editable) {
                if (new_cell) {
                    cell.on('click', null,
                        {record: record, cell:cell},
                        this.clicked.bind(this));
                }
                var state_attrs = this.field.get_state_attrs(record);
                disabled = this.attributes.readonly || state_attrs.readonly;
            }
            cell.prop('disabled', disabled);
            return cell;
        },
        clicked: function(evt) {
            var record = evt.data.record;
            var cell = evt.data.cell;
            var current_record = this.tree.screen.current_record;
            var fields = this.tree.get_fields();
            if (!current_record || current_record.validate(
                fields, false, false, true)) {
                var value = cell.prop('checked');
                this.field.set_client(record, value);
            } else {
                evt.preventDefault();
            }
        }
    });

    Sao.View.Tree.Many2OneColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-many2one',
        get_cell: function() {
            var cell = Sao.View.Tree.Many2OneColumn._super.get_cell.call(this);
            cell.append(jQuery('<a/>', {
                'href': '#',
            }));
            return cell;
        },
        update_text: function(cell, record) {
            cell = cell.children('a');
            cell.unbind('click');
            Sao.View.Tree.Many2OneColumn._super.update_text.call(this, cell, record);
            cell.click(event => {
                event.stopPropagation();
                var params = {};
                params.model = this.attributes.relation;
                params.res_id = this.field.get(record);
                params.mode = ['form'];
                params.name = this.attributes.string;
                params.context = this.field.get_context(record);
                Sao.Tab.create(params);
            });
        }
    });

    Sao.View.Tree.One2OneColumn = Sao.class_(Sao.View.Tree.Many2OneColumn, {
        class_: 'column-one2one'
    });

    Sao.View.Tree.SelectionColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-selection',
        init: function(model, attributes) {
            Sao.View.Tree.SelectionColumn._super.init.call(this, model,
                attributes);
            Sao.common.selection_mixin.init.call(this);
            if (!this.tree || this.tree.editable) {
                this.init_selection();
            }
        },
        init_selection: function(key) {
            Sao.common.selection_mixin.init_selection.call(this, key);
        },
        update_selection: function(record, callback) {
            if (!this.selection) {
                this.init_selection();
            }
            Sao.common.selection_mixin.update_selection.call(this, record,
                this.field, callback);
        },
        get_textual_value: function(record) {
            var related = this.field.name + ':string';
            if (!this.tree.editable && related in record._values) {
                return record._values[related];
            } else {
                var value = this.field.get(record);
                for (const option of this.selection) {
                    if (option[0] === value) {
                        return option[1];
                    }
                }
                return value;
            }
        },
        update_text: function(cell, record) {
            if (!this.tree.editable &&
                    (this.field.name + ':string' in record._values)) {
                var text_value = this.get_textual_value(record);
                cell.text(text_value).attr('title', text_value);
            } else {
                this.update_selection(record, () => {
                    var value = this.field.get(record);
                    var prm, text, found = false;
                    for (const option of this.selection) {
                        if (option[0] === value) {
                            found = true;
                            text = option[1];
                            break;
                        }
                    }
                    if (!found) {
                        prm = Sao.common.selection_mixin.get_inactive_selection
                            .call(this, value).then(function(inactive) {
                                return inactive[1];
                            });
                    } else {
                        prm = jQuery.when(text);
                    }
                    prm.done(text_value => {
                        cell.text(text_value).attr('title', text_value);
                    });
                });
            }
        }
    });

    Sao.View.Tree.MultiSelectionColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-multiselection',
        init: function(model, attributes) {
            Sao.View.Tree.MultiSelectionColumn._super.init.call(
                this, model, attributes);
            Sao.common.selection_mixin.init.call(this);
            this.init_selection();
        },
        init_selection: function(key) {
            Sao.common.selection_mixin.init_selection.call(this, key);
        },
        update_selection: function(record, callback) {
            Sao.common.selection_mixin.update_selection.call(this, record,
                this.field, callback);
        },
        get_textual_value: function(record) {
            var related = this.field.name + ':string';
            if (!this.tree.editable && related in record._values) {
                return record._values[related];
            } else {
                var values = this.field.get_eval(record).map(value => {
                    for (const option of this.selection) {
                        if (option[0] === value) {
                            return option[1];
                        }
                    }
                    return '';
                });
                return values.join(';');
            }
        },
        update_text: function(cell, record) {
            if (!this.tree.editable &&
                    (this.field_name + ':string' in record._values)) {
                var text_value = this.get_textual_value(record);
                cell.text(text_value).attr('title', text_value);
            } else {
                this.update_selection(record, () => {
                    var text_value = this.get_textual_value(record);
                    cell.text(text_value).attr('title', text_value);
                });
            }
        },
    });

    Sao.View.Tree.ReferenceColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-reference',
        init: function(model, attributes) {
            Sao.View.Tree.ReferenceColumn._super.init.call(this, model,
                attributes);
            Sao.common.selection_mixin.init.call(this);
            this.init_selection();
        },
        init_selection: function(key) {
            Sao.common.selection_mixin.init_selection.call(this, key);
        },
        update_selection: function(record, callback) {
            Sao.common.selection_mixin.update_selection.call(this, record,
                this.field, callback);
        },
        get_cell: function() {
            var cell = Sao.View.Tree.ReferenceColumn._super
                .get_cell.call(this);
            cell.append(jQuery('<a/>', {
                'href': '#',
            }));
            return cell;
        },
        get_textual_value: function(record) {
            var value = Sao.View.Tree.ReferenceColumn._super
                .get_textual_value.call(this, record);
            var model, name, text;
            if (!value) {
                model = '';
                name = '';
            } else {
                model = value[0];
                name = value[1];
            }
            if (model) {
                for (const option of this.selection) {
                    if (option[0] === model) {
                        model = option[1];
                        break;
                    }
                }
                text = model + ',' + name;
            } else {
                text = name;
            }
            return text;
        },
        update_text: function(cell, record) {
            cell = cell.children('a');
            cell.unbind('click');
            this.update_selection(record, () => {
                var text = this.get_textual_value(record);
                cell.text(text).attr('title', text);
                cell.click(event => {
                    event.stopPropagation();
                    var value = this.field.get(record);
                    if (value) {
                        var model = value.split(',')[0];
                        var id = parseInt(value.split(',')[1], 10);
                        var params = {
                            model: model,
                            res_id: id,
                            mode: ['form'],
                            name: this.attributes.string,
                            context: this.field.get_context(record),
                        };
                        Sao.Tab.create(params);
                    }
                });
            });
        }
    });

    Sao.View.Tree.DictColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-dict',
        get_textual_value: function(record) {
            return '(' + Object.keys(this.field.get_client(record)).length + ')';
        },
    });

    Sao.View.Tree.DateColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-date',
        get_textual_value: function(record) {
            var value = this.field.get_client(record);
            var date_format = this.field.date_format(record);
            return Sao.common.format_date(date_format, value);
        }
    });

    Sao.View.Tree.TimeColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-time',
        get_textual_value: function(record) {
            var value = this.field.get_client(record);
            return Sao.common.format_time(
                    this.field.time_format(record), value);
        }
    });

    Sao.View.Tree.TimeDeltaColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-timedelta'
    });

    Sao.View.Tree.One2ManyColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-one2many',
        get_textual_value: function(record) {
            return '( ' + this.field.get_client(record).length + ' )';
        }
    });

    Sao.View.Tree.Many2ManyColumn = Sao.class_(Sao.View.Tree.One2ManyColumn, {
        class_: 'column-many2many'
    });

    Sao.View.Tree.BinaryColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-binary',
        init: function(model, attributes) {
            Sao.View.Tree.BinaryColumn._super.init.call(this, model, attributes);
            this.filename = attributes.filename || null;
        },
        get_cell: function() {
            var cell = Sao.View.Tree.BinaryColumn._super.get_cell.call(this);
            jQuery('<span/>').appendTo(cell);
            return cell;
        },
        get_textual_value: function(record) {
            var size;
            if (this.field.get_size) {
                size = this.field.get_size(record);
            } else {
                size = this.field.get(record).length;
            }
            return size? Sao.common.humanize(size, 'B') : '';
        },
        update_text: function(cell, record) {
            var text = this.get_textual_value(record);
            cell.children('span').text(text).attr('title', text);
            var button = cell.children('button');
            if (!button.length) {
                button = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'title': Sao.i18n.gettext("Save As..."),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-download')
                ).appendTo(cell)
                    .click(record, event => {
                        // Prevent editable tree to start edition
                        event.stopPropagation();
                        this.save_as(event.data);
                    });
            }
            if (!text) {
                button.hide();
            } else {
                button.show();
            }
        },
        save_as: function(record) {
            var filename;
            var filename_field = record.model.fields[this.filename];
            if (filename_field) {
                filename = filename_field.get_client(record);
            }
            var prm;
            if (this.field.get_data) {
                prm = this.field.get_data(record);
            } else {
                prm = jQuery.when(this.field.get(record));
            }
            prm.done(data => {
                Sao.common.download_file(data, filename);
            });
        },
    });

    Sao.View.Tree.ImageColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-image',
        get_cell: function() {
            var cell = jQuery('<img/>', {
                'class': this.class_ + ' center-block',
                'tabindex': 0
            });
            this.height = parseInt(this.attributes.height || 100, 10);
            this.width = parseInt(this.attributes.width || 300, 10);
            cell.css('max-height', this.height);
            cell.css('max-width', this.width);
            cell.css('height', 'auto');
            cell.css('width', 'auto');
            return cell;
        },
        render: function(record, cell) {
            if (!cell) {
                cell = this.get_cell();
            }
            const render = () => {
                const set_src = data => {
                    cell.attr('src', Sao.common.image_url(data));
                };

                var value = this.field.get_client(record);
                if (value) {
                    if (value > Sao.config.image_max_size) {
                        set_src(null);
                    } else {
                        this.field.get_data(record).done(set_src);
                    }
                } else {
                    set_src(null);
                }
            };
            const render_error = () => {
                cell.attr('src', null);
            };
            if (!record.is_loaded(this.attributes.name)) {
                record.load(this.attributes.name, true, false)
                    .done(render)
                    .fail(render_error);
            } else {
                if (render.exception) {
                    render_error();
                } else {
                    render();
                }
            }
            return cell;
        }
    });

    Sao.View.Tree.URLColumn = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-url',
        render: function(record, cell) {
            cell = Sao.View.Tree.URLColumn._super.render.call(
                    this, record, cell);
            this.field.set_state(record);
            var state_attrs = this.field.get_state_attrs(record);
            if (state_attrs.readonly) {
                cell.hide();
            } else {
                cell.show();
            }
            return cell;
        }
    });

    Sao.View.Tree.ProgressBar = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-progressbar',
        get_cell: function() {
            var cell = jQuery('<div/>', {
                'class': this.class_ + ' progress',
                'tabindex': 0
            });
            var progressbar = jQuery('<div/>', {
                'class': 'progress-bar',
                'role': 'progressbar',
                'aria-valuemin': 0,
                'aria-valuemax': 100
            }).appendTo(cell);
            progressbar.css('min-width: 2em');
            return cell;
        },
        get_textual_value: function(record) {
            var text = this.field.get_client(record, 100);
            if (text) {
                text = Sao.i18n.gettext('%1%', text);
            }
            return text;
        },
        update_text: function(cell, record) {
            var text = this.get_textual_value(record);
            var value = this.field.get(record) || 0;
            var progressbar = cell.find('.progress-bar');
            progressbar.attr('aria-valuenow', value * 100);
            progressbar.css('width', value * 100 + '%');
            progressbar.text(text).attr('title', text);
        }
    });

    Sao.View.Tree.ToleranceGauge = Sao.class_(Sao.View.Tree.CharColumn, {
        class_: 'column-tolerance-gauge form-tolerance-gauge',
        get_cell: function() {
            var cell = jQuery('<div/>', {
                'class': this.class_,
                'tabindex': 0
            });
            var labels = jQuery('<div/>', {
                'class': 'form-tolerance-gauge-labels'
            }).appendTo(cell);
            jQuery('<span/>', {
                'class': 'form-tolerance-gauge-label form-tolerance-gauge-label-min'
            }).appendTo(labels);
            jQuery('<span/>', {
                'class': 'form-tolerance-gauge-label form-tolerance-gauge-label-center'
            }).appendTo(labels);
            jQuery('<span/>', {
                'class': 'form-tolerance-gauge-label form-tolerance-gauge-label-max'
            }).appendTo(labels);
            var track = jQuery('<div/>', {
                'class': 'form-tolerance-gauge-track',
                'role': 'meter'
            }).appendTo(cell);
            jQuery('<div/>', {
                'class': 'form-tolerance-gauge-fill'
            }).appendTo(track);
            jQuery('<div/>', {
                'class': 'form-tolerance-gauge-center-tick'
            }).appendTo(track);
            jQuery('<div/>', {
                'class': 'form-tolerance-gauge-marker'
            }).appendTo(track);
            jQuery('<div/>', {
                'class': 'form-tolerance-gauge-value'
            }).appendTo(cell);
            return cell;
        },
        get_textual_value: function(record) {
            return this.field.get_client(record);
        },
        update_text: function(cell, record) {
            this.el = cell;
            this.min_label = cell.find('.form-tolerance-gauge-label-min');
            this.center_label = cell.find('.form-tolerance-gauge-label-center');
            this.max_label = cell.find('.form-tolerance-gauge-label-max');
            this.track = cell.find('.form-tolerance-gauge-track');
            this.value_label = cell.find('.form-tolerance-gauge-value');
            this._set_gauge(
                record, this.field.get(record), this.get_textual_value(record));
        }
    });
    jQuery.extend(
        Sao.View.Tree.ToleranceGauge.prototype,
        Sao.View.ToleranceGaugeMixin);

    Sao.View.Tree.ButtonColumn = Sao.class_(Object, {
        init: function(view, attributes) {
            this.view = view;
            this.type = 'button';
            this.attributes = attributes;
            this.footers = [];
        },
        render: function(record, el) {
            var button = new Sao.common.Button(this.attributes, el, 'btn-sm');
            if (!el) {
                button.el.click(
                        [record, button], this.button_clicked.bind(this));
            }
            jQuery.map(this.view.screen.model.fields,
                function(field, name) {
                    if ((field.description.loading || 'eager') ==
                        'eager') {
                        return name;
                    } else {
                        return undefined;
                    }
                });
            button.set_state(record);
            return button.el;
        },
        set_visible: function(visible) {
            var cells = this.footers.slice();
            cells.push(this.header);
            for (const cell of cells) {
                if (visible) {
                    cell.show();
                    cell.removeClass('invisible');
                } else {
                    cell.hide();
                    cell.addClass('invisible');
                }
            }
        },
        get_visible: function() {
            return !this.header.hasClass('invisible');
        },
        button_clicked: function(event) {
            var record = event.data[0];
            var button = event.data[1];
            if (record != this.view.screen.current_record) {
                // Need to raise the event to get the record selected
                return true;
            }
            var states = record.expr_eval(this.attributes.states || {});
            if (states.invisible || states.readonly) {
                return;
            }
            button.el.prop('disabled', true);  // state will be reset at display
            var row = this.view.rows.find(function(row) {
                return row.record == record;
            });
            if (row) {
                row._drawed_record = null;  // force redraw the row
            }
            this.view.screen.button(this.attributes);
        }
    });

    Sao.View.TreeXMLViewParser.WIDGETS = {
        'binary': Sao.View.Tree.BinaryColumn,
        'badge': Sao.View.Tree.CharColumn,
        'boolean': Sao.View.Tree.BooleanColumn,
        'callto': Sao.View.Tree.URLColumn,
        'char': Sao.View.Tree.CharColumn,
        'date': Sao.View.Tree.DateColumn,
        'dict': Sao.View.Tree.DictColumn,
        'email': Sao.View.Tree.URLColumn,
        'float': Sao.View.Tree.FloatColumn,
        'image': Sao.View.Tree.ImageColumn,
        'integer': Sao.View.Tree.IntegerColumn,
        'many2many': Sao.View.Tree.Many2ManyColumn,
        'many2one': Sao.View.Tree.Many2OneColumn,
        'numeric': Sao.View.Tree.FloatColumn,
        'one2many': Sao.View.Tree.One2ManyColumn,
        'one2one': Sao.View.Tree.One2OneColumn,
        'progressbar': Sao.View.Tree.ProgressBar,
        'reference': Sao.View.Tree.ReferenceColumn,
        'selection': Sao.View.Tree.SelectionColumn,
        'multiselection': Sao.View.Tree.MultiSelectionColumn,
        'sip': Sao.View.Tree.URLColumn,
        'text': Sao.View.Tree.TextColum,
        'time': Sao.View.Tree.TimeColumn,
        'timedelta': Sao.View.Tree.TimeDeltaColumn,
        'tolerance_gauge': Sao.View.Tree.ToleranceGauge,
        'url': Sao.View.Tree.URLColumn,
        'variation': Sao.View.Tree.CharColumn,
    };

    Sao.View.EditableTree = {};

    Sao.View.EditableTree.editable_mixin = function(widget) {
        var key_press = function(event_) {
            if ((event_.which == Sao.common.TAB_KEYCODE) ||
                    (event_.which == Sao.common.UP_KEYCODE) ||
                    (event_.which == Sao.common.DOWN_KEYCODE) ||
                    (event_.which == Sao.common.ESC_KEYCODE) ||
                    (event_.which == Sao.common.RETURN_KEYCODE)) {
                this.focus_out();
            }
        };
        widget.el.on('keydown', key_press.bind(widget));
    };

    Sao.View.EditableTree.Char = Sao.class_(Sao.View.Form.Char, {
        class_: 'editabletree-char',
        init: function(view, attributes) {
            Sao.View.EditableTree.Char._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.URL = Sao.class_(Sao.View.EditableTree.Char, {
        class_: 'editable-url',
        set_readonly: function(readonly) {
            Sao.View.EditableTree.URL._super.set_readonly.call(this, readonly);
            if (readonly) {
                this.input.hide();
            } else {
                this.input.show();
            }
        },
    });

    Sao.View.EditableTree.Date = Sao.class_(Sao.View.Form.Date, {
        class_: 'editabletree-date',
        init: function(view, attributes) {
            Sao.View.EditableTree.Date._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.Time = Sao.class_(Sao.View.Form.Time, {
        class_: 'editabletree-time',
        init: function(view, attributes) {
            Sao.View.EditableTree.Time._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.TimeDelta = Sao.class_(Sao.View.Form.TimeDelta, {
        class_: 'editabletree-timedelta',
        init: function(view, attributes) {
            Sao.View.EditableTree.TimeDelta._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.Integer = Sao.class_(Sao.View.Form.Integer, {
        class_: 'editabletree-integer',
        init: function(view, attributes) {
            attributes = jQuery.extend({}, attributes);
            delete attributes.symbol;
            Sao.View.EditableTree.Integer._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        },
        get width() {
            return null;
        },
    });

    Sao.View.EditableTree.Float = Sao.class_(Sao.View.Form.Float, {
        class_: 'editabletree-float',
        init: function(view, attributes) {
            attributes = jQuery.extend({}, attributes);
            delete attributes.symbol;
            Sao.View.EditableTree.Float._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        },
        get width() {
            return null;
        },
    });

    Sao.View.EditableTree.Selection = Sao.class_(Sao.View.Form.Selection, {
        class_: 'editabletree-selection',
        init: function(view, attributes) {
            Sao.View.EditableTree.Selection._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.Boolean = Sao.class_(Sao.View.Form.Boolean, {
        class_: 'editabletree-boolean',
        init: function(view, attributes) {
            Sao.View.EditableTree.Boolean._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.Many2One = Sao.class_(Sao.View.Form.Many2One, {
        class_: 'editabletree-many2one',
        init: function(view, attributes) {
            Sao.View.EditableTree.Many2One._super.init.call(
                this, view, attributes);
        },
    });

    Sao.View.EditableTree.Reference = Sao.class_(Sao.View.Form.Reference, {
        class_: 'editabletree-reference',
        init: function(view, attributes) {
            Sao.View.EditableTree.Reference._super.init.call(
                this, view, attributes);
        },
    });

    Sao.View.EditableTree.One2One = Sao.class_(Sao.View.Form.One2One, {
        class_: 'editabletree-one2one',
        init: function(view, attributes) {
            Sao.View.EditableTree.One2One._super.init.call(
                this, view, attributes);
        },
    });

    Sao.View.EditableTree.One2Many = Sao.class_(Sao.View.EditableTree.Char, {
        class_: 'editabletree-one2many',
        init: function(view, attributes) {
            Sao.View.EditableTree.One2Many._super.init.call(
                this, view, attributes);
        },
        display: function(record, field) {
            if (record) {
                this.el.val('(' + field.get_client(record).length + ')');
            } else {
                this.el.val('');
            }
        },
        key_press: function(event_) {
            // TODO: remove when key_press is implemented
            if (event_.which == Sao.common.TAB_KEYCODE) {
                this.focus_out();
            }
        },
        set_value: function(record, field) {
        }
    });

    Sao.View.EditableTree.Binary = Sao.class_(Sao.View.Form.Binary, {
        class_: 'editabletree-binary',
        init: function(view, attributes) {
            Sao.View.EditableTree.Binary._super.init.call(
                this, view, attributes);
            Sao.View.EditableTree.editable_mixin(this);
        }
    });

    Sao.View.EditableTree.WIDGETS = {
        'binary': Sao.View.EditableTree.Binary,
        'boolean': Sao.View.EditableTree.Boolean,
        'callto': Sao.View.EditableTree.URL,
        'char': Sao.View.EditableTree.Char,
        'date': Sao.View.EditableTree.Date,
        'email': Sao.View.EditableTree.URL,
        'float': Sao.View.EditableTree.Float,
        'integer': Sao.View.EditableTree.Integer,
        'many2many': Sao.View.EditableTree.Many2Many,
        'many2one': Sao.View.EditableTree.Many2One,
        'numeric': Sao.View.EditableTree.Float,
        'one2many': Sao.View.EditableTree.One2Many,
        'one2one': Sao.View.EditableTree.One2One,
        'reference': Sao.View.EditableTree.Reference,
        'selection': Sao.View.EditableTree.Selection,
        'sip': Sao.View.EditableTree.URL,
        'text': Sao.View.EditableTree.Char,
        'time': Sao.View.EditableTree.Time,
        'timedelta': Sao.View.EditableTree.TimeDelta,
        'url': Sao.View.EditableTree.URL,
    };

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.View.GraphXMLViewParser = Sao.class_(Sao.View.XMLViewParser, {
        init: function(view, exclude_field, fields) {
            Sao.View.GraphXMLViewParser._super.init.call(
                this, view, exclude_field, fields);
            this._xfield = null;
            this._yfields = [];
        },
        _node_attributes: function(node) {
            var node_attrs = {};
            for (const attribute of node.attributes) {
                node_attrs[attribute.name] = attribute.value;
            }
            if (node_attrs.name) {
                if (!node_attrs.string && (node_attrs.name != '#')) {
                    var field = this.field_attrs[node_attrs.name];
                    node_attrs.string = field.string;
                }
            }
            return node_attrs;
        },
        _parse_graph: function(node, attributes) {
            for (const child of node.childNodes) {
                this.parse(child);
            }
            var Widget = Sao.View.GraphXMLViewParser.WIDGETS[
                attributes.type || 'vbar'];
            var widget = new Widget(this.view, this._xfield, this._yfields);
            this.view.el.append(widget.el);
            this.view.widgets.root = widget;
        },
        _parse_x: function(node, attributes) {
            for (const child of node.children) {
                this._xfield = this._node_attributes(child);
            }
        },
        _parse_y: function(node, attributes) {
            for (const child of node.children) {
                this._yfields.push(this._node_attributes(child));
            }
        }
    });

    Sao.View.Graph = Sao.class_(Sao.View, {
        editable: false,
        view_type: 'graph',
        xml_parser: Sao.View.GraphXMLViewParser,
        init: function(view_id, screen, xml, children_field) {
            this.el = jQuery('<div/>', {
                'class': 'graph'
            });

            Sao.View.Graph._super.init.call(this, view_id, screen, xml);
        },
        display: function() {
            return this.widgets.root.display(this.group);
        }
    });

    Sao.View.Graph.Chart = Sao.class_(Object, {
        _chart_type: undefined,

        init: function(view, xfield, yfields) {
            this.view = view;
            this.xfield = xfield;
            this.yfields = yfields;
            this.el = jQuery('<div/>');
            this.el.uniqueId();
        },
        update_data: function(group) {
            var data = {};
            var record, yfield, key;
            var i, len, j, y_len;

            this.ids = {};
            data.columns = [['labels']];
            data.names = {};
            var key2columns = {};
            var fields2load = [this.xfield.name];
            for (i = 0, len = this.yfields.length; i < len; i++) {
                yfield = this.yfields[i];
                key = yfield.key || yfield.name;
                data.columns.push([key]);
                data.names[key] = yfield.string;
                key2columns[key] = i + 1;
                fields2load.push(yfield.name);
            }

            var prms = [];
            const set_data = index => {
                return () => {
                    record = group[index];
                    var x = record.field_get_client(this.xfield.name);
                    // c3 does not support moment
                    if (x && (x.isDate || x.isDateTime)) {
                        x = x.toString();
                    }
                    var pos = data.columns[0].indexOf(x);
                    if (pos < 0) {
                        pos = data.columns[0].push(x) - 1;
                    }
                    this._add_id(x, record.id);

                    var column;
                    for (j = 0, y_len = this.yfields.length; j < y_len; j++) {
                        yfield = this.yfields[j];
                        key = yfield.key || yfield.name;
                        column = data.columns[key2columns[key]];
                        if (column[pos] === undefined) {
                            column[pos] = null;
                        }
                        if (yfield.domain) {
                            var ctx = jQuery.extend({},
                                    Sao.Session.current_session.context);
                            ctx.context = ctx;
                            ctx._user = Sao.Session.current_session.user_id;
                            for (var field in group.model.fields) {
                                ctx[field] = record.field_get(field);
                            }
                            var decoder = new Sao.PYSON.Decoder(ctx);
                            if (!decoder.decode(yfield.domain)) {
                                continue;
                            }
                        }
                        if (!column[pos]) {
                            column[pos] = 0;
                        }
                        if (yfield.name == '#') {
                            column[pos] += 1;
                        } else {
                            var value = record.field_get(yfield.name);
                            if (value && value.isTimeDelta) {
                                value = value.asSeconds();
                            }
                            column[pos] += value || 0;
                        }
                    }
                };
            };

            var r_prms = [];
            for (i = 0, len = group.length; i < len; i++) {
                record = group[i];
                for (const fname of fields2load) {
                    prms.push(record.load(fname));
                }
                r_prms.push(
                        jQuery.when.apply(jQuery, prms).then(set_data(i)));
            }
            return jQuery.when.apply(jQuery, r_prms).then(function() {
                return data;
            });
        },
        _add_id: function(key, id) {
            if (!(key in this.ids)) {
                this.ids[key] = [];
            }
            this.ids[key].push(id);
        },
        display: function(group) {
            var update_prm = this.update_data(group);
            update_prm.done(data => {
                c3.generate(this._c3_config(data));
            });
            return update_prm;
        },
        _c3_config: function(data) {
            var c3_config = {};

            c3_config.bindto = '#' + this.el.attr('id');
            c3_config.data = data;
            c3_config.data.type = this._chart_type;
            c3_config.data.x = 'labels';
            c3_config.data.onclick = this.action.bind(this);

            var type = this.view.screen.model.fields[this.xfield.name]
                .description.type;
            if ((type == 'date') || (type == 'datetime')) {
                var format_func, date_format, time_format;
                date_format = Sao.common.date_format(
                    this.view.screen.context.date_format);
                time_format = '%X';
                if (type == 'datetime') {
                    c3_config.data.xFormat = '%Y-%m-%d %H:%M:%S';
                    format_func = function(dt) {
                        return Sao.common.format_datetime(
                            date_format + ' ' + time_format, moment(dt));
                    };
                } else {
                    c3_config.data.xFormat = '%Y-%m-%d';
                    format_func = function(dt) {
                        return Sao.common.format_date(date_format, moment(dt));
                    };
                }
                c3_config.axis = {
                    x: {
                        type: 'timeseries',
                        tick: {
                            format: format_func,
                        }
                    }
                };
            } else {
                c3_config.axis = {
                    x: {
                        type: 'category',
                    }
                };
            }
            var color = this.view.attributes.color || Sao.config.graph_color;
            var rgb = Sao.common.hex2rgb(
                Sao.common.COLOR_SCHEMES[color] || color);
            var maxcolor = Math.max.apply(null, rgb);
            var keys = [];
            var i, yfield;
            for (i = 0; i < this.yfields.length; i++) {
                yfield = this.yfields[i];
                keys.push(yfield.key || yfield.name);
            }
            var colors = Sao.common.generateColorscheme(
                color, keys, maxcolor / (keys.length || 1));
            for (i = 0; i < this.yfields.length; i++) {
                yfield = this.yfields[i];
                if (yfield.color) {
                    colors[yfield.key || yfield.name] = yfield.color;
                }
            }
            c3_config.data.color = function(color, column) {
                // column is an object when called for legend
                var key = column.id || column;
                return colors[key] || color;
            };
            return c3_config;
        },
        action: function(data, element) {
            var ids = this.ids[this._action_key(data)];
            var ctx = jQuery.extend({}, this.view.screen.group._context);
            delete ctx.active_ids;
            delete ctx.active_id;
            Sao.Action.exec_keyword('graph_open', {
                model: this.view.screen.model_name,
                id: ids[0],
                ids: ids
            }, ctx, false);
        },
        _action_key: function(data) {
            var x = data.x;
            var type = this.view.screen.model.fields[this.xfield.name]
                .description.type;
            if (x && (type == 'datetime')) {
                x = Sao.DateTime(x).toString();
            } else if (x && (type == 'date')) {
                x = Sao.Date(x).toString();
            }
            return x;
        }
    });

    Sao.View.Graph.VerticalBar = Sao.class_(Sao.View.Graph.Chart, {
        _chart_type: 'bar'
    });

    Sao.View.Graph.HorizontalBar = Sao.class_(Sao.View.Graph.Chart, {
        _chart_type: 'bar',
        _c3_config: function(data) {
            var config = Sao.View.Graph.HorizontalBar._super._c3_config
                .call(this, data);
            config.axis.rotated = true;
            return config;
        }
    });

    Sao.View.Graph.Line = Sao.class_(Sao.View.Graph.Chart, {
        _chart_type: 'line',
        _c3_config: function(data) {
            var config =  Sao.View.Graph.Line._super._c3_config
                .call(this, data);
            config.line = {
                connectNull: true,
            };
            return config;
        }
    });

    Sao.View.Graph.Pie = Sao.class_(Sao.View.Graph.Chart, {
        _chart_type: 'pie',
        _c3_config: function(data) {
            var config = Sao.View.Graph.Pie._super._c3_config.call(this, data);
            var pie_columns = [], pie_names = {};
            var i, len;
            var labels, values;

            for (i = 0, len = data.columns.length; i < len; i++) {
                if (data.columns[i][0] == 'labels') {
                    labels = data.columns[i].slice(1);
                } else {
                    values = data.columns[i].slice(1);
                }
            }

            // Pie chart do not support axis definition.
            delete config.axis;
            delete config.data.x;
            var format_func;
            var type = this.view.screen.model.fields[this.xfield.name]
                .description.type;
            if ((type == 'date') || (type == 'datetime')) {
                var date_format = Sao.common.date_format(
                    this.view.screen.context.date_format);
                var datetime_format = date_format + ' %X';
                if (type == 'datetime') {
                    format_func = function(dt) {
                        return Sao.common.format_datetime(datetime_format, dt);
                    };
                } else {
                    format_func = function(dt) {
                        return Sao.common.format_date(date_format, dt);
                    };
                }
            }
            var label;
            for (i = 0, len = labels.length; i < len; i++) {
                label = labels[i];
                if (format_func) {
                    label = format_func(label);
                }
                pie_columns.push([i, values[i]]);
                pie_names[i] = label;
            }

            config.data.columns = pie_columns;
            config.data.names = pie_names;
            return config;
        },
        _add_id: function(key, id) {
            var type = this.xfield.type;
            if ((type == 'date') || (type == 'datetime')) {
                var date_format = Sao.common.date_format(
                    this.view.screen.context.date_format);
                var datetime_format = date_format + ' %X';
                if (type == 'datetime') {
                    key = Sao.common.format_datetime(datetime_format, key);
                } else {
                    key = Sao.common.format_date(date_format, key);
                }
            }
            Sao.View.Graph.Pie._super._add_id.call(this, key, id);
        },
        _action_key: function(data) {
            return data.id;
        }
    });

    Sao.View.GraphXMLViewParser.WIDGETS = {
        'hbar': Sao.View.Graph.HorizontalBar,
        'line': Sao.View.Graph.Line,
        'pie': Sao.View.Graph.Pie,
        'vbar': Sao.View.Graph.VerticalBar,
    };
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    function set_calendar_height(el) {
        var height = window.innerHeight - 15;
        if (el.parents('.modal-body').length) {
            var modal_height = parseInt(
                el.parents('.modal-body').css('max-height'), 10);
            if (isNaN(modal_height)) {
                height -= 200;
            } else {
                height = modal_height;
            }
        }
        height -= el.find('.fc-toolbar').height();
        el.parents('.panel-body').each(function(i, panel) {
            panel = jQuery(panel);
            height -= parseInt(panel.css('padding-top'), 10);
            height -= parseInt(panel.css('padding-bottom'), 10);
        });
        el.parents('.panel').each(function(i, panel) {
            panel = jQuery(panel);
            var lengths = panel.css('box-shadow').match(/\d+px/g);
            if (lengths && lengths.length) {
                lengths = lengths.map(function(length) {
                    length = parseInt(length, 10);
                    return isNaN(length) ? 0 : length;
                });
                height -= Math.max.apply(null, lengths);
            }
        });
        height -= el[0].getBoundingClientRect().y;
        el.fullCalendar('option', 'contentHeight', height);
    }

    jQuery(window).resize(function() {
        jQuery('.calendar').each(function(i, el) {
            set_calendar_height(jQuery(el));
        });
    });

    Sao.View.CalendarXMLViewParser = Sao.class_(Sao.View.XMLViewParser, {
        _parse_calendar: function(node, attributes) {
            for (const child of node.childNodes) {
                this.parse(child);
            }

            var view_week;
            if (this.view.screen.model.fields[attributes.dtstart]
                .description.type == "datetime") {
                view_week = 'agendaWeek';
            } else {
                view_week = 'basicWeek';
            }
            var view_day;
            if (this.view.screen.model.fields[attributes.dtstart]
                    .description.type == "datetime") {
                view_day = 'agendaDay';
            } else {
                view_day =  'basicDay';
            }
            var defaultview = 'month';
            if (attributes.mode == 'week') {
                defaultview = view_week;
            }
            if (attributes.mode == 'day') {
                defaultview = view_day;
            }
            var header = {
                left: 'today prev,next',
                center: 'title',
                right: 'month,' + view_week + ',' + view_day,
            };
            if (Sao.i18n.rtl) {
                var header_rtl = jQuery.extend({}, header);
                header_rtl.left = header.right;
                header_rtl.right = header.left;
                header = header_rtl;
            }
            this.view.el.fullCalendar({
                defaultView: defaultview,
                header: header,
                timeFormat: 'H:mm',
                scrollTime: (
                    this.view.screen.context.calendar_scroll_time ||
                    Sao.Time(6)).toString(),
                events: this.view.get_events.bind(this.view),
                locale: Sao.i18n.getlang().slice(0, 2),
                isRTL: Sao.i18n.rtl,
                themeSystem: 'bootstrap3',
                bootstrapGlyphicons: {
                    'prev': 'chevron-' + (Sao.i18n.rtl? 'right' : 'left'),
                    'next': 'chevron-' + (Sao.i18n.rtl? 'left' : 'right'),
                },
                buttonTextOverride: {
                    'today': Sao.i18n.gettext("Today"),
                    'month': Sao.i18n.gettext("Month"),
                    'week': Sao.i18n.gettext("Week"),
                    'day': Sao.i18n.gettext("Day"),
                },
                eventRender: this.view.event_render.bind(this.view),
                eventResize: this.view.event_resize.bind(this.view),
                eventDrop: this.view.event_drop.bind(this.view),
                eventClick: this.view.event_click.bind(this.view),
                dayClick: this.view.day_click.bind(this.view),
            });

            if (attributes.height !== undefined) {
                this.view.el.css('min-height', attributes.height + 'px');
            }
            if (attributes.width !== undefined) {
                this.view.el.css('min-width', attributes.width + 'px');
            }
        },
        _parse_field: function(node, attributes) {
            this.view.fields.push(attributes.name);
        },
    });

    Sao.View.Calendar = Sao.class_(Sao.View, {
    /* Fullcalendar works with utc date, the default week start day depends on
       the user language, the events dates are handled by moment object. */
        editable: false,
        creatable: false,
        view_type: 'calendar',
        xml_parser: Sao.View.CalendarXMLViewParser,
        init: function(view_id, screen, xml) {
            // Used to check if the events are still processing
            this.processing = true;
            this.fields = [];
            this.el = jQuery('<div/>', {
                'class': 'calendar'
            });
            Sao.View.Calendar._super.init.call(this, view_id, screen, xml);
            //this.el.fullCalendar('changeView', defaultview);
        },
        get_colors: function(record) {
            var colors = {};
            colors.text_color = Sao.config.calendar_colors[0];
            if (this.attributes.color) {
                colors.text_color = record.field_get(
                    this.attributes.color);
            }
            colors.background_color = Sao.config.calendar_colors[1];
            if (this.attributes.background_color) {
                colors.background_color = record.field_get(
                    this.attributes.background_color);
            }
            return colors;
        },
        display: function() {
            this.el.fullCalendar('render');
            set_calendar_height(this.el);
            // Don't refetch events from server when get_events is processing
            if (!this.processing) {
                this.el.fullCalendar('refetchEvents');
            }
        },
        insert_event: function(record) {
            var description_fields = jQuery.extend([], this.fields);
            var title_field = description_fields.shift();
            var title = this.screen.model.fields[title_field].get_client(
                record);
            var field_start = record.model.fields[this.attributes.dtstart];
            var date_start = field_start.get_client(record);
            field_start.set_state(record);
            var date_end = null;
            var field_end;
            if (this.attributes.dtend) {
                field_end = record.model.fields[this.attributes.dtend];
                date_end = field_end.get_client(record);
                field_end.set_state(record);
            }

            var model_access = Sao.common.MODELACCESS.get(
                this.screen.model_name);
            var editable = (
                parseInt(this.attributes.editable || 1, 10) &&
                model_access.write);

            var description = [];
            for (const field of description_fields) {
                description.push(
                    this.screen.model.fields[field].get_client( record));
            }
            description = description.join('\n');
            if (date_start) {
                var allDay = date_start.isDate &&
                    (!date_end || date_end.isDate);
                if (allDay && date_end && !date_end.isSame(date_start)  &&
                        this.screen.current_view.view_type == "calendar") {
                    // Add one day to allday event that last more than one day.
                    // http://github.com/fullcalendar/fullcalendar/issues/2909
                    date_end.add(1, 'day');
                }
                // Skip invalid event
                if (date_end && date_start > date_end) {
                    return;
                }
                var event_editable = (
                    editable &&
                    !field_start.get_state_attrs(record).readonly &&
                    (!date_end || !field_end.get_state_attrs(record).readonly));
                var colors = this.get_colors(record);
                var values = {
                    title: title,
                    start: date_start,
                    end: date_end,
                    allDay: allDay,
                    editable: event_editable,
                    color: colors.background_color,
                    textColor: colors.text_color,
                    record: record,
                    description: description
                };
                this.events.push(values);
            }
        },
        get_events: function(start, end, timezone, callback) {
            this.processing = true;
            this.start = Sao.DateTime(start.utc());
            this.end = Sao.DateTime(end.utc());
            var prm = jQuery.when();
            if (this.screen.current_view &&
                (this.screen.current_view.view_type != 'form')) {
                var search_string = this.screen.screen_container.get_text();
                prm = this.screen.search_filter(search_string);
            }
            this.events =  [];
            var promisses = [];
            prm.then(() => {
                this.group.forEach(record => {
                    var record_promisses = [];
                    for (const name of this.fields) {
                        record_promisses.push(record.load(name));
                    }
                    var prm = jQuery.when.apply(jQuery, record_promisses).then(
                        () => {
                            this.insert_event(record);
                        });
                    promisses.push(prm);
                });
                return jQuery.when.apply(jQuery, promisses).then(() => {
                    callback(this.events);
                }).always(() => {
                    this.processing = false;
                });
            });
        },
        event_click: function(calEvent, jsEvent, view) {
            // Prevent opening the wrong event while the calendar event clicked
            // when loading
            if (!this.clicked_event) {
                this.clicked_event = true;
                this.screen.current_record = calEvent.record;
                this.screen.switch_view().always(() => {
                    this.clicked_event = false;
                });
            }
        },
        event_drop: function(event, delta, revertFunc, jsEvent, ui, view) {
            var dtstart = this.attributes.dtstart;
            var dtend = this.attributes.dtend;
            var record = event.record;
            var previous_start = record.field_get(dtstart);
            var previous_end = previous_start;
            if (dtend) {
                previous_end = record.field_get(dtend);
            }
            var new_start = event.start;
            var new_end = event.end;
            if (new_end == previous_start || !new_end) {
                new_end = new_start;
            }
            if (previous_start.isDateTime) {
                new_end = Sao.DateTime(new_end.format()).utc();
                new_start = Sao.DateTime(new_start.format()).utc();
            } else if (!previous_start.isSame(previous_end)) {
                // Remove the day that was added at the event end.
                new_end.subtract(1, 'day');
                this.el.fullCalendar('refetchEvents');
            }
            if (previous_start <= new_start) {
                if (dtend) {
                    record.field_set_client(dtend, new_end);
                }
                record.field_set_client(dtstart, new_start);
            } else {
                record.field_set_client(dtstart, new_start);
                if (dtend) {
                    record.field_set_client(dtend, new_end);
                }
            }
            record.save();
        },
        event_resize: function(event, delta, revertFunc, jsEvent, ui, view) {
            var dtend = this.attributes.dtend;
            var record = event.record;
            var previous_end = record.field_get(dtend);
            var new_end = event.end;
            if (previous_end.isDateTime === true) {
                new_end = Sao.DateTime(new_end.format()).utc();
            } else {
                // Remove the day that was added at the event end.
                new_end.subtract(1, 'day');
                this.el.fullCalendar('refetchEvents');
            }
            if (new_end == previous_end || !new_end) {
                new_end = previous_end;
            }
            record.field_set_client(dtend, new_end);
            record.save();
        },
        event_render: function(event, element, view) {
            // The description field is added in the calendar events and the
            // event time is not shown in week view.
            if (this.screen.model.fields.date &&
                   this.screen.view_name == 'calendar') {
                element.find('.fc-time').remove();
            }
            element.find('.fc-content')
                .append(jQuery('<div/>', {'class': 'fc-description'})
                    .text(event.description));
            element.css('white-space', 'pre')
                .css('overflow', 'hidden')
                .css('text-overflow', 'ellipsis')
                .attr('title', [event.title, event.description]
                    .filter(function(e) {
                        return e;
                    }).join('\n'));
        },
        day_click: function(date, jsEvent, view){
            var model_access = Sao.common.MODELACCESS.get(
                this.screen.model_name);
            if (parseInt(this.attributes.editable || 1, 10) &&
                model_access.create) {
                // Set the calendar date to the clicked date
                this.el.fullCalendar('gotoDate', date);
                this.screen.current_record = null;
                this.screen.new_();
            }
        },
        current_domain: function() {
            if (!this.start && !this.end) {
                return [['id', '=', -1]];
            }
            var start = Sao.DateTime(this.start);
            var end = Sao.DateTime(this.end);
            var dtstart = this.attributes.dtstart;
            var dtend = this.attributes.dtend || dtstart;
            var fields = this.screen.model.fields;
            if (fields[dtstart].description.type == 'date') {
                start = start.todate();
            }
            if (fields[dtend].description.type == 'date') {
                end = end.todate();
            }
            return [
                [dtstart, '!=', null],
                [dtend, '!=', null],
                ['OR',
                    ['AND', [dtstart, '>=', start], [dtstart,  '<', end]],
                    ['AND', [dtend, '>=', start], [dtend, '<', end]],
                    ['AND',  [dtstart, '<', start], [dtend, '>', end]],
                ],
            ];
        },
        get_displayed_period: function(){
            var DatesPeriod = [];
            if (this.start && this.end) {
                DatesPeriod.push(this.start, this.end);
            }
            return DatesPeriod;
        },
        set_default_date: function(record, selected_date){
            var dtstart = this.attributes.dtstart;
            var field = record.model.fields[dtstart];
            if (field instanceof Sao.field.DateTime) {
                selected_date = Sao.DateTime(selected_date);
            } else if (field instanceof Sao.field.Date) {
                selected_date = Sao.Date(selected_date);
            }
            field.set(record, selected_date);
            record.on_change([dtstart]);
            record.on_change_with([dtstart]);
        },
        get_selected_date: function(){
            return this.el.fullCalendar('getDate');
        },
        get listed_records() {
            return this.events.map(e => e.record);
        },
    });

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.View.ListGroupViewForm = Sao.class_(Sao.View.Form, {
        get record() {
            return this._record;
        },
        set record(value) {
            this._record = value;
        },
        button_clicked: function(event) {
            if (Sao.common.compare(this.screen.selected_records, [this.record])) {
                Sao.View.ListGroupViewForm._super.button_clicked.call(this, event);
            }
        }
    });

    Sao.View.ListForm = Sao.class_(Sao.View, {
        editable: true,
        creatable: true,
        view_type: 'list-form',
        init: function(view_id, screen, xml) {
            Sao.View.ListForm._super.init.call(this, view_id, screen, xml);
            if (this.attributes.creatable) {
                this.creatable = Boolean(parseInt(this.attributes.creatable, 10));
            }

            this.form_xml = xml;
            this.el = jQuery('<ul/>', {
                'class': 'list-group list-form'
            });
            this._view_forms = [];
        },
        display: function(selected_nodes) {
            var record, view_form, view_form_frame, to_delete;
            var deferreds = [];
            var new_elements = [];
            var selected = new Set();
            if (!jQuery.isEmptyObject(selected_nodes)) {
                for (const id_path of selected_nodes) {
                    selected.add(id_path[0]);
                }
            }
            for (var i = 0; i < this.group.length; i++) {
                record = this.group[i];
                view_form = this._view_forms[i];
                if (!view_form) {
                    view_form_frame = this._create_form(record);
                    new_elements.push(view_form_frame);
                    view_form = this._view_forms[this._view_forms.length - 1];
                } else {
                    view_form_frame = view_form.el.parent();
                    view_form.record = record;
                }

                if (~this.group.record_deleted.indexOf(record) ||
                        ~this.group.record_removed.indexOf(record)) {
                    view_form_frame.addClass('disabled');
                } else {
                    view_form_frame.removeClass('disabled');
                }
                if ((this.record === record) || selected.has(record.id)) {
                    view_form_frame.addClass('list-group-item-selected');
                } else {
                    view_form_frame.removeClass('list-group-item-selected');
                }
                deferreds.push(view_form.display());
            }
            if (new_elements.length > 0) {
                this.el.append(new_elements);
            }
            to_delete = this._view_forms.splice(this.group.length);
            jQuery(to_delete.map(function (vf) { return vf.el[0]; }))
                .parent().detach();
            return jQuery.when.apply(jQuery, deferreds);
        },
        get_selected_paths: function() {
            var paths = [];
            var view_form_frame;
            for (const form of this._view_forms) {
                view_form_frame = form.el.parent();
                if (view_form_frame.hasClass('list-group-item-selected')) {
                    paths.push([form.record.id]);
                }
            }
            return paths;
        },
        _create_form: function(record) {
            var view_form = new Sao.View.ListGroupViewForm(
                this.view_id, this.screen, this.form_xml);
            view_form.record = record;
            this._view_forms.push(view_form);
            var frame = jQuery('<li/>', {
                'class': 'list-group-item list-form-item'
            });
            frame.append(view_form.el);
            frame.click(
                this._view_forms.length - 1, this._select_row.bind(this));
            return frame;
        },
        get selected_records() {
            var records = [];
            for (const view_form of this._view_forms) {
                const frame = view_form.el.parent();
                if (frame.hasClass('list-group-item-selected')) {
                    records.push(view_form.record);
                }
            }
            return records;
        },
        get listed_records() {
            return this.group.slice();
        },
        set_cursor: function(new_, reset_view) {
            if (new_) {
                this.el.animate({
                    scrollTop: this.el[0].scrollHeight
                });
            }
        },
        select_records: function(from, to) {
            jQuery(this._view_forms.map(function (vf) { return vf.el[0]; }))
                .parent().removeClass('list-group-item-selected');
            if ((from === null) && (to === null)) {
                return;
            }

            if (!from) {
                from = 0;
            }
            if (!to) {
                to = 0;
            }
            if (to < from) {
                var tmp = from;
                from = to;
                to = tmp;
            }

            for (const form of this._view_forms.slice(from, to + 1)) {
                form.el.parent().addClass('list-group-item-selected');
            }
        },
        _select_row: function(event_) {
            var next_form_idx = event_.data;
            var next_view_form = this._view_forms[next_form_idx];

            var prm = jQuery.when();
            if (this.record && (next_view_form.record != this.record)) {
                if (!this.screen.group.parent) {
                    if (!this.record.validate(
                            this.get_fields(), false, false, false)) {
                        prm = jQuery.Deferred().reject();
                    } else {
                        prm = this.record.save();
                    }
                } else if (this.screen.attributes.pre_validate) {
                    prm = this.record.pre_validate();
                }
            }

            prm.done(() => {
                var current_view_form;

                if (event_.shiftKey) {
                    let i = 0;
                    for (const other_view_form of this._view_forms) {
                        if (other_view_form.record === this.record) {
                            current_view_form = other_view_form;
                            break;
                        }
                        i++;
                    }
                    this.select_records(i, next_form_idx);
                } else {
                    if (!(event_.ctrlKey || event_.metaKey)) {
                        this.select_records(null, null);
                    }
                    this.record = next_view_form.record;
                    next_view_form.el.parent()
                        .toggleClass('list-group-item-selected');
                }
                if (current_view_form) {
                    this.record = current_view_form.record;
                }
            });
        }
    });

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.Action = {
        report_blob_url: undefined
    };

    Sao.Action.exec_action = function(action, data, context) {
        if (!context) {
            context = {};
        } else {
            context = jQuery.extend({}, context);
        }
        var session = Sao.Session.current_session;
        if (data === undefined) {
            data = {};
        } else {
            data = jQuery.extend({}, data);
        }

        delete context.active_id;
        delete context.active_ids;
        delete context.active_model;

        function add_name_suffix(name, context){
            if (!data.model || !data.ids) {
                return jQuery.when(name);
            }
            var max_records = 5;
            var ids = data.ids.filter(function(id){
                return id >= 0;
            }).slice(0, max_records);
            if (!ids.length) {
                return jQuery.when(name);
            }
            return Sao.rpc({
                'method': 'model.' + data.model + '.read',
                'params': [ids, ['rec_name'], context]
            }, Sao.Session.current_session).then(function(result) {
                var name_suffix = result.map(function(record){
                    return record.rec_name;
                }).join(Sao.i18n.gettext(', '));

                if (data.ids.length > ids.length) {
                    name_suffix += Sao.i18n.gettext(',...');
                }
                if (name_suffix) {
                    return Sao.i18n.gettext('%1 (%2)', name, name_suffix);
                } else {
                    return name;
                }
            });
        }
        data.action_id = action.id;
        var params = {
            'icon': action['icon.rec_name'] || '',
        };
        var name_prm;
        switch (action.type) {
            case 'ir.action.act_window':
                if (!jQuery.isEmptyObject(action.views)) {
                    params.view_ids = [];
                    params.mode = [];
                    for (const view of action.views) {
                        params.view_ids.push(view[0]);
                        params.mode.push(view[1]);
                    }
                } else if (!jQuery.isEmptyObject(action.view_id)) {
                    params.view_ids = [action.view_id[0]];
                }

                if (action.pyson_domain === undefined) {
                    action.pyson_domain = '[]';
                }
                var ctx = {
                    active_model: data.model || null,
                    active_id: data.id || null,
                    active_ids: data.ids || [],
                };
                ctx = jQuery.extend(ctx, session.context);
                ctx._user = session.user_id;
                var decoder = new Sao.PYSON.Decoder(ctx);
                params.context = jQuery.extend(
                    {}, context,
                    decoder.decode( action.pyson_context || '{}'));
                ctx = jQuery.extend(ctx, params.context);

                ctx.context = ctx;
                decoder = new Sao.PYSON.Decoder(ctx);
                params.domain = decoder.decode(action.pyson_domain);
                params.order = decoder.decode(action.pyson_order);
                params.search_value = decoder.decode(
                    action.pyson_search_value || '[]');
                params.tab_domain = [];
                for (const element of action.domains) {
                    params.tab_domain.push(
                        [element[0], decoder.decode(element[1]), element[2]]);
                }
                name_prm = jQuery.when(action.name);
                params.model = action.res_model || data.res_model;
                params.res_id = action.res_id || data.res_id;
                params.context_model = action.context_model;
                params.context_domain = action.context_domain;
                if ((action.limit !== undefined) && (action.limit !== null)) {
                    params.limit = action.limit;
                }

                if (action.keyword) {
                    name_prm = add_name_suffix(action.name, params.context);
                }
                return name_prm.then(function(name) {
                    params.name = name;
                    return Sao.Tab.create(params);
                });
            case 'ir.action.wizard':
                params.action = action.wiz_name;
                params.data = data;
                params.context = context;
                params.window = action.window;
                name_prm = jQuery.when(action.name);
                if ((action.keyword || 'form_action') === 'form_action') {
                    name_prm = add_name_suffix(action.name, context);
                }
                return name_prm.then(function(name) {
                    params.name = name;
                    return Sao.Wizard.create(params);
                });
            case 'ir.action.report':
                params.name = action.report_name;
                params.data = data;
                params.direct_print = action.direct_print;
                params.context = context;
                return Sao.Action.exec_report(params);
            case 'ir.action.url':
                window.open(action.url, '_blank', 'noreferrer,noopener');
                return jQuery.when();
        }
    };

    Sao.Action.exec_keyword = function(
        keyword, data, context, warning=true, alwaysask=false) {
        var model_id = data.id;
        var args = {
            'method': 'model.' + 'ir.action.keyword.get_keyword',
            'params': [keyword, [data.model, model_id], {}]
        };
        var prm = Sao.rpc(args, Sao.Session.current_session);
        var exec_action = function(actions) {
            var keyact = {};
            for (var i in actions) {
                var action = actions[i];
                keyact[action.name.split(' / ').pop()] = action;
            }
            var prm = Sao.common.selection(
                    Sao.i18n.gettext('Select your action'),
                    keyact, alwaysask);
            return prm.then(function(action) {
                Sao.Action.exec_action(action, data, context);
            }, function() {
                if (jQuery.isEmptyObject(keyact) && warning) {
                    alert(Sao.i18n.gettext('No action defined.'));
                }
            });
        };
        return prm.pipe(exec_action);
    };

    Sao.Action.exec_report = function(attributes) {
        if (!attributes.context) {
            attributes.context = {};
        }
        var data = jQuery.extend({}, attributes.data);
        var context = jQuery.extend({}, Sao.Session.current_session.context);
        jQuery.extend(context, attributes.context);
        context.direct_print = attributes.direct_print;

        var prm = Sao.rpc({
            'method': 'report.' + attributes.name + '.execute',
            'params': [data.ids || [], data, context]
        }, Sao.Session.current_session);
        prm.done(function(result) {
            var report_type = result[0];
            var data = result[1];
            // TODO direct print
            var name = result[3];

            var file_name = name + '.' + report_type;
            Sao.common.download_file(data, file_name);
        });
    };

    Sao.Action.execute = function(action, data, context, keyword) {
        if (typeof action == 'number') {
            action = Sao.rpc({
                'method': 'model.ir.action.get_action_value',
                'params': [action, context],
            }, Sao.Session.current_session, false);
        }
        if (keyword) {
            var keywords = {
                'ir.action.report': 'form_report',
                'ir.action.wizard': 'form_action',
                'ir.action.act_window': 'form_relate'
            };
            if (!action.keyword) {
                action.keyword = keywords[action.type];
            }
        }
        return Sao.Action.exec_action(action, data, context);
    };

    Sao.Action.evaluate = function(action, atype, record) {
        action = jQuery.extend({}, action);
        var email = {};
        if ('pyson_email' in action) {
            email = record.expr_eval(action.pyson_email);
            if (jQuery.isEmptyObject(email)) {
                email = {};
            }
        }
        if (!('subject' in email)) {
            email.subject = action.name.replace(/_/g, '');
        }
        action.email = email;
        return action;
    };
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    var ENCODINGS = ["866", "ansi_x3.4-1968", "arabic", "ascii",
        "asmo-708", "big5", "big5-hkscs", "chinese", "cn-big5", "cp1250",
        "cp1251", "cp1252", "cp1253", "cp1254", "cp1255", "cp1256",
        "cp1257", "cp1258", "cp819", "cp866", "csbig5", "cseuckr",
        "cseucpkdfmtjapanese", "csgb2312", "csibm866", "csiso2022jp",
        "csiso2022kr", "csiso58gb231280", "csiso88596e", "csiso88596i",
        "csiso88598e", "csiso88598i", "csisolatin1", "csisolatin2",
        "csisolatin3", "csisolatin4", "csisolatin5", "csisolatin6",
        "csisolatin9", "csisolatinarabic", "csisolatincyrillic",
        "csisolatingreek", "csisolatinhebrew", "cskoi8r", "csksc56011987",
        "csmacintosh", "csshiftjis", "cyrillic", "dos-874", "ecma-114",
        "ecma-118", "elot_928", "euc-jp", "euc-kr", "gb18030", "gb2312",
        "gb_2312", "gb_2312-80", "gbk", "greek", "greek8", "hebrew",
        "hz-gb-2312", "ibm819", "ibm866", "iso-2022-cn", "iso-2022-cn-ext",
        "iso-2022-jp", "iso-2022-kr", "iso-8859-1", "iso-8859-10",
        "iso-8859-11", "iso-8859-13", "iso-8859-14", "iso-8859-15",
        "iso-8859-16", "iso-8859-2", "iso-8859-3", "iso-8859-4",
        "iso-8859-5", "iso-8859-6", "iso-8859-6-e", "iso-8859-6-i",
        "iso-8859-7", "iso-8859-8", "iso-8859-8-e", "iso-8859-8-i",
        "iso-8859-9", "iso-ir-100", "iso-ir-101", "iso-ir-109",
        "iso-ir-110", "iso-ir-126", "iso-ir-127", "iso-ir-138",
        "iso-ir-144", "iso-ir-148", "iso-ir-149", "iso-ir-157", "iso-ir-58",
        "iso8859-1", "iso8859-10", "iso8859-11", "iso8859-13", "iso8859-14",
        "iso8859-15", "iso8859-2", "iso8859-3", "iso8859-4", "iso8859-5",
        "iso8859-6", "iso8859-7", "iso8859-8", "iso8859-9", "iso88591",
        "iso885910", "iso885911", "iso885913", "iso885914", "iso885915",
        "iso88592", "iso88593", "iso88594", "iso88595", "iso88596",
        "iso88597", "iso88598", "iso88599", "iso_8859-1", "iso_8859-15",
        "iso_8859-1:1987", "iso_8859-2", "iso_8859-2:1987", "iso_8859-3",
        "iso_8859-3:1988", "iso_8859-4", "iso_8859-4:1988", "iso_8859-5",
        "iso_8859-5:1988", "iso_8859-6", "iso_8859-6:1987", "iso_8859-7",
        "iso_8859-7:1987", "iso_8859-8", "iso_8859-8:1988", "iso_8859-9",
        "iso_8859-9:1989", "koi", "koi8", "koi8-r", "koi8-ru", "koi8-u",
        "koi8_r", "korean", "ks_c_5601-1987", "ks_c_5601-1989", "ksc5601",
        "ksc_5601", "l1", "l2", "l3", "l4", "l5", "l6", "l9", "latin1",
        "latin2", "latin3", "latin4", "latin5", "latin6", "logical", "mac",
        "macintosh", "ms932", "ms_kanji", "shift-jis", "shift_jis", "sjis",
        "sun_eu_greek", "tis-620", "unicode-1-1-utf-8", "us-ascii",
        "utf-16", "utf-16be", "utf-16le", "utf-8", "utf8", "visual",
        "windows-1250", "windows-1251", "windows-1252", "windows-1253",
        "windows-1254", "windows-1255", "windows-1256", "windows-1257",
        "windows-1258", "windows-31j", "windows-874", "windows-949",
        "x-cp1250", "x-cp1251", "x-cp1252", "x-cp1253", "x-cp1254",
        "x-cp1255", "x-cp1256", "x-cp1257", "x-cp1258", "x-euc-jp", "x-gbk",
        "x-mac-cyrillic", "x-mac-roman", "x-mac-ukrainian", "x-sjis",
        "x-user-defined", "x-x-big5"];

    Sao.Window = {};

    Sao.Window.InfoBar = Sao.class_(Object, {
        init: function() {
            this.el = jQuery('<div/>', {
                'class': 'infobar',
            });
            this.__messages = new Set();
        },
        add: function(message, type, kind) {
            kind = kind || null;
            if (!message) {
                return;
            }
            var key = JSON.stringify([message, type]);
            if (!this.__messages.has(key)) {
                var infobar = jQuery('<div/>', {
                    'class': 'alert alert-dismissible alert-' + (
                        type || 'error'),
                    'role': 'alert',
                }).append(jQuery('<button/>', {
                    'type': 'button',
                    'class': 'close',
                    'aria-label': Sao.i18n.gettext("Close"),
                    'title': Sao.i18n.gettext("Close"),
                    'data-dismiss': 'alert',
                }).append(jQuery('<span/>', {
                    'aria-hidden': true,
                }).append('&times;'))
                ).append(jQuery('<span/>')
                    .css('white-space','pre-wrap')
                    .text(message))
                    .on('close.bs.alert',
                        null, key, this.__response.bind(this));
                this.el.append(infobar);
                infobar.data('kind', kind);
            }
        },
        __response: function(evt) {
            this.__messages.add(evt.data);
        },
        refresh: function(kind) {
            kind = kind || null;
            this.el.children().each((i, el) => {
                el = jQuery(el);
                if (el.data('kind') === kind) {
                    el.remove();
                }
            });
        },
        clear: function() {
            var kinds = this.el.children().each(
                (i, el) => jQuery(el).data('kind'));
            new Set(kinds).forEach(kind => {
                this.refresh(kind);
            });
            this.__messages.clear();
        },
    });

    Sao.Window.Form = Sao.class_(Object, {
        init: function(screen, callback, kwargs) {
            kwargs = kwargs || {};
            this.screen = screen;
            this.callback = callback;
            this.many = kwargs.many || 0;
            this.domain = kwargs.domain || null;
            this.context = kwargs.context || null;
            this.save_current = kwargs.save_current;
            var title_prm = jQuery.when(kwargs.title || '').then(
                title => {
                    if (screen.breadcrumb.length) {
                        var breadcrumb = jQuery.extend([], screen.breadcrumb);
                        if (title) {
                            breadcrumb.push(title);
                        }
                        this.title = breadcrumb.slice(-3, -1).map(function(x) {
                            return Sao.common.ellipsize(x, 30);
                        }).concat(breadcrumb.slice(-1)).join(' › ');
                        if (breadcrumb.length > 3) {
                            this.title = '... › ' + this.title;
                        }
                    } else {
                        if (!title) {
                            title = Sao.common.MODELNAME.get(this.screen.model_name);
                        }
                        this.title = title;
                    }
                    var revision = this.screen.context._datetime;
                    var label;
                    if (revision &&
                        Sao.common.MODELHISTORY.contains(this.screen.model_name)) {
                        var date_format = Sao.common.date_format(
                            this.screen.context.date_format);
                        var time_format = '%H:%M:%S.%f';
                        var revision_label = ' @ ' + Sao.common.format_datetime(
                            date_format + ' ' + time_format, revision);
                        label = Sao.common.ellipsize(
                            this.title, 80 - revision_label.length) +
                            revision_label;
                        title = this.title + revision_label;
                    } else {
                        label = Sao.common.ellipsize(this.title, 80);
                    }
                    return label;
                });

            this.prev_view = screen.current_view;
            this.screen.screen_container.alternate_view = true;
            this.info_bar = new Sao.Window.InfoBar();
            var view_type = kwargs.view_type || 'form';

            this.switch_prm = this.screen.switch_view(view_type)
                .done(() => {
                    if (kwargs.new_ &&
                        (this.screen.current_view.view_type == view_type)) {
                        this.screen.new_(undefined, kwargs.defaults);
                    }
                });
            var dialog = new Sao.Dialog('', 'window-form', 'lg', false);
            this.dialog = dialog;
            this.el = dialog.modal;
            this.el.on('keydown', e => {
                if (e.which == Sao.common.ESC_KEYCODE) {
                    e.preventDefault();
                    this.response('RESPONSE_CANCEL');
                }
            });

            var readonly = this.screen.group.readonly;

            this.but_ok = null;
            this.but_new = null;

            this._initial_value = null;
            this.view_type = view_type;
            if ((view_type == 'form') && !readonly) {
                let label;
                if (kwargs.new_) {
                    label = Sao.i18n.gettext('Delete');
                } else {
                    label = Sao.i18n.gettext("Discard changes");
                    var record = this.screen.current_record;
                    this._initial_value = record.get_on_change_value();
                    if (record.group.parent &&
                        record.model.fields[record.group.parent_name]) {
                        var parent_field = record.model.fields[
                            record.group.parent_name];
                        this._initial_value[record.group.parent_name] = (
                            parent_field.get_eval(record));
                    }
                }

                dialog.footer.append(jQuery('<button/>', {
                    'class': 'btn btn-link',
                    'type': 'button',
                    'title': label,
                }).text(label).click(() => {
                    this.response('RESPONSE_CANCEL');
                }));
            }

            if (kwargs.new_ && this.many) {
                let label;
                if (this.save_current && !readonly) {
                    label = Sao.i18n.gettext("Save and New");
                } else {
                    label = Sao.i18n.gettext("Add and New");
                }
                dialog.footer.append(jQuery('<button/>', {
                    'class': 'btn btn-default',
                    'type': 'button',
                    'title': label,
                }).text(label).click(() => {
                    this.response('RESPONSE_ACCEPT');
                }));
            }

            if (this.save_current && !readonly) {
                let label = Sao.i18n.gettext("Save");
                this.but_ok = jQuery('<button/>', {
                    'class': 'btn btn-primary',
                    'type': 'submit',
                    'title': label,
                }).text(label).appendTo(dialog.footer);
            } else {
                let label;
                if (readonly || (view_type == 'tree')) {
                    label = Sao.i18n.gettext("Close");
                } else if (kwargs.new_) {
                    label = Sao.i18n.gettext("Add");
                } else {
                    label = Sao.i18n.gettext("Apply changes");
                }
                this.but_ok = jQuery('<button/>', {
                    'class': 'btn btn-primary',
                    'type': 'submit',
                    'title': label,
                }).text(label).appendTo(dialog.footer);
            }
            dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            if (view_type == 'tree') {
                var menu = jQuery('<div/>', {
                    'class': 'window-form-toolbar'
                }).appendTo(dialog.body);
                var group = jQuery('<div/>', {
                    'class': 'input-group input-group-sm'
                }).appendTo(menu);

                this.wid_text = jQuery('<input/>', {
                    type: 'input'
                }).appendTo(menu);
                this.wid_text.hide();

                var buttons = jQuery('<div/>', {
                    'class': 'input-group-btn'
                }).appendTo(group);
                var access = Sao.common.MODELACCESS.get(this.screen.model_name);

                var disable_during = function(callback) {
                    return function(evt) {
                        var button = jQuery(evt.target);
                        button.prop('disabled', true);
                        (callback(evt) || jQuery.when())
                            .always(function() {
                                button.prop('disabled', false);
                            });
                    };
                };

                this.but_switch = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Switch"),
                    'title': Sao.i18n.gettext("Switch"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-switch')
                ).appendTo(buttons);
                this.but_switch.click(
                    disable_during(this.switch_.bind(this)));

                this.but_previous = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Previous"),
                    'title': Sao.i18n.gettext("Previous"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-back')
                ).appendTo(buttons);
                this.but_previous.click(
                    disable_during(this.previous.bind(this)));

                this.label = jQuery('<span/>', {
                    'class': 'badge'
                }).appendTo(jQuery('<span/>', {
                    'class': 'btn hidden-xs',
                }).appendTo(buttons));

                this.but_next = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Next"),
                    'title': Sao.i18n.gettext("Next"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-forward')
                ).appendTo(buttons);
                this.but_next.click(disable_during(this.next.bind(this)));

                if (this.domain) {
                    this.wid_text.show();

                    this.but_add = jQuery('<button/>', {
                        'class': 'btn btn-default btn-sm',
                        'type': 'button',
                        'aria-label': Sao.i18n.gettext("Add"),
                        'title': Sao.i18n.gettext("Add"),
                    }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-add')
                    ).appendTo(buttons);
                    this.but_add.click(disable_during(this.add.bind(this)));
                    this.but_add.prop('disabled', !access.read || readonly);

                    this.but_remove = jQuery('<button/>', {
                        'class': 'btn btn-default btn-sm',
                        'type': 'button',
                        'aria-label': Sao.i18n.gettext("Remove"),
                        'title': Sao.i18n.gettext("Remove"),
                    }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
                    ).appendTo(buttons);
                    this.but_remove.click(
                        disable_during(this.remove.bind(this)));
                    this.but_remove.prop('disabled', !access.read || readonly);
                }

                this.but_new = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("New"),
                    'title': Sao.i18n.gettext("New"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-create')
                ).appendTo(buttons);
                this.but_new.click(disable_during(this.new_.bind(this)));
                this.but_new.prop('disabled', !access.create || readonly);

                this.but_del = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Delete"),
                    'title': Sao.i18n.gettext("Delete"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-delete')
                ).appendTo(buttons);
                this.but_del.click(disable_during(this.delete_.bind(this)));
                this.but_del.prop('disabled', !access['delete'] || readonly);

                this.but_undel = jQuery('<button/>', {
                    'class': 'btn btn-default btn-sm',
                    'type': 'button',
                    'aria-label': Sao.i18n.gettext("Undelete"),
                    'title': Sao.i18n.gettext("Undelete"),
                }).append(Sao.common.ICONFACTORY.get_icon_img('tryton-undo')
                ).appendTo(buttons);
                this.but_undel.click(disable_during(this.undelete.bind(this)));
                this.but_undel.prop('disabled', !access['delete'] || readonly);
            }

            var content = jQuery('<div/>').appendTo(dialog.body);

            dialog.body.append(this.info_bar.el);

            this.screen.windows.push(this);

            this.switch_prm.done(() => {
                if (this.screen.current_view.view_type != view_type) {
                    this.destroy();
                } else {
                    title_prm.done(dialog.add_title.bind(dialog));
                    content.append(this.screen.screen_container.alternate_viewport);
                    this.el.modal('show');
                }
            });
            this.el.on('shown.bs.modal', event => {
                this.screen.display().done(() => {
                    this.screen.set_cursor();
                });
            });
            this.el.on('hidden.bs.modal', function(event) {
                jQuery(this).remove();
            });
        },
        record_message: function(position, size) {
            if (this.view_type != 'tree') {
                return;
            }
            var name = '_';
            var access = Sao.common.MODELACCESS.get(this.screen.model_name);
            var deletable = this.screen.deletable;
            var readonly = this.screen.group.readonly;
            if (position >= 1) {
                name = position;
                if (this.domain) {
                    this.but_remove.prop('disabled', false);
                }
                this.but_next.prop('disabled', position >= size);
                this.but_previous.prop('disabled', position <= 1);
                this.but_del.prop(
                    'disabled',
                    readonly ||
                    !access.delete ||
                    !deletable);
                this.but_undel.prop('disabled', readonly);
            } else {
                this.but_del.prop('disabled', true);
                this.but_undel.prop('disabled', true);
                this.but_next.prop('disabled', true);
                this.but_previous.prop('disabled', true);
                if (this.domain) {
                    this.but_remove.prop('disabled', true);
                }
            }
            var message = name + '/' + size;
            this.label.text(message).attr('title', message);
        },
        record_modified: function() {
            this.info_bar.refresh();
        },
        add: function() {
            var domain = jQuery.extend([], this.domain);
            var model_name = this.screen.model_name;
            var value = this.wid_text.val();

            const callback = result => {
                var prm = jQuery.when();
                if (!jQuery.isEmptyObject(result)) {
                    var ids = [];
                    for (const record of result) {
                        ids.push(record[0]);
                    }
                    this.screen.group.load(ids, true, -1, null);
                    prm = this.screen.display();
                }
                prm.done(() => {
                    this.screen.set_cursor();
                });
                this.entry.val('');
            };
            var parser = new Sao.common.DomainParser();
            new Sao.Window.Search(model_name, callback, {
                sel_multi: true,
                context: this.context,
                domain: domain,
                search_filter: parser.quote(value)
            });
        },
        remove: function() {
            this.screen.remove(false, true, false);
        },
        new_: function() {
            this.screen.new_();
            this._initial_value = null;
            this.many -= 1;
            if (this.many == 0) {
                this.but_new.addClass('disabled');
            }
        },
        delete_: function() {
            this.screen.remove(false, false, false);
        },
        undelete: function() {
            this.screen.unremove();
        },
        previous: function() {
            return this.screen.display_previous();
        },
        next: function() {
            return this.screen.display_next();
        },
        switch_: function() {
            return this.screen.switch_view();
        },
        response: function(response_id) {
            var result;
            this.screen.current_view.set_value();
            var readonly = this.screen.group.readonly;
            if (~['RESPONSE_OK', 'RESPONSE_ACCEPT'].indexOf(response_id) &&
                    !readonly &&
                    this.screen.current_record) {
                this.screen.current_record.validate().then(validate => {
                    if (validate && this.screen.attributes.pre_validate) {
                        return this.screen.current_record.pre_validate().then(
                            () => true, () => false);
                    }
                    return validate;
                }).then(validate => {
                    var closing_prm = jQuery.Deferred();
                    if (validate && this.save_current) {
                        this.screen.save_current().then(closing_prm.resolve,
                            closing_prm.reject);
                    } else if (validate &&
                            this.screen.current_view.view_type == 'form') {
                        var view = this.screen.current_view;
                        var validate_prms = [];
                        for (var name in view.widgets) {
                            var widget = view.widgets[name];
                            if (widget.screen &&
                                widget.screen.attributes.pre_validate) {
                                var record = widget.screen.current_record;
                                if (record) {
                                    validate_prms.push(record.pre_validate());
                                }
                            }
                        }
                        jQuery.when.apply(jQuery, validate_prms).then(
                            closing_prm.resolve, closing_prm.reject);
                    } else if (!validate) {
                        this.info_bar.add(
                            this.screen.invalid_message(), 'danger');
                        closing_prm.reject();
                    } else {
                        this.info_bar.clear();
                        closing_prm.resolve();
                    }

                    closing_prm.fail(() => {
                        this.screen.display().done(() => {
                            this.screen.set_cursor();
                        });
                    });

                    // TODO Add support for many
                    closing_prm.done(() => {
                        if (response_id == 'RESPONSE_ACCEPT') {
                            this.screen.new_();
                            this.screen.current_view.display().done(() => {
                                this.screen.set_cursor();
                            });
                            this.many -= 1;
                            if (this.many === 0) {
                                this.but_new.prop('disabled', true);
                            }
                        } else {
                            result = true;
                            if (this.callback) {
                                this.callback(result);
                            }
                            this.destroy();
                        }
                    });
                });
                return;
            }

            var cancel_prm = null;
            if (response_id == 'RESPONSE_CANCEL' &&
                    !readonly &&
                    this.screen.current_record) {
                result = false;
                var record = this.screen.current_record;
                var added = record.modified_fields.id;
                if ((record.id < 0) || this.save_current) {
                    cancel_prm = this.screen.cancel_current(
                        this._initial_value);
                } else if (record.modified) {
                    record.cancel();
                    cancel_prm = record.reload().then(() => {
                        this.screen.display();
                    });
                }
                if (added) {
                    record.modified_fields.id = added;
                }
            } else {
                result = (response_id != 'RESPONSE_CANCEL') && !readonly;
            }
            (cancel_prm || jQuery.when()).then(() => {
                if (this.callback) {
                    this.callback(result);
                }
                this.destroy();
            });
        },
        destroy: function() {
            this.screen.windows.splice(this.screen.windows.indexOf(this), 1);
            this.screen.screen_container.alternate_view = false;
            this.screen.screen_container.alternate_viewport.children()
                .detach();
            if (this.prev_view) {
                // Empty when opening from Many2One
                this.screen.switch_view(this.prev_view.view_type);
            }
            this.el.modal('hide');
        }
    });

    Sao.Window.Attachment = Sao.class_(Sao.Window.Form, {
        init: function(record, callback) {
            this.resource = record.model.name + ',' + record.id;
            this.attachment_callback = callback;
            var context = jQuery.extend({}, record.get_context());
            var screen = new Sao.Screen('ir.attachment', {
                domain: [['resource', '=', this.resource]],
                mode: ['tree', 'form'],
                context: context,
            });
            var title = record.rec_name().then(function(rec_name) {
                return Sao.i18n.gettext('Attachments (%1)', rec_name);
            });
            Sao.Window.Attachment._super.init.call(this, screen, this.callback,
                {view_type: 'tree', title: title});
            this.switch_prm = this.switch_prm.then(function() {
                return screen.search_filter();
            });
        },
        callback: function(result) {
            var prm = jQuery.when();
            if (result) {
                prm = this.screen.save_current();
            }
            if (this.attachment_callback) {
                prm.always(this.attachment_callback.bind(this));
            }
        },
        add_data: function(data, filename) {
            var screen = this.screen;
            this.switch_prm.then(function() {
                screen.new_().then(function(record) {
                    var data_field = record.model.fields.data;
                    record.field_set_client(
                        data_field.description.filename, filename);
                    record.field_set_client('data', data);
                    screen.display();
                });
            });
        },
        add_uri: function(uri) {
            var screen = this.screen;
            this.switch_prm.then(function() {
                screen.current_record = null;
                screen.switch_view('form').then(function() {
                    screen.new_().then(function(record) {
                        record.field_set_client('link', uri);
                        record.field_set_client('type', 'link');
                        screen.display();
                    });
                });
            });
        },
        add_text: function(text) {
            var screen = this.screen;
            this.switch_prm.then(function() {
                screen.current_record = null;
                screen.switch_view('form').then(function() {
                    screen.new_().then(function(record) {
                        record.field_set_client('description', text);
                        screen.display();
                    });
                });
            });
        },
    });
    Sao.Window.Attachment.get_attachments = function(record) {
        var prm;
        if (record && (record.id >= 0)) {
            var context = record.get_context();
            prm = Sao.rpc({
                'method': 'model.ir.attachment.search_read',
                'params': [
                    [['resource', '=', record.model.name + ',' + record.id]],
                    0, 20, null, ['rec_name', 'name', 'type', 'link'],
                    context],
            }, record.model.session);
        } else {
            prm = jQuery.when([]);
        }
        var partial = function(callback, attachment, context, session) {
            return function() {
                return callback(attachment, context, session);
            };
        };
        return prm.then(function(attachments) {
            return attachments.map(function(attachment) {
                var name = attachment.rec_name;
                if (attachment.type == 'link') {
                    return [name, attachment.link];
                } else {
                    var callback = Sao.Window.Attachment[
                        'open_' + attachment.type];
                    return [name, partial(
                        callback, attachment, context, record.model.session)];
                }
            });
        });
    };
    Sao.Window.Attachment.open_data = function(attachment, context, session) {
        Sao.rpc({
            'method': 'model.ir.attachment.read',
            'params': [
                [attachment.id], ['data'], context],
        }, session).then(function(values) {
            Sao.common.download_file(values[0].data, attachment.name);
        });
    };

    Sao.Window.Note = Sao.class_(Sao.Window.Form, {
        init: function(record, callback) {
            this.resource = record.model.name + ',' + record.id;
            this.note_callback = callback;
            var context = jQuery.extend({}, record.get_context());
            var screen = new Sao.Screen('ir.note', {
                domain: [['resource', '=', this.resource]],
                mode: ['tree', 'form'],
                context: context,
            });
            var title = record.rec_name().then(function(rec_name) {
                return Sao.i18n.gettext('Notes (%1)', rec_name);
            });
            Sao.Window.Note._super.init.call(this, screen, this.callback,
                {view_type: 'tree', title: title});
            this.switch_prm = this.switch_prm.then(function() {
                return screen.search_filter();
            });
        },
        callback: function(result) {
            var prm = jQuery.when();
            if (result) {
                var unread = this.screen.group.model.fields.unread;
                for (const record of this.screen.group) {
                    if (record.get_loaded() || record.id < 0) {
                        if (!record.modified_fields.unread) {
                            unread.set_client(record, false);
                        }
                    }
                }
                prm = this.screen.save_current();
            }
            if (this.note_callback) {
                prm.always(this.note_callback.bind(this));
            }
        }
    });

    Sao.Window.Log = Sao.class_(Sao.Window.Form, {
        init: function(record) {
            this.resource = record.model.name + ',' + record.id;
            var title = record.rec_name().then(function(rec_name) {
                return Sao.i18n.gettext("Logs (%1)", rec_name);
            });
            var context = jQuery.extend({}, record.get_context());

            var form = jQuery('<div/>', {
                'class': 'form',
            });
            var table = jQuery('<table/>', {
                'class': 'form-container responsive responsive-noheader',
            }).appendTo(form);
            var body = jQuery('<tbody/>').appendTo(table);

            record.model.execute(
                'read', [[record.id],
                    ['create_uid.rec_name', 'create_date',
                        'write_uid.rec_name', 'write_date',
                        'xml_id']], context
            ).then(([log]) => {
                var row, cell, label, input;

                row = jQuery('<tr/>').appendTo(body);
                cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                label = jQuery('<label/>', {
                    'class': 'form-label',
                    'text': Sao.i18n.gettext("Model:"),
                }).appendTo(cell);
                label.uniqueId();
                cell = jQuery('<td/>').appendTo(row);
                input = jQuery('<input/>', {
                    'readonly': true,
                    'class': 'form-control input-sm',
                    'aria-labelledby': label.attr('id'),
                }).val(record.model.name).appendTo(cell);
                input.uniqueId();

                cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                label = jQuery('<label/>', {
                    'class': 'form-label',
                    'text': Sao.i18n.gettext("ID:"),
                }).appendTo(cell);
                label.uniqueId();
                cell = jQuery('<td/>').appendTo(row);
                input = jQuery('<input/>', {
                    'type': 'integer',
                    'readonly': true,
                    'class': 'form-control input-sm',
                    'aria-labelledby': label.attr('id'),
                }).val(record.id).appendTo(cell);
                input.css('text-align', 'end');
                input.uniqueId();

                if (log.xml_id) {
                    const [module, xml_id] = log.xml_id.split('.', 2);

                    row = jQuery('<tr/>').appendTo(body);
                    cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                    label = jQuery('<label/>', {
                        'class': 'form-label',
                        'text': Sao.i18n.gettext("Module:"),
                    }).appendTo(cell);
                    label.uniqueId();
                    cell = jQuery('<td/>').appendTo(row);
                    input = jQuery('<input/>', {
                        'readonly': true,
                        'class': 'form-control input-sm',
                        'aria-labelledby': label.attr('id'),
                    }).val(module).appendTo(cell);
                    input.uniqueId();

                    cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                    label = jQuery('<label/>', {
                        'class': 'form-label',
                        'text': Sao.i18n.gettext("XML ID:"),
                    }).appendTo(cell);
                    label.uniqueId();
                    cell = jQuery('<td/>').appendTo(row);
                    input = jQuery('<input/>', {
                        'readonly': true,
                        'class': 'form-control input-sm',
                        'aria-labelledby': label.attr('id'),
                    }).val(xml_id).appendTo(cell);
                    input.uniqueId();
                }

                [['create_uid.', Sao.i18n.gettext("Created by:"),
                    'create_date', Sao.i18n.gettext("Created at:")],
                    ['write_uid.', Sao.i18n.gettext("Last Modified by:"),
                        'write_date', Sao.i18n.gettext("Last Modified at:")],
                ].forEach(([user, user_label, date, date_label]) => {

                    row = jQuery('<tr/>').appendTo(body);
                    cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                    label = jQuery('<label/>', {
                        'class': 'form-label',
                        'text': user_label,
                    }).appendTo(cell);
                    label.uniqueId();
                    cell = jQuery('<td/>').appendTo(row);
                    user = log[user];
                    if (user) {
                        user = user.rec_name;
                    }
                    input = jQuery('<input/>', {
                        'readonly': true,
                        'class': 'form-control input-sm',
                        'aria-labelledby': label.attr('id'),
                    }).val(user || '').appendTo(cell);
                    input.css('width', '50ch');
                    input.uniqueId();

                    cell = jQuery('<td/>').css('text-align', 'end').appendTo(row);
                    label = jQuery('<label/>', {
                        'class': 'form-label',
                        'text': date_label,
                    }).appendTo(cell);
                    label.uniqueId();
                    cell = jQuery('<td/>').appendTo(row);
                    date = log[date];
                    if (date) {
                        date = Sao.common.format_datetime(
                            Sao.common.date_format() + ' %H:%M:%S', date);
                    }
                    input = jQuery('<input/>', {
                        'readonly': true,
                        'class': 'form-control input-sm',
                        'aria-labelledby': label.attr('id'),
                    }).val(date || '').appendTo(cell);
                    input.css('width', date.length + 'ch');
                    input.uniqueId();
                });
            });

            var screen = new Sao.Screen('ir.model.log', {
                domain: [['resource', '=', this.resource]],
                mode: ['tree', 'form'],
                context: context,
            });
            Sao.Window.Log._super.init.call(
                this, screen, null, {view_type: 'tree', title: title});
            this.switch_prm = this.switch_prm.then(function() {
                return screen.search_filter();
            });

            this.dialog.body.prepend(form);
        },
    });

    Sao.Window.Search = Sao.class_(Object, {
        init: function(model, callback, kwargs) {
            kwargs = kwargs || {};
            var views_preload = kwargs.views_preload || {};
            this.model_name = model;
            this.domain = kwargs.domain || [];
            this.context = kwargs.context || {};
            this.order = kwargs.order || null;
            this.view_ids = kwargs.view_ids;
            this.views_preload = views_preload;
            this.sel_multi = kwargs.sel_multi;
            this.callback = callback;
            var title = kwargs.title;
            if (!title) {
                title = Sao.common.MODELNAME.get(model);
            }
            this.title = title;
            this.exclude_field = kwargs.exclude_field || null;
            var dialog = new Sao.Dialog(Sao.i18n.gettext(
                'Search %1', this.title), '', 'lg', false);
            this.el = dialog.modal;
            this.el.on('keydown', e => {
                if (e.which == Sao.common.ESC_KEYCODE) {
                    e.preventDefault();
                    this.response('RESPONSE_CANCEL');
                }
            });

            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.response('RESPONSE_CANCEL');
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-default',
                'type': 'button',
                'title': Sao.i18n.gettext("Find"),
            }).text(Sao.i18n.gettext('Find')).click(() => {
                this.response('RESPONSE_APPLY');
            }).appendTo(dialog.footer);
            if (kwargs.new_ && Sao.common.MODELACCESS.get(model).create) {
                jQuery('<button/>', {
                    'class': 'btn btn-default',
                    'type': 'button',
                    'title': Sao.i18n.gettext("New"),
                }).text(Sao.i18n.gettext('New')).click(() => {
                    this.response('RESPONSE_ACCEPT');
                }).appendTo(dialog.footer);
            }
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).appendTo(dialog.footer);
            dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            this.screen = new Sao.Screen(model, {
                mode: ['tree'],
                context: this.context,
                domain: this.domain,
                order: this.order,
                view_ids: kwargs.view_ids,
                views_preload: views_preload,
                row_activate: this.activate.bind(this),
                readonly: true,
                search_window: true,
                breadcrumb: [this.title],
            });
            this.screen.load_next_view().done(() => {
                this.screen.switch_view().done(() => {
                    if (!this.sel_multi) {
                        this.screen.current_view.selection_mode = (
                            Sao.common.SELECTION_SINGLE);
                    } else {
                        this.screen.current_view.selection_mode = (
                            Sao.common.SELECTION_MULTIPLE);
                    }
                    dialog.body.append(this.screen.screen_container.el);
                    this.el.modal('show');
                    this.screen.display();
                    if (kwargs.search_filter !== undefined) {
                        this.screen.search_filter(kwargs.search_filter);
                    }
                });
            });
            this.el.on('hidden.bs.modal', function(event) {
                jQuery(this).remove();
            });
        },
        activate: function() {
            this.response('RESPONSE_OK');
        },
        response: function(response_id) {
            var records;
            var value = [];
            if (response_id == 'RESPONSE_OK') {
                records = this.screen.current_view.selected_records;
            } else if (response_id == 'RESPONSE_APPLY') {
                this.screen.search_filter(
                    this.screen.screen_container.get_text());
                return;
            } else if (response_id == 'RESPONSE_ACCEPT') {
                var view_ids = jQuery.extend([], this.view_ids);
                if (!jQuery.isEmptyObject(view_ids)) {
                    // Remove the first tree view as mode is form only
                    view_ids.shift();
                }
                var screen = new Sao.Screen(this.model_name, {
                    domain: this.domain,
                    context: this.context,
                    order: this.order,
                    mode: ['form'],
                    view_ids: view_ids,
                    views_preload: this.views_preload,
                    exclude_field: this.exclude_field,
                });

                var callback = function(result) {
                    if (result) {
                        var record = screen.current_record;
                        this.callback([[record.id,
                            record._values.rec_name || '']]);
                    } else {
                        this.callback(null);
                    }
                };
                this.el.modal('hide');
                new Sao.Window.Form(screen, callback.bind(this), {
                    new_: true,
                    save_current: true,
                });
                return;
            }
            if (records) {
                var index, record;
                for (index in records) {
                    record = records[index];
                    value.push([record.id, record._values.rec_name || '']);
                }
            }
            this.callback(value);
            this.el.modal('hide');
        }
    });

    Sao.Window.Preferences = Sao.class_(Object, {
        init: function(callback) {
            var dialog = new Sao.Dialog('Preferences', '', 'lg');
            this.el = dialog.modal;

            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.response('RESPONSE_CANCEL');
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).appendTo(dialog.footer);
            dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            this.screen = new Sao.Screen('res.user', {
                mode: []
            });
            // Reset readonly set automaticly by MODELACCESS
            this.screen.attributes.readonly = false;
            this.screen.group.readonly = false;
            this.screen.group.skip_model_access = true;

            const set_view = view => {
                this.screen.add_view(view);
                this.screen.switch_view().done(() => {
                    this.screen.new_(false);
                    this.screen.model.execute('get_preferences', [false], {})
                    .then(set_preferences, this.destroy);
                });
            };
            const set_preferences = preferences => {
                this.screen.current_record.cancel();
                this.screen.current_record.set(preferences);
                this.screen.current_record.id =
                    this.screen.model.session.user_id;
                this.screen.current_record.validate(null, true).then(() => {
                    this.screen.display(true);
                });
                dialog.body.append(this.screen.screen_container.el);
                this.el.modal('show');
            };
            this.el.on('hidden.bs.modal', function(event) {
                callback();
                jQuery(this).remove();
            });

            this.screen.model.execute('get_preferences_fields_view', [], {})
                .then(set_view, this.destroy);
        },
        response: function(response_id) {
            const end = () => {
                this.destroy();
            };
            var prm = jQuery.when();
            if (response_id == 'RESPONSE_OK') {
                prm = this.screen.current_record.validate()
                    .then(validate => {
                        if (validate) {
                            var values = jQuery.extend({}, this.screen.get());
                            return this.screen.model.execute(
                                'set_preferences', [values], {});
                        }
                    });
            }
            prm.done(end);
        },
        destroy: function() {
            this.el.modal('hide');
        }
    });

    Sao.Window.Revision = Sao.class_(Object, {
        init: function(revisions, revision, callback) {
            this.callback = callback;
            var dialog = new Sao.Dialog(
                    Sao.i18n.gettext('Revision'), '', 'lg');
            this.el = dialog.modal;

            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.response('RESPONSE_CANCEL');
            }).appendTo(dialog.footer);
            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).appendTo(dialog.footer);
            dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            var group = jQuery('<div/>', {
                'class': 'form-group'
            }).appendTo(dialog.body);
            jQuery('<label/>', {
                'for': 'revision',
                'text': 'Revision'
            }).appendTo(group);
            this.select = jQuery('<select/>', {
                'class': 'form-control',
                id: 'revision',
                'placeholder': Sao.i18n.gettext('Revision')
            }).appendTo(group);
            var date_format = Sao.common.date_format();
            var time_format = '%H:%M:%S.%f';
            this.select.append(jQuery('<option/>', {
                value: null,
                text: ''
            }));
            for (let rev of revisions) {
                var name = rev[2];
                rev = rev[0];
                this.select.append(jQuery('<option/>', {
                    value: rev.valueOf(),
                    text: Sao.common.format_datetime(
                        date_format + ' ' + time_format, rev) + ' ' + name,
                }));
            }
            if (revision) {
                this.select.val(revision.valueOf());
            }
            this.el.modal('show');
            this.el.on('hidden.bs.modal', function(event) {
                jQuery(this).remove();
            });
        },
        response: function(response_id) {
            var revision = null;
            if (response_id == 'RESPONSE_OK') {
                revision = this.select.val();
                if (revision) {
                    revision = Sao.DateTime(parseInt(revision, 10));
                }
            }
            this.el.modal('hide');
            this.callback(revision);
        }
    });

    Sao.Window.CSV = Sao.class_(Object, {
        init: function(title) {
            this.dialog = new Sao.Dialog(title, 'csv', 'lg');
            this.el = this.dialog.modal;

            this.fields = {};
            this.fields_model = {};
            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(Sao.i18n.gettext('Cancel')).click(() => {
                this.response('RESPONSE_CANCEL');
            }).appendTo(this.dialog.footer);

            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("OK"),
            }).text(Sao.i18n.gettext('OK')).click(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            }).appendTo(this.dialog.footer);

            var row_fields = jQuery('<div/>', {
                'class': 'row'
            }).appendTo(this.dialog.body);

            var column_fields_all = jQuery('<div/>', {
                'class': 'col-md-5',
            }).append(jQuery('<div/>', {
                'class': 'panel panel-default',
            }).append(jQuery('<div/>', {
                'class': 'panel-heading',
            }).append(jQuery('<h3/>', {
                'class': 'panel-title',
                'text': Sao.i18n.gettext('All Fields')
            })))).appendTo(row_fields);

            this.fields_all = jQuery('<ul/>', {
                'class': 'list-unstyled column-fields panel-body'
            }).css('cursor', 'pointer')
                .appendTo(column_fields_all.find('.panel'));

            this.model_populate(this._get_fields(this.screen.model_name));
            this.view_populate(this.fields_model, this.fields_all);

            this.column_buttons = jQuery('<div/>', {
                'class': 'col-md-2'
            }).appendTo(row_fields);

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Add"),
            }).text(' ' + Sao.i18n.gettext('Add')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-add')
            ).click(() => {
                this.fields_all.find('.bg-primary').each((i, el_field) => {
                    this.sig_sel_add(el_field);
                });
            })
            .appendTo(this.column_buttons);

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Remove"),
            }).text(' ' + Sao.i18n.gettext('Remove')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-remove')
            ).click(() => {
                // sig_unsel
                this.fields_selected.children('li.bg-primary').remove();
            })
            .appendTo(this.column_buttons);

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Clear"),
            }).text(' ' + Sao.i18n.gettext('Clear')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-clear')
            ).click(() => {
                this.fields_selected.empty();
            })
            .appendTo(this.column_buttons);

            jQuery('<hr>').appendTo(this.column_buttons);

            var column_fields_selected = jQuery('<div/>', {
                'class': 'col-md-5',
            }).append(jQuery('<div/>', {
                'class': 'panel panel-default',
            }).append(jQuery('<div/>', {
                'class': 'panel-heading',
            }).append(jQuery('<h3/>', {
                'class': 'panel-title',
                'text': Sao.i18n.gettext('Fields Selected')
            })))).appendTo(row_fields);

            this.fields_selected = jQuery('<ul/>', {
                'class': 'list-unstyled column-fields panel-body',
            }).css('cursor', 'pointer')
                .appendTo(column_fields_selected.find('.panel'));

            this.chooser_form = jQuery('<div/>', {
                'class': 'form-inline'
            }).appendTo(this.dialog.body);

            var row_csv_param = jQuery('<div/>', {
            }).appendTo(this.dialog.body);

            jQuery('<label/>', {
                'text': Sao.i18n.gettext('CSV Parameters')
            }).append(jQuery('<span/>', {
                'class': 'caret',
            }).html('&nbsp;'))
                .css('cursor', 'pointer')
                .on('click', () => {
                    this.expander_csv.collapse('toggle');
                }).appendTo(row_csv_param);

            this.expander_csv = jQuery('<div/>', {
                'id': 'expander_csv',
                'class': 'collapse form-inline'
            }).appendTo(row_csv_param);

            var delimiter_label = jQuery('<label/>', {
                'text': Sao.i18n.gettext('Delimiter:'),
                'class': 'control-label',
                'for': 'input-delimiter'
            });

            var separator = ',';
            if (navigator.platform &&
                    navigator.platform.slice(0, 3) == 'Win') {
                separator = ';';
            }
            this.el_csv_delimiter = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control',
                'id': 'input-delimiter',
                'size': '1',
                'maxlength': '1',
                'value': separator
            });

            jQuery('<div/>', {
                'class': 'form-group'
            }).append(delimiter_label)
                .append(this.el_csv_delimiter)
                .appendTo(this.expander_csv);
            this.expander_csv.append(' ');

            var quotechar_label = jQuery('<label/>', {
                'text': Sao.i18n.gettext('Quote Char:'),
                'class': 'control-label',
                'for': 'input-quotechar'
            });

            this.el_csv_quotechar = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control',
                'id': 'input-quotechar',
                'size': '1',
                'maxlength': '1',
                'value': '"',
            });

            jQuery('<div/>', {
                'class': 'form-group'
            }).append(quotechar_label)
                .append(this.el_csv_quotechar)
                .appendTo(this.expander_csv);
            this.expander_csv.append(' ');

            this.el.modal('show');
            this.el.on('hidden.bs.modal', function() {
                jQuery(this).remove();
            });
        },
        _get_fields: function(model) {
            return Sao.rpc({
                'method': 'model.' + model + '.fields_get'
            }, this.session, false);
        },
        on_row_expanded: function(node) {
            var container_view = jQuery('<ul/>').css('list-style', 'none')
                .insertAfter(node.view);
            this.children_expand(node);
            this.view_populate(node.children, container_view);
        },
        destroy: function() {
            this.el.modal('hide');
        }
    });

    Sao.Window.Import = Sao.class_(Sao.Window.CSV, {
        init: function(name, screen) {
            this.name = name;
            this.screen = screen;
            this.session = Sao.Session.current_session;
            this.fields_data = {}; // Ask before Removing this.
            this.fields_invert = {};
            Sao.Window.Import._super.init.call(this,
                Sao.i18n.gettext('CSV Import: %1', name));

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Auto-Detect"),
            }).text(' ' + Sao.i18n.gettext('Auto-Detect')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-search')
            ).click(() => {
                this.autodetect();
            })
            .appendTo(this.column_buttons);

            var chooser_label = jQuery('<label/>', {
                'text': Sao.i18n.gettext('File to Import'),
                'class': 'col-sm-6 control-label',
                'for': 'input-csv-file'
            });

            this.file_input = jQuery('<input/>', {
                'type': 'file',
                'id': 'input-csv-file'
            });

            jQuery('<div/>', {
                'class': 'form-group'
            }).append(chooser_label).append(jQuery('<div/>', {
                'class': 'col-sm-6'
            }).append(this.file_input))
            .appendTo(this.chooser_form);

            var encoding_label = jQuery('<label/>', {
                'text': Sao.i18n.gettext('Encoding:'),
                'class': 'control-label',
                'for': 'input-encoding'
            });

            this.el_csv_encoding = jQuery('<select/>', {
                'class': 'form-control',
                'id': 'input-encoding'
            });

            for (const encoding of ENCODINGS) {
                jQuery('<option/>', {
                    'val': encoding,
                }).append(encoding).appendTo(this.el_csv_encoding);
            }

            var enc = 'utf-8';
            if (navigator.platform &&
                    navigator.platform.slice(0, 3) == 'Win') {
                enc = 'cp1252';
            }
            this.el_csv_encoding.children('option[value="' + enc + '"]')
            .attr('selected', 'selected');

            jQuery('<div/>', {
                'class': 'form-group'
            }).append(encoding_label)
                .append(this.el_csv_encoding)
                .appendTo(this.expander_csv);
            this.expander_csv.append(' ');

            var skip_label = jQuery('<label/>', {
                'text': Sao.i18n.gettext('Lines to Skip:'),
                'class': 'control-label',
                'for': 'input-skip'
            });

            this.el_csv_skip = jQuery('<input/>', {
                'type': 'number',
                'class': 'form-control',
                'id': 'input-skip',
                'value': '0'
            });

            jQuery('<div/>', {
                'class': 'form-group'
            }).append(skip_label)
                .append(this.el_csv_skip)
                .appendTo(this.expander_csv);
            this.expander_csv.append(' ');
            Sortable.create(this.fields_selected.get(0), {
                handle: '.draggable-handle',
                ghostClass: 'dragged-row'
            });
        },
        sig_sel_add: function(el_field) {
            el_field = jQuery(el_field);
            this._add_node(el_field.attr('field'), el_field.attr('name'));
        },
        _add_node: function(field, name) {
            jQuery('<li/>', {
                'field': field,
                'class': 'draggable-handle',
            }).text(name).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-drag')
            ).click(function(evt) {
                const node = jQuery(evt.target);
                if (evt.ctrlKey || evt.metaKey) {
                    node.toggleClass('bg-primary');
                } else {
                    node.addClass('bg-primary');
                    node.siblings().removeClass('bg-primary');
                }
            }).appendTo(this.fields_selected);
        },
        view_populate: function (parent_node, parent_view) {
            var fields_order = Object.keys(parent_node).sort(function(a,b) {
                if (parent_node[b].string < parent_node[a].string) {
                    return -1;
                }
                else {
                    return 1;
                }
            }).reverse();

            fields_order.forEach(field => {
                var name = parent_node[field].string || field;
                var node = jQuery('<li/>', {
                    'field': parent_node[field].field,
                    'name': parent_node[field].name
                }).text(name).click(e => {
                    if (e.ctrlKey || e.metaKey) {
                        node.toggleClass('bg-primary');
                    } else {
                        this.fields_all.find('li').removeClass('bg-primary');
                        node.addClass('bg-primary');
                    }
                }).appendTo(parent_view);
                parent_node[field].view = node;
                var expander_icon = Sao.common.ICONFACTORY
                    .get_icon_img('tryton-arrow-right')
                    .data('expanded', false)
                    .click(e => {
                        e.stopPropagation();
                        var icon;
                        var expanded = expander_icon.data('expanded');
                        expander_icon.data('expanded', !expanded);
                        if (expanded) {
                            icon = 'tryton-arrow-right';
                            node.next('ul').remove();
                        } else {
                            icon = 'tryton-arrow-down';
                            this.on_row_expanded(parent_node[field]);
                        }
                        Sao.common.ICONFACTORY.get_icon_url(icon)
                            .then(function(url) {
                                expander_icon.attr('src', url);
                            });
                    }).prependTo(node);
                expander_icon.css(
                    'visibility',
                    parent_node[field].relation ? 'visible' : 'hidden');
            });
        },
        model_populate: function (fields, parent_node, prefix_field,
            prefix_name) {
            parent_node = parent_node || this.fields_model;
            prefix_field = prefix_field || '';
            prefix_name = prefix_name || '';

            for (const field of Object.keys(fields)) {
                if(!fields[field].readonly || field == 'id') {
                    var name = fields[field].string || field;
                    name = prefix_name + name;
                    // Only One2Many can be nested for import
                    var relation;
                    if (fields[field].type == 'one2many') {
                        relation = fields[field].relation;
                    } else {
                        relation = null;
                    }
                    var node = {
                        name: name,
                        field: prefix_field + field,
                        relation: relation,
                        string: fields[field].string
                    };
                    parent_node[field] = node;
                    this.fields[prefix_field + field] = node;
                    this.fields_invert[name] = prefix_field + field;
                    if (relation) {
                        node.children = {};
                    }
                }
            }
        },
        children_expand: function(node) {
            if (jQuery.isEmptyObject(node.children) && node.relation) {
                this.model_populate(
                    this._get_fields(node.relation), node.children,
                    node.field + '/', node.name + '/');
            }
        },
        autodetect: function() {
            var fname = this.file_input.val();
            if(!fname) {
                Sao.common.message.run(
                    Sao.i18n.gettext('You must select an import file first.'));
                return;
            }
            this.fields_selected.empty();
            this.el_csv_skip.val(1);
            Papa.parse(this.file_input[0].files[0], {
                delimiter: this.el_csv_delimiter.val(),
                quoteChar: this.el_csv_quotechar.val(),
                preview: 1,
                encoding: this.el_csv_encoding.val(),
                error: (err, file, inputElem, reason) => {
                    Sao.common.warning.run(
                        reason,
                        Sao.i18n.gettext("Detection failed"));
                },
                complete: results => {
                    results.data[0].every(word => {
                        if (!(word in this.fields_invert) && !(word in this.fields)) {
                            var fields = this.fields_model;
                            var prefix = '';
                            var parents = word.split('/').slice(0, -1);
                            this._traverse(fields, prefix, parents, 0);
                        }
                        return this._auto_select(word);
                    });
                }
            });
        },
        _auto_select: function(word) {
            var name,field;
            if(word in this.fields_invert) {
                name = word;
                field = this.fields_invert[word];
            }
            else if (word in this.fields) {
                name = this.fields[word].name;
                field = [word];
            }
            else {
                Sao.common.warning.run(
                    Sao.i18n.gettext('Unknown column header "%1"', word),
                    Sao.i18n.gettext('Error'));
                return false;
            }
            this._add_node(field, name);
            return true;
        },
        _traverse: function(fields, prefix, parents, i) {
            var field, item;
            var names = Object.keys(fields);
            for (item = 0; item<names.length; item++) {
                field = fields[names[item]];
                if (field.name == (prefix + parents[i]) ||
                    field.field == (prefix + parents[i])) {
                    this.children_expand(field);
                    prefix += parents[i] + '/';
                    if (field.children) {
                        this._traverse(field.children, prefix, parents, ++i);
                    }
                    break;
                }
            }
        },
        response: function(response_id) {
            if(response_id == 'RESPONSE_OK') {
                var fields = [];
                this.fields_selected.children('li').each((i, field_el) => {
                    fields.push(field_el.getAttribute('field'));
                });
                var fname = this.file_input.val();
                if(fname) {
                    this.import_csv(fname, fields).then(() => {
                        this.destroy();
                    });
                } else {
                    this.destroy();
                }
            }
            else {
                this.destroy();
            }
        },
        import_csv: function(fname, fields) {
            var skip = this.el_csv_skip.val();
            var encoding = this.el_csv_encoding.val();
            var prm = jQuery.Deferred();

            Papa.parse(this.file_input[0].files[0], {
                delimiter: this.el_csv_delimiter.val(),
                quoteChar: this.el_csv_quotechar.val(),
                encoding: encoding,
                error: (err, file, inputElem, reason) => {
                    Sao.common.warning.run(
                        reason,
                        Sao.i18n.gettext("Import failed"))
                        .always(prm.reject);
                },
                complete: results => {
                    var data = results.data.slice(skip, results.data.length - 1);
                    Sao.rpc({
                        'method': 'model.' + this.screen.model_name +
                        '.import_data',
                        'params': [fields, data, {}]
                    }, this.session).then(count => {
                        return Sao.common.message.run(
                            Sao.i18n.ngettext('%1 record imported',
                                '%1 records imported', count));
                    }).then(prm.resolve, prm.reject);
                }
            });
            return prm.promise();
        }
    });

    Sao.Window.Export = Sao.class_(Sao.Window.CSV, {
        init: function(name, screen, names) {
            this.name = name;
            this.screen = screen;
            this.session = Sao.Session.current_session;
            Sao.Window.Export._super.init.call(this,
                Sao.i18n.gettext('CSV Export: %1',name));
            var fields = this.screen.model.fields;
            for (const name of names) {
                var type = fields[name].description.type;
                if (type == 'selection') {
                    this.sel_field(name + '.translated');
                } else if (type == 'reference') {
                    this.sel_field(name + '.translated');
                    this.sel_field(name + '/rec_name');
                } else {
                    this.sel_field(name);
                }
            }

            this.predef_exports = {};
            this.fill_predefwin();

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Save Export"),
            }).text(' ' + Sao.i18n.gettext('Save Export')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-save')
            ).click(() => {
                this.addreplace_predef();
            })
            .appendTo(this.column_buttons);

            this.button_url = jQuery('<a/>', {
                'class': 'btn btn-default btn-block',
                'target': '_blank',
                'rel': 'noreferrer noopener',
                'title': Sao.i18n.gettext("URL Export"),
            }).text(' ' + Sao.i18n.gettext("URL Export")).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-public')
            )
            .appendTo(this.column_buttons);
            this.dialog.body.on('change click', this.set_url.bind(this));

            jQuery('<button/>', {
                'class': 'btn btn-default btn-block',
                'type': 'button',
                'title': Sao.i18n.gettext("Delete Export"),
            }).text(' ' + Sao.i18n.gettext('Delete Export')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-delete')
            ).click(() => {
                this.remove_predef();
            })
            .appendTo(this.column_buttons);

            var predefined_exports_column = jQuery('<div/>', {
                'class': 'panel panel-default',
            }).append(jQuery('<div/>', {
                'class': 'panel-heading',
            }).append(jQuery('<h3/>', {
                'class': 'panel-title',
                'text': Sao.i18n.gettext('Predefined Exports')
            }))).appendTo(this.column_buttons);

            this.predef_exports_list = jQuery('<ul/>', {
                'class': 'list-unstyled predef-exports panel-body'
            }).css('cursor', 'pointer')
            .appendTo(predefined_exports_column);

            this.selected_records = jQuery('<select/>', {
                'class': 'form-control',
                'id': 'input-records',
            }).append(jQuery('<option/>', {
                'val': true,
                'selected': this.screen_is_tree ? false : true,
            }).text(Sao.i18n.gettext("Selected Records")))
                .append(jQuery('<option/>', {
                    'val': false,
                    'selected': this.screen_is_tree ? true : false,
                }).text(Sao.i18n.gettext("Listed Records")));

            this.ignore_search_limit = jQuery('<input/>', {
                'type': 'checkbox',
            });

            this.selected_records.change(() => {
                this.ignore_search_limit.parents('.form-group').first().toggle(
                    !JSON.parse(this.selected_records.val()) &&
                    !this.screen_is_tree);
            });

            jQuery('<div/>', {
                'class': 'form-group',
            }).appendTo(this.chooser_form)
            .append(jQuery('<label/>', {
                'text': Sao.i18n.gettext("Export:"),
                'class': 'control-label',
                'for': 'input-records',
            })).append(this.selected_records);

            jQuery('<div/>', {
                'class': 'form-group',
            }).appendTo(this.chooser_form)
            .append(jQuery('<div/>', {
                'class': 'checkbox',
            }).append(jQuery('<label/>', {
                'text': ' ' + Sao.i18n.gettext("Ignore search limit")
            }).prepend(this.ignore_search_limit)))
            .hide();

            this.el_csv_locale = jQuery('<input/>', {
                'type': 'checkbox',
                'checked': 'checked',
            });

            jQuery('<div/>', {
                'class': 'checkbox',
            }).append(jQuery('<label/>', {
                'text': ' ' + Sao.i18n.gettext("Use locale format"),
            }).prepend(this.el_csv_locale)).appendTo(this.expander_csv);
            this.expander_csv.append(' ');

            this.el_add_field_names = jQuery('<input/>', {
                'type': 'checkbox',
                'checked': 'checked'
            });

            jQuery('<div/>', {
                'class': 'checkbox',
            }).append(jQuery('<label/>', {
                'text': ' '+Sao.i18n.gettext('Add Field Names')
            }).prepend(this.el_add_field_names)).appendTo(this.expander_csv);
            this.expander_csv.append(' ');

            this.set_url();
            Sortable.create(this.fields_selected.get(0), {
                handle: '.draggable-handle',
                ghostClass: 'dragged-row'
            });
        },
        get context() {
            return this.screen.context;
        },
        get screen_is_tree() {
            return Boolean(
                this.screen.current_view &&
                (this.screen.current_view.view_type == 'tree') &&
                this.screen.current_view.children_field);
        },
        view_populate: function(parent_node, parent_view) {
            var names = Object.keys(parent_node).sort(function(a, b) {
                if (parent_node[b].string < parent_node[a].string) {
                    return -1;
                }
                else {
                    return 1;
                }
            }).reverse();

            names.forEach(name => {
                var path = parent_node[name].path;
                var node = jQuery('<li/>', {
                    'path': path
                }).text(parent_node[name].string).click(e => {
                    if (e.ctrlKey || e.metaKey) {
                        node.toggleClass('bg-primary');
                    } else {
                        this.fields_all.find('li')
                            .removeClass('bg-primary');
                        node.addClass('bg-primary');
                    }
                }).appendTo(parent_view);
                parent_node[name].view = node;

                var expander_icon = Sao.common.ICONFACTORY
                    .get_icon_img('tryton-arrow-right')
                    .data('expanded', false)
                    .click(e => {
                        e.stopPropagation();
                        var icon;
                        var expanded = expander_icon.data('expanded');
                        expander_icon.data('expanded', !expanded);
                        if (expanded) {
                            icon = 'tryton-arrow-right';
                            node.next('ul').remove();
                        } else {
                            icon = 'tryton-arrow-down';
                            this.on_row_expanded(parent_node[name]);
                        }
                        Sao.common.ICONFACTORY.get_icon_url(icon)
                            .then(function(url) {
                                expander_icon.attr('src', url);
                            });
                    }).prependTo(node);
                expander_icon.css(
                    'visibility',
                    parent_node[name].children ? 'visible' : 'hidden');
            });
        },
        model_populate: function(fields, parent_node, prefix_field,
            prefix_name) {
            parent_node = parent_node || this.fields_model;
            prefix_field = prefix_field || '';
            prefix_name = prefix_name || '';

            for (const name of Object.keys(fields)) {
                var field = fields[name];
                var string = field.string || name;
                var items = [{ name: name, field: field, string: string }];

                if (field.type == 'selection') {
                    items.push({
                        name: name+'.translated',
                        field: field,
                        string: Sao.i18n.gettext('%1 (string)', string)
                    });
                } else if (field.type == 'reference') {
                    items.push({
                        name: name + '.translated',
                        field: field,
                        string: Sao.i18n.gettext("%1 (model name)", string),
                    });
                    items.push({
                        name: name + '/rec_name',
                        field: field,
                        string: Sao.i18n.gettext("%1/Record Name", string),
                    });
                }

                for (const item of items) {
                    var path = prefix_field + item.name;
                    var long_string = prefix_name + item.string;

                    var node = {
                        path: path,
                        string: item.string,
                        long_string: long_string,
                        relation: item.field.relation
                    };
                    parent_node[item.name] = node;
                    this.fields[path] = node;

                    // Insert relation only to real field
                    if (item.name.indexOf('.') == -1 && item.field.relation) {
                        node.children = {};
                    }
                }
            }
        },
        children_expand: function(node) {
            if (jQuery.isEmptyObject(node.children) && node.relation) {
                this.model_populate(
                    this._get_fields(node.relation), node.children,
                    node.path + '/', node.long_string + '/');
            }
        },
        sig_sel_add: function(el_field) {
            el_field = jQuery(el_field);
            var name = el_field.attr('path');
            this.sel_field(name);
        },
        fill_predefwin: function() {
            Sao.rpc({
                'method': 'model.ir.export.get',
                'params': [
                    this.screen.model_name,
                    ['name', 'header', 'records', 'export_fields.name'],
                    this.context,
                ],
            }, this.session).done(exports => {
                for (const export_ of exports) {
                    this.predef_exports[export_.id] = {
                        'fields': export_['export_fields.'].map(
                            field => field.name),
                        'values': export_,
                    };
                    this.add_to_predef(export_.id, export_.name);
                    this.predef_exports_list.children('li').first().focus();
                }
            });
        },
        add_to_predef: function(id, name) {
            var node = jQuery('<li/>', {
                'text': name,
                'export_id': id,
                'tabindex': 0
            }).on('keypress', function(e) {
                var keyCode = (e.keyCode ? e.keyCode : e.which);
                if(keyCode == 13 || keyCode == 32) {
                    node.click();
                }
            }).click(event => {
                node.toggleClass('bg-primary')
                    .siblings().removeClass('bg-primary');
                if (node.hasClass('bg-primary')) {
                    this.sel_predef(node.attr('export_id'));
                }
            });
            this.predef_exports_list.append(node);
        },
        addreplace_predef: function() {
            var fields = [];
            var selected_fields = this.fields_selected.children('li');
            for (const field of selected_fields) {
                fields.push(field.getAttribute('path'));
            }
            if(fields.length === 0) {
                return;
            }
            var pref_id;

            const save = name => {
                var prm;
                var values = {
                    'header': this.el_add_field_names.is(':checked'),
                    'records': (
                        JSON.parse(this.selected_records.val()) ?
                        'selected' : 'listed'),
                };
                if (!pref_id) {
                    values.name = name;
                    values.resource = this.screen.model_name;
                    values.export_fields = fields.map(f => ({'name': f}));
                    prm = Sao.rpc({
                        method: 'model.ir.export.set',
                        params: [values, this.context],
                    }, this.session);
                } else {
                    prm = Sao.rpc({
                        method: 'model.ir.export.update',
                        params: [pref_id, values, fields, this.context],
                    }, this.session).then(() =>  pref_id);
                }
                return prm.then(pref_id => {
                    this.session.cache.clear(
                        'model.' + this.screen.model_name + '.view_toolbar_get');
                    this.predef_exports[pref_id] = {
                        'fields': fields,
                        'values': values,
                    };
                    if (selection.length === 0) {
                        this.add_to_predef(pref_id, name);
                    }
                });
            };

            var selection = this.predef_exports_list.children('li.bg-primary');
            if (selection.length === 0) {
                pref_id = null;
                Sao.common.ask.run(
                    Sao.i18n.gettext('What is the name of this export?'),
                    'export')
                .then(save);
            }
            else {
                pref_id = selection.attr('export_id');
                Sao.common.sur.run(
                    Sao.i18n.gettext(
                        'Override %1 definition?', selection.text()))
                .then(save);
            }
        },
        remove_predef: function() {
            var selection = this.predef_exports_list.children('li.bg-primary');
            if (selection.length === 0) {
                return;
            }
            var export_id = jQuery(selection).attr('export_id');
            Sao.rpc({
                'method': 'model.ir.export.unset',
                'params': [export_id, this.context]
            }, this.session).then(() => {
                this.session.cache.clear(
                    'model.' + this.screen.model_name + '.view_toolbar_get');
                delete this.predef_exports[export_id];
                selection.remove();
            });
        },
        sel_predef: function(export_id) {
            this.fields_selected.empty();
            const export_ = this.predef_exports[export_id];
            for (const name of export_.fields) {
                if (!(name in this.fields)) {
                    var fields = this.fields_model;
                    var prefix = '';
                    var parents = name.split('/').slice(0, -1);
                    this._traverse(fields, prefix, parents, 0);
                }
                if(!(name in this.fields)) {
                    return;
                }
                this.sel_field(name);
            }
            this.el_add_field_names.prop('checked', export_.values.header);
            this.selected_records.val(
                JSON.stringify(export_.values.records == 'selected'));
        },
        _traverse: function(fields, prefix, parents, i) {
            var field, item;
            var names = Object.keys(fields);
            for (item = 0; item < names.length; item++) {
                field = fields[names[item]];
                if (field.path == (prefix + parents[i])) {
                    this.children_expand(field);
                    prefix += parents[i] + '/';
                    if (field.children) {
                        this._traverse(field.children, prefix, parents, ++i);
                    }
                    break;
                }
            }
        },
        sel_field: function(name) {
            var long_string = this.fields[name].long_string;
            var relation = this.fields[name].relation;
            if (relation) {
                name += '/rec_name';
            }
            var node = jQuery('<li/>', {
                'path': name,
                'class': 'draggable-handle',
            }).text(long_string).click(function(e) {
                if (e.ctrlKey || e.metaKey) {
                    node.toggleClass('bg-primary');
                } else {
                    jQuery(e.target).addClass('bg-primary')
                        .siblings().removeClass('bg-primary');
                }
            }).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-drag')
            ).appendTo(this.fields_selected);
        },
        response: function(response_id) {
            if(response_id == 'RESPONSE_OK') {
                var fields = [];
                this.fields_selected.children('li').each(function(i, field) {
                    fields.push(field.getAttribute('path'));
                });
                var header = this.el_add_field_names.is(':checked');
                var prm, ids, paths;
                if (JSON.parse(this.selected_records.val())) {
                    ids = this.screen.selected_records.map(function(r) {
                        return r.id;
                    });
                    paths = this.screen.selected_paths;
                    prm = Sao.rpc({
                        'method': (
                            'model.' + this.screen.model_name +
                            '.export_data'),
                        'params': [ids, fields, header, this.context]
                    }, this.session);
                } else if (this.screen_is_tree) {
                    ids = this.screen.listed_records.map(function(r) {
                        return r.id;
                    });
                    paths = this.screen.listed_paths;
                    prm = Sao.rpc({
                        'method': (
                            'model.' + this.screen.model_name +
                            '.export_data'),
                        'params': [ids, fields, header, this.context]
                    }, this.session);
                } else {
                    var domain = this.screen.search_domain(
                        this.screen.screen_container.get_text());
                    var offset, limit;
                    if (this.ignore_search_limit.prop('checked')) {
                        offset = 0;
                        limit = null;
                    } else {
                        offset = this.screen.offset;
                        limit = this.screen.limit;
                    }
                    prm = Sao.rpc({
                        'method': (
                            'model.' + this.screen.model_name +
                            '.export_data_domain'),
                        'params': [
                            domain, fields, offset, limit, this.screen.order,
                            header, this.context],
                    }, this.session);
                }
                prm.then(data => {
                    this.export_csv(data, paths, header).then(() => {
                        this.destroy();
                    });
                });
            } else {
                this.destroy();
            }
        },
        export_csv: function(data, paths, header=false) {
            var locale_format = this.el_csv_locale.prop('checked');
            var unparse_obj = {};
            unparse_obj.data = data.map(function(row, i) {
                var indent = paths && paths[i] ? paths[i].length -1 : 0;
                return Sao.Window.Export.format_row(
                    row, indent, locale_format);
            });
            var csv = Papa.unparse(unparse_obj, {
                quoteChar: this.el_csv_quotechar.val(),
                delimiter: this.el_csv_delimiter.val()
            });
            if (navigator.platform &&
                navigator.platform.slice(0, 3) == 'Win') {
                csv = Sao.BOM_UTF8 + csv;
            }
            Sao.common.download_file(
                csv, this.name + '.csv', {type: 'text/csv;charset=utf-8'});
            var size = data.length;
            if (header) {
                size -= 1;
            }
            return Sao.common.message.run(
                Sao.i18n.ngettext(
                    "%1 record saved", "%1 records saved", size));
        },
        set_url: function() {
            var path = [this.session.database, 'data', this.screen.model_name];
            var query_string = [];
            var domain;
            if (JSON.parse(this.selected_records.val())) {
                domain = this.screen.current_view.selected_records.map(function(r) {
                    return r.id;
                });
            } else {
                domain = this.screen.search_domain(
                    this.screen.screen_container.get_text());
                if (!this.ignore_search_limit.prop('checked') &&
                    this.screen.limit !== null) {
                    query_string.push(['s', this.screen.limit.toString()]);
                    query_string.push(
                        ['p', Math.floor(
                            this.screen.offset / this.screen.limit).toString()]);
                }
                if (this.screen.order) {
                    for (const expr of this.screen.order) {
                        query_string.push(['o', expr.map(function(e) {
                            return e;
                        }).join(',')]);
                    }
                }
            }
            query_string.splice(
                0, 0, ['d', JSON.stringify(Sao.rpc.prepareObject(domain))]);
            if (!jQuery.isEmptyObject(this.screen.local_context)) {
                query_string.push(
                    ['c', JSON.stringify(Sao.rpc.prepareObject(
                        this.screen.local_context))]);
            }

            this.fields_selected.children('li').each(function(i, field) {
                query_string.push(['f', field.getAttribute('path')]);
            });

            query_string.push(['dl', this.el_csv_delimiter.val()]);
            query_string.push(['qc', this.el_csv_quotechar.val()]);

            if (!this.el_add_field_names.is(':checked')) {
                query_string.push(['h', '0']);
            }
            if (this.el_csv_locale.prop('checked')) {
                query_string.push(['loc', '1']);
            }

            query_string = query_string.map(function(param) {
                return param.map(encodeURIComponent).join('=');
            }).join('&');
            this.button_url.attr('href', '/' + path.join('/') + '?' + query_string);
        },
    });

    Sao.Window.Export.format_row = function(
        line, indent=0, locale_format=true) {
        var row = [];
        line.forEach(function(val, i) {
            if (locale_format) {
                if (val.isDateTime) {
                    val = val.local().format(
                        Sao.common.date_format() + ' ' +
                        Sao.common.moment_format('%X'));
                } else if (val.isDate) {
                    val = val.format(Sao.common.date_format());
                } else if (val.isTimeDelta) {
                    val = Sao.common.timedelta.format(
                        val, {'s': 1, 'm': 60, 'h': 60 * 60});
                } else if (!isNaN(Number(val))) {
                    val = val.toLocaleString(
                        Sao.i18n.BC47(Sao.i18n.getlang()), {
                            'minimumFractionDigits': 0,
                            'maximumFractionDigits': 20,
                        });
                }
            } else if (val.isDateTime) {
                val = val.utc();
            } else if (val.isTimeDelta) {
                val = val.asSeconds();
            } else if (typeof(val) == 'boolean') {
                val += 0;
            }
            if ((i === 0) && indent && (typeof(val) == 'string')) {
                val = '  '.repeat(indent) + val;
            }
            if (val instanceof Uint8Array) {
                val = Sao.common.btoa(val);
            }
            row.push(val);
        });
        return row;
    };

    Sao.Window.EmailEntry = Sao.class_(Sao.common.InputCompletion, {
        init: function(el, session) {
            this.session = session;
            Sao.Window.EmailEntry._super.init.call(
                this, el,
                this._email_source,
                this._email_match_selected,
                this._email_format);
        },
        _email_match_selected: function(value) {
            this.input.val(value[2]);
        },
        _email_source: function(text) {
            if (this.input[0].selectionStart < this.input.val().length) {
                return jQuery.when([]);
            }
            return Sao.rpc({
                'method': 'model.ir.email.complete',
                'params': [text, Sao.config.limit, {}],
            }, this.session)
            .fail(function() {
                Sao.Logger.warn("Unable to complete email entry");
            });
        },
        _email_format: function(value) {
            return value[1];
        },
    });

    Sao.Window.Email = Sao.class_(Object, {
        init: function(name, record, prints, template) {
            this.record = record;
            this.dialog = new Sao.Dialog(
                Sao.i18n.gettext('E-mail %1', name), 'email', 'lg');
            this.el = this.dialog.modal;
            this.dialog.content.addClass('form-horizontal');

            var body = this.dialog.body;
            function add_group(name, label, required) {
                var group = jQuery('<div/>', {
                    'class': 'form-group',
                }).appendTo(body);
                jQuery('<label/>', {
                    'class': 'control-label col-sm-1',
                    'text': label,
                    'for': 'email-' + name,
                }).appendTo(group);
                var input = jQuery('<input/>', {
                    'type': 'text',
                    'class':'form-control input-sm',
                    'id': 'email-' + name,
                }).appendTo(jQuery('<div/>', {
                    'class': 'col-sm-11',
                }).appendTo(group));
                if (required) {
                    input.attr('required', true);
                }
                return input;
            }

            this.to = add_group('to', Sao.i18n.gettext('To:'), true);
            this.cc = add_group('cc', Sao.i18n.gettext('Cc:'));
            this.bcc = add_group('bcc', Sao.i18n.gettext('Bcc:'));
            for (const input of [this.to, this.cc, this.bcc]) {
                new Sao.Window.EmailEntry(input, this.record.model.session);
            }
            this.subject = add_group(
                'subject', Sao.i18n.gettext('Subject:'), true);

            var panel = jQuery('<div/>', {
                'class': 'panel panel-default',
            }).appendTo(body
            ).append(jQuery('<div/>', {
                'class': 'panel-heading',
            }).append(Sao.common.richtext_toolbar()));
            this.body = jQuery('<div>', {
                'class': 'email-richtext form-control input-sm mousetrap',
                'contenteditable': true,
                'spellcheck': true,
                'id': 'email-body',
            }).appendTo(jQuery('<div/>', {
                'class': 'panel-body',
            }).appendTo(panel));

            var print_frame = jQuery('<div/>', {
                'class': 'col-md-4',
            }).appendTo(body);
            jQuery('<label/>', {
                'text': Sao.i18n.gettext("Reports"),
            }).appendTo(print_frame);
            this.print_actions = {};
            for (const print of prints) {
                var print_check = jQuery('<input/>', {
                    'type': 'checkbox',
                });
                jQuery('<div/>', {
                    'class': 'checkbox',
                }).append(jQuery('<label/>'
                ).text(Sao.i18n.gettext(print.name)
                ).prepend(print_check)).appendTo(print_frame);
                this.print_actions[print.id] = print_check;
            }

            var attachment_frame = jQuery('<div/>', {
                'class': 'col-md-4',
            }).appendTo(body);
            jQuery('<label/>', {
                'text': Sao.i18n.gettext("Attachments"),
            }).appendTo(attachment_frame);
            this.attachments = jQuery('<select/>', {
                'class': 'form-control input-sm',
                'name': 'attachments',
                'multiple': true,
            }).appendTo(attachment_frame);
            Sao.rpc({
                'method': 'model.ir.attachment.search_read',
                'params': [
                    [
                        ['resource', '=', record.model.name + ',' + record.id],
                        ['OR',
                            ['data', '!=', null],
                            ['file_id', '!=', null],
                        ],
                    ],
                    0, null, null, ['rec_name'], record.get_context()],
            }, record.model.session).then(attachments => {
                for (const attachment of attachments) {
                    this.attachments.append(jQuery('<option/>', {
                        'value': JSON.stringify(attachment.id),
                        'text': attachment.rec_name,
                    }));
                }
            })
            .fail(function() {
                Sao.Logger.error(
                    "Could not fetch attachment for", record);
            });

            this.files = jQuery('<div/>', {
                'class': 'col-md-4',
            }).appendTo(body);
            jQuery('<label/>', {
                'text': Sao.i18n.gettext("Files"),
            }).appendTo(this.files);
            this._add_file_button();

            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Cancel"),
            }).text(' ' + Sao.i18n.gettext('Cancel')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-cancel')
            ).click(() => {
                this.response('RESPONSE_CANCEL');
            }).appendTo(this.dialog.footer);

            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("Send"),
            }).text(' ' + Sao.i18n.gettext('Send')).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-send')
            ).appendTo(this.dialog.footer);
            this.dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            this._fill_with(template);

            this.el.modal('show');
            this.el.on('hidden.bs.modal', function() {
                jQuery(this).remove();
            });
        },
        _add_file_button: function() {
            var row = jQuery('<div/>').appendTo(this.files);
            var file = jQuery('<input/>', {
                'type': 'file',
            }).appendTo(row);
            var button = jQuery('<a/>', {
                'class': 'close',
                'title': Sao.i18n.gettext("Remove File"),
            }).append(jQuery('<span/>', {
                'aria-hidden': true,
                'text': 'x',
            })).append(jQuery('<span/>', {
                'class': 'sr-only',
            }).text(Sao.i18n.gettext("Remove")));
            button.hide();
            button.appendTo(row);

            file.on('change', () => {
                button.show();
                this._add_file_button();
            });
            button.click(function() {
                row.remove();
            });
        },
        get_files: function() {
            var prms = [];
            var files = [];
            this.files.find('input[type=file]').each(function(i, input) {
                if (input.files.length) {
                    var dfd = jQuery.Deferred();
                    prms.push(dfd);
                    Sao.common.get_file_data(
                        input.files[0], function(data, filename) {
                            files.push([filename, data]);
                            dfd.resolve();
                        });
                }
            });
            return jQuery.when.apply(jQuery, prms).then(function() {
                return files;
            });
        },
        get_attachments: function() {
            var attachments = this.attachments.val();
            if (attachments) {
                return attachments.map(function(e) { return JSON.parse(e); });
            }
            return [];
        },
        _fill_with: function(template) {
            var prm;
            if (template) {
                prm = Sao.rpc({
                    'method': 'model.ir.email.template.get',
                    'params': [template, this.record.id, {}],
                }, this.record.model.session);
            } else {
                prm = Sao.rpc({
                    'method': 'model.ir.email.template.get_default',
                    'params': [this.record.model.name, this.record.id, {}],
                }, this.record.model.session);
            }
            prm.then(values => {
                this.to.val((values.to || []).join(', '));
                this.cc.val((values.cc || []).join(', '));
                this.bcc.val((values.bcc || []).join(', '));
                this.subject.val(values.subject || '');
                this.body.html(Sao.HtmlSanitizer.sanitize(values.body || ''));
                var print_ids = (values.reports || []);
                for (var print_id in this.print_actions) {
                    var check = this.print_actions[print_id];
                    check.prop(
                        'checked', ~print_ids.indexOf(parseInt(print_id, 10)));
                }
            });
        },
        response: function(response_id) {
            if (response_id == 'RESPONSE_OK') {
                var to = this.to.val();
                var cc = this.cc.val();
                var bcc = this.bcc.val();
                var subject = this.subject.val();
                var body = Sao.common.richtext_normalize(this.body.html());
                var reports = [];
                for (var id in this.print_actions) {
                    var check = this.print_actions[id];
                    if (check.prop('checked')) {
                        reports.push(id);
                    }
                }
                var attachments = this.get_attachments();
                var record = this.record;
                this.get_files().then(function(files) {
                    return Sao.rpc({
                        'method': 'model.ir.email.send',
                        'params': [
                            to, cc, bcc, subject, body,
                            files,
                            [record.model.name, record.id],
                            reports,
                            attachments,
                            {}],
                    }, record.model.session);
                }).then(() => {
                    this.destroy();
                });
            } else {
                this.destroy();
            }
        },
        destroy: function() {
            this.el.modal('hide');
        },
    });

    Sao.Window.CodeScanner = Sao.class_(Object, {
        init: function(callback, loop=false) {
            this.callback = callback;
            this.loop = loop;
            this.submitting = false;
            this.dialog = new Sao.Dialog(
                Sao.i18n.gettext("Code Scanner"), 'code-scanner', 'md');
            this.el = this.dialog.modal;
            this.input = jQuery('<input/>', {
                'type': 'text',
                'class': 'form-control input-sm mousetrap',
                'aria-label': Sao.i18n.gettext("Code"),
                'placeholder': Sao.i18n.gettext("Code"),
            }).appendTo(jQuery('<div/>', {
                'class': 'col-sm-12',
            }).appendTo(this.dialog.body));

            var sound_btn = jQuery('<button/>', {
                'class': 'btn btn-default pull-right',
                'type': 'button',
                'title': Sao.i18n.gettext("Toggle Sound"),
            }).prependTo(this.dialog.header);
            var sound_icon = jQuery('<img/>', {
                'class': 'icon',
            }).appendTo(sound_btn);

            var sound_set_state = function(state) {
                var prm;
                if (state) {
                    sound_btn.addClass('active');
                    sound_btn.attr('aria-pressed', state);
                    prm = Sao.common.ICONFACTORY.get_icon_url('tryton-sound-on')
                        .done(url => {
                            sound_icon.attr('src', url);
                        });
                } else {
                    sound_btn.removeClass('active');
                    sound_btn.attr('aria-pressed', state);
                    prm = Sao.common.ICONFACTORY.get_icon_url('tryton-sound-off')
                        .done(url => {
                            sound_icon.attr('src', url);
                        });
                }
                localStorage.setItem('sao_code_scanner_sound', state);
                return prm;
            };
            var sound_state = JSON.parse(
                localStorage.getItem('sao_code_scanner_sound'));
            if (sound_state === null) {
                sound_state = true;
            }
            var sound_prm = sound_set_state(sound_state);
            sound_btn.click(
                () => sound_set_state(!sound_btn.hasClass('active')));

            jQuery('<button/>', {
                'class': 'btn btn-link',
                'type': 'button',
                'title': Sao.i18n.gettext("Close"),
            }).text(' ' + Sao.i18n.gettext("Close")).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-close')
            ).click(() => {
                this.response('RESPONSE_CLOSE');
            }).appendTo(this.dialog.footer);

            jQuery('<button/>', {
                'class': 'btn btn-primary',
                'type': 'submit',
                'title': Sao.i18n.gettext("OK"),
            }).text(' ' + Sao.i18n.gettext("OK")).prepend(
                Sao.common.ICONFACTORY.get_icon_img('tryton-ok')
            ).appendTo(this.dialog.footer);
            this.dialog.content.submit(e => {
                e.preventDefault();
                this.response('RESPONSE_OK');
            });

            this.el.on('shown.bs.modal', () => {
                this.input.on('blur', this._keep_focus);
                this.input.focus();
            });
            this.el.on('hidden.bs.modal', function() {
                jQuery(this).remove();
            });
            // show modal after sound icons have been set
            // because they can trigger a renew session modal
            sound_prm.then(() => this.el.modal('show'));
        },
        _play_sound: function(sound) {
            if (JSON.parse(localStorage.getItem('sao_code_scanner_sound'))) {
                Sao.common.play_sound(sound);
            }
        },
        _keep_focus: function() {
            setTimeout(() => this.focus(), 1);
        },
        response: function(response_id) {
            if (this.submitting) return;
            if (response_id == 'RESPONSE_OK') {
                var code = this.input.val();
                this.input.val('');  // clear input to prevent multiple calls
                if (code) {
                    this.submitting = true;
                    this.input.off('blur');
                    return this.callback(code)
                        .always(() => this.submitting = false)
                        .then((modified) => {
                            this._play_sound('success');
                            if (!this.loop || !modified) {
                                this.destroy();
                            }
                            this.input.on('blur', this._keep_focus);
                            this.input.focus();
                        }, (error) => {
                            this._play_sound('danger');
                            if (error[0] == 'UserError') {
                                Sao.common.warning.run(error[1][1], error[1][0]);
                            } else {
                                Sao.common.error.run(error[0], error[1]);
                            }
                            this.destroy();
                        });
                }
            }
            if (!this.loop || response_id != 'RESPONSE_OK') {
                this.destroy();
            }
        },
        destroy: function() {
            this.el.modal('hide');
        },
    });

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.Wizard = Sao.class_(Object, {
        init: function(name) {
            this.widget = jQuery('<div/>', {
                'class': 'wizard'
            });
            this.name = name || Sao.i18n.gettext('Wizard');
            this.action_id = null;
            this.id = null;
            this.ids = null;
            this.action = null;
            this.context = null;
            this.states = {};
            this.session_id = null;
            this.start_state = null;
            this.end_state = null;
            this.screen = null;
            this.screen_state = null;
            this.state = null;
            this.session = Sao.Session.current_session;
            this.__processing = false;
            this.__waiting_response = false;
            this.info_bar = new Sao.Window.InfoBar();
        },
        run: function(attributes) {
            this.action = attributes.action;
            this.action_id = attributes.data.action_id;
            this.id = attributes.data.id;
            this.ids = attributes.data.ids;
            this.model = attributes.data.model;
            this.context = jQuery.extend({}, attributes.context);
            this.context.active_id = this.id;
            this.context.active_ids = this.ids;
            this.context.active_model = this.model;
            this.context.action_id = this.action_id;
            return Sao.rpc({
                'method': 'wizard.' + this.action + '.create',
                'params': [this.session.context]
            }, this.session).then(result => {
                this.session_id = result[0];
                this.start_state = this.state = result[1];
                this.end_state = result[2];
                return this.process();
            }, () => {
                this.destroy();
            });
        },
        process: function() {
            if (this.__processing || this.__waiting_response) {
                return jQuery.when();
            }
            var process = function() {
                if (this.state == this.end_state) {
                    return this.end();
                }
                var ctx = jQuery.extend({}, this.context);
                var data = {};
                if (this.screen) {
                    data[this.screen_state] = this.screen.get_on_change_value();
                }
                return Sao.rpc({
                    'method': 'wizard.' + this.action + '.execute',
                    'params': [this.session_id, data, this.state, ctx]
                }, this.session).then(result => {
                    var prms = [];
                    if (result.view) {
                        this.clean();
                        var view = result.view;
                        this.update(view.fields_view, view.buttons);

                        prms.push(this.screen.new_(false).then(() => {
                            this.screen.current_record.set_default(
                                view.defaults || {})
                                .then(() => {
                                    this.screen.current_record.set(
                                        view.values || {});
                                    this.update_buttons();
                                    this.screen.set_cursor();
                                });
                        }));

                        this.screen_state = view.state;
                        this.__waiting_response = true;
                    } else {
                        this.state = this.end_state;
                    }

                    const execute_actions = () => {
                        var prms = [];
                        if (result.actions) {
                            for (const action of result.actions) {
                                var context = jQuery.extend({}, this.context);
                                // Remove wizard keys added by run
                                delete context.active_id;
                                delete context.active_ids;
                                delete context.active_model;
                                delete context.action_id;
                                prms.push(Sao.Action.execute(
                                    action[0], action[1], context));
                            }
                        }
                        return jQuery.when.apply(jQuery, prms);
                    };

                    if (this.state == this.end_state) {
                        prms.push(this.end().then(execute_actions));
                    } else {
                        prms.push(execute_actions());
                    }
                    this.__processing = false;
                    return jQuery.when.apply(jQuery, prms);
                }, result => {
                    // TODO end for server error.
                    this.__processing = false;
                });
            };
            return process.call(this);
        },
        destroy: function(action) {
            // TODO
        },
        end: function() {
            return Sao.rpc({
                'method': 'wizard.' + this.action + '.delete',
                'params': [this.session_id, this.session.context]
            }, this.session).then(action => {
                this.destroy(action);
            })
            .fail(() => {
                Sao.Logger.warn(
                    "Unable to delete session %s of wizard %s",
                    this.session_id, this.action);
            });
        },
        clean: function() {
            this.widget.empty();
            this.states = {};
        },
        response: function(definition) {
            this.__waiting_response = false;
            this.screen.current_view.set_value();
            if (definition.validate && !this.screen.current_record.validate(
                    null, null, null, true)) {
                this.screen.display(true);
                this.info_bar.add(this.screen.invalid_message(), 'danger');
                return;
            }
            this.info_bar.clear();
            this.state = definition.state;
            this.process();
        },
        _get_button: function(definition) {
            var style = 'btn-default';
            if (definition.default) {
                style = 'btn-primary';
            } else if (definition.state == this.end_state) {
                style = 'btn-link';
            }
            var button = new Sao.common.Button(
                definition, undefined, undefined, style);
            this.states[definition.state] = button;
            return button;
        },
        record_modified: function() {
            this.update_buttons();
            this.info_bar.refresh();
        },
        update_buttons: function() {
            var record = this.screen.current_record;
            for (var state in this.states) {
                var button = this.states[state];
                button.set_state(record);
            }
        },
        update: function(view, buttons) {
            for (const button of buttons) {
                this._get_button(button);
            }
            if (this.screen) {
                this.screen.windows.splice(
                    this.screen.windows.indexOf(this), 1);
            }
            this.screen = new Sao.Screen(view.model,
                    {mode: [], context: this.context});
            this.screen.add_view(view);
            this.screen.switch_view();
            this.screen.windows.push(this);
            this.header.append(jQuery('<h4/>', {
                'class': 'model-title',
                'title': this.name,
            }).text(Sao.common.ellipsize(this.name, 80)));
            this.widget.append(this.screen.screen_container.el);
        }
    });

    Sao.Wizard.create = function(attributes) {
        var win;
        if (attributes.window) {
            win = new Sao.Wizard.Form(attributes.name);
            var tab = new Sao.Tab.Wizard(win);
            Sao.Tab.add(tab);
        } else {
            win = new Sao.Wizard.Dialog(attributes.name);
        }
        return win.run(attributes);
    };

    Sao.Wizard.Form = Sao.class_(Sao.Wizard, {
        init: function(name) {
            Sao.Wizard.Form._super.init.call(this, name);
            this.tab = null;  // Filled by Sao.Tab.Wizard

            this.header = jQuery('<div/>', {
                'class': 'modal-header',
            });
            this.form = jQuery('<div/>', {
                'class': 'wizard-form',
            }).append(this.header).append(this.widget);
            this.footer = jQuery('<div/>', {
                'class': 'modal-footer'
            }).appendTo(this.form);
        },
        clean: function() {
            Sao.Wizard.Form._super.clean.call(this);
            this.header.empty();
            this.footer.empty();
        },
        _get_button: function(definition) {
            var button = Sao.Wizard.Form._super._get_button.call(this,
                definition);
            this.footer.append(button.el);
            button.el.click(() => {
                this.response(definition);
            });
            return button;
        },
        destroy: function(action) {
            Sao.Wizard.Form._super.destroy.call(this, action);
            switch (action) {
                case 'reload menu':
                    Sao.Session.current_session.reload_context()
                        .then(function() {
                            Sao.menu();
                        });
                    break;
                case 'reload context':
                    Sao.Session.current_session.reload_context();
                    break;
            }
        },
        end: function() {
            return Sao.Wizard.Form._super.end.call(this).always(
                () => this.tab.close());
        }
    });

    Sao.Wizard.Dialog = Sao.class_(Sao.Wizard, { // TODO nomodal
        init: function(name) {
            Sao.Wizard.Dialog._super.init.call(this, name);
            var dialog = new Sao.Dialog(name, 'wizard-dialog', 'md', false);
            this.dialog = dialog.modal;
            this.header = dialog.header;
            this.content = dialog.content;
            this.footer = dialog.footer;
            this.dialog.on('keydown', e => {
                if (e.which == Sao.common.ESC_KEYCODE) {
                    e.preventDefault();
                    if (this.end_state in this.states) {
                        this.response(this.states[this.end_state].attributes);
                    }
                }
            });
            dialog.body.append(this.widget).append(this.info_bar.el);
        },
        clean: function() {
            Sao.Wizard.Dialog._super.clean.call(this);
            this.header.empty();
            this.footer.empty();
        },
        _get_button: function(definition) {
            var button = Sao.Wizard.Dialog._super._get_button.call(this,
                    definition);
            this.footer.append(button.el);
            if (definition['default']) {
                this.content.unbind('submit');
                this.content.submit(e => {
                    this.response(definition);
                    e.preventDefault();
                });
                button.el.attr('type', 'submit');
            } else {
                button.el.click(() => {
                    this.response(definition);
                });
            }
            return button;
        },
        update: function(view, buttons) {
            this.content.unbind('submit');
            Sao.Wizard.Dialog._super.update.call(this, view, buttons);
            this.show();
        },
        destroy: function(action) {
            Sao.Wizard.Dialog._super.destroy.call(this, action);
            const destroy = () => {
                this.dialog.remove();
                var dialog = jQuery('.wizard-dialog').filter(':visible')[0];
                var is_menu = false;
                var screen;
                if (!dialog) {
                    dialog = Sao.Tab.tabs.get_current();
                }
                if (!dialog ||
                    !this.model ||
                    (Sao.main_menu_screen &&
                    (Sao.main_menu_screen.model_name == this.model))) {
                    is_menu = true;
                    screen = Sao.main_menu_screen;
                } else {
                    screen = dialog.screen;
                }
                if (screen) {
                    var prm = jQuery.when();
                    if (screen.current_record && !is_menu) {
                        var ids;
                        if (screen.model_name == this.model) {
                            ids = this.ids;
                        } else {
                            // Wizard run form a children record so reload
                            // parent record
                            ids = [screen.current_record.id];
                        }
                        //TRADON MODIF
                        //prm = screen.reload(ids, true);
                    }
                    if (action) {
                        prm.then(function() {
                            screen.client_action(action);
                        });
                    }
                }
            };
            if ((this.dialog.data('bs.modal') || {}).isShown) {
                this.dialog.on('hidden.bs.modal', destroy);
                this.dialog.modal('hide');
            } else {
                destroy();
            }
        },
        show: function() {
            var view = this.screen.current_view;
            var expand;
            if (view.view_type == 'form') {
                expand = false;
                var fields = view.get_fields();
                for (const name of fields) {
                    var widgets = view.widgets[name];
                    for (const widget of widgets) {
                        if (widget.expand) {
                            expand = true;
                            break;
                        }
                    }
                    if (expand) {
                        break;
                    }
                }
            } else {
                expand = true;
            }
            if (expand) {
                this.dialog.find('.modal-dialog')
                    .removeClass('modal-md modal-sm')
                    .addClass('modal-lg');
            } else {
                this.dialog.find('.modal-dialog')
                    .removeClass('modal-lg modal-sm')
                    .addClass('modal-md');
            }
            this.dialog.modal('show');
        },
        hide: function() {
            this.dialog.modal('hide');
        },
        state_changed: function() {
            this.process();
        }
    });

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.View.BoardXMLViewParser = Sao.class_(Sao.View.FormXMLViewParser, {
        _parse_board: function(node, attributes) {
            var container = new Sao.View.Form.Container(
                Number(node.getAttribute('col') || 4),
                attributes.col_widths);
            this.view.el.append(container.el);
            this.parse_child(node, container);
            if (this._containers.length > 0) {
                throw 'AssertionError';
            }
        },
        _parse_action: function(node, attributes) {
            var action;
            if (attributes.yexpand === undefined) {
                attributes.yexpand = true;
            }
            if (attributes.yfill === undefined) {
                attributes.yfill = true;
            }
            action = new Sao.View.Board.Action(this.view, attributes);
            this.view.actions.push(action);
            this.container.add(action, attributes);
        },
    });

    Sao.View.Board = Sao.class_(Object, {
        xml_parser: Sao.View.BoardXMLViewParser,
        init: function(xml, context) {
            this.context = context;
            this.actions = [];
            this.containers = [];
            this.state_widgets = [];
            this.el = jQuery('<div/>', {
                'class': 'board'
            });
            new this.xml_parser(this, null, {}).parse(xml.children()[0]);

            var actions_prms = [];
            for (const action of this.actions) {
                actions_prms.push(action.action_prm);
            }
            this.actions_prms = jQuery.when.apply(jQuery, actions_prms);
        },
        reload: function() {
            var i;
            for (i = 0; i < this.actions.length; i++) {
                this.actions[i].display();
            }
            for (i = 0; i < this.state_widgets.length; i++) {
                this.state_widgets[i].set_state(null);
            }
        },
        active_changed: function(event_action) {
            for (const action of this.actions) {
                if (action !== event_action) {
                    action.update_domain(this.actions);
                }
            }
        },
    });

    Sao.View.Board.Action = Sao.class_(Object, {
        init: function(view, attributes) {
            var session = Sao.Session.current_session;
            this.name = attributes.name;
            this.view = view;

            this.el = jQuery('<div/>', {
                'class': 'board-action panel panel-default',
            });
            this.title = jQuery('<div/>', {
                'class': 'panel-heading',
            });
            this.el.append(this.title);
            this.body = jQuery('<div/>', {
                'class': 'panel-body',
            });
            this.el.append(this.body);

            var act_window = new Sao.Model('ir.action.act_window');
            this.action_prm = act_window.execute('get', [this.name], {});
            this.action_prm.done(action => {
                var params = {};
                this.action = action;
                params.view_ids = [];
                params.mode = null;
                if (!jQuery.isEmptyObject(action.views)) {
                    params.view_ids = [];
                    params.mode = [];
                    for (const view of action.views) {
                        params.view_ids.push(view[0]);
                        params.mode.push(view[1]);
                    }
                } else if (!jQuery.isEmptyObject(action.view_id)) {
                    params.view_ids = [action.view_id[0]];
                }

                if (!('pyson_domain' in this.action)) {
                    this.action.pyson_domain = '[]';
                }
                var ctx = {};
                ctx = jQuery.extend(ctx, session.context);
                ctx._user = session.user_id;
                var decoder = new Sao.PYSON.Decoder(ctx);
                params.context = jQuery.extend(
                    {}, this.view.context,
                    decoder.decode(action.pyson_context || '{}'));
                ctx = jQuery.extend(ctx, params.context);

                ctx.context = ctx;
                decoder = new Sao.PYSON.Decoder(ctx);
                params.order = decoder.decode(action.pyson_order);
                params.search_value = decoder.decode(
                    action.pyson_search_value || '[]');
                params.tab_domain = [];
                for (const element of action.domains) {
                    params.tab_domain.push(
                        [element[0], decoder.decode(element[1]), element[2]]);
                }
                params.context_model = action.context_model;
                params.context_domain = action.context_domain;
                if (action.limit !== null) {
                    params.limit = action.limit;
                } else {
                    params.limit = Sao.config.limit;
                }

                this.context = ctx;
                this.domain = [];
                this.update_domain([]);

                params.row_activate = this.row_activate.bind(this);

                this.screen = new Sao.Screen(this.action.res_model,
                        params);

                if (attributes.string) {
                    this.title.text(attributes.string);
                } else {
                    this.title.text(this.action.name);
                }
                this.screen.switch_view().done(() => {
                    this.body.append(this.screen.screen_container.el);
                    this.screen.search_filter();
                });
            });
        },
        row_activate: function() {
            if (!this.screen.current_record) {
                return;
            }

            if (this.screen.current_view.view_type == 'tree' &&
                    (this.screen.current_view.attributes.keyword_open == 1)) {
                const record_ids = this.screen.current_view.selected_records.map(
                        function(record) { return record.id; });
                Sao.Action.exec_keyword('tree_open', {
                    model: this.screen.model_name,
                    id: this.screen.current_record.id,
                    ids: record_ids
                }, jQuery.extend({}, this.screen.group._context), false);
            } else {
                new Sao.Window.Form(this.screen, result => {
                    if (result) {
                        this.screen.current_record.save();
                    } else {
                        this.screen.current_record.cancel();
                    }
                }, {
                'title': this.title.text(),
                });
            }
        },
        display: function() {
            this.screen.search_filter(this.screen.screen_container.get_text());
        },
        record_message: function() {
            this.view.active_changed(this);
        },
        get active() {
            if (this.screen && this.screen.current_record) {
                return {
                    'active_id': this.screen.current_record.id,
                    'active_ids': this.screen.selected_records.map(
                        function(r) {
                            return r.id;
                        }),
                };
            } else {
                return {
                    'active_id': null,
                    'active_ids': [],
                };
            }
        },
        update_domain: function(actions) {
            const domain_ctx = jQuery.extend({}, this.context);
            domain_ctx._actions = {};
            for (var i = 0, len = actions.length; i < len; i++) {
                domain_ctx._actions[actions[i].name] = actions[i].active;
            }
            const decoder = new Sao.PYSON.Decoder(domain_ctx);
            const new_domain = decoder.decode(this.action.pyson_domain);
            if (Sao.common.compare(this.domain, new_domain)) {
                return;
            }
            this.domain.splice(0, this.domain.length);
            jQuery.extend(this.domain, new_domain);
            if (this.screen) {
                this.display();
            }
        }
    });
}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */
(function() {
    'use strict';

    Sao.Bus = {};

    // Bus Identifier
    Sao.Bus.id = Sao.common.uuid4();
    Sao.Bus.channels = ['client:' + Sao.Bus.id];

    Sao.Bus.listen = function(last_message, wait) {
        wait = wait || 1;
        var session = Sao.Session.current_session;
        if (!session) {
            return;
        }

        var prm = jQuery.ajax({
            headers: {
                Authorization: 'Session ' + session.get_auth(),
            },
            contentType: 'application/json',
            data: JSON.stringify({
                last_message: last_message,
                channels: Sao.Bus.channels
            }),
            dataType: 'json',
            url: '/' + session.database + '/bus',
            type: 'POST',
            timeout: Sao.config.bus_timeout,
        });

        prm.done(function(response) {
            if (Sao.Session.current_session != session) {
                return;
            }
            if (response.message) {
                last_message = response.message.message_id;
                Sao.Logger.info(
                    "poll channels %s with last message",
                    Sao.Bus.channels, last_message);
                Sao.Bus.handle(response.message);
            }
            Sao.Bus.listen(last_message, 1);
        });

        prm.fail(function(response, status, error) {
            if (Sao.Session.current_session != session) {
                return;
            }
            if (error === "timeout") {
                Sao.Bus.listen(last_message, 1);
            } else if (response.status == 501) {
                Sao.Logger.info("Bus not supported");
                return;
            } else {
                Sao.Logger.error(
                    "An exception occured while connection to the bus. " +
                    "Sleeping for %s seconds",
                    wait, error);
                window.setTimeout(
                    Sao.Bus.listen,
                    Math.min(wait * 1000, Sao.config.bus_timeout),
                    last_message, wait * 2);
            }
        });
    };

    Sao.Bus.handle = function(message) {
        var notify = function(message) {
            try {
                if (Notification.permission != "granted") {
                    return;
                }
            } catch (e) {
                Sao.Logger.error(e.message, e.stack);
                return;
            }

            new Notification(message.title, {
                body: message.body || '',
            });
        };

        switch (message.type) {
            case 'notification':
                notify(message);
                break;
        }
    };

}());

/* This file is part of Tryton.  The COPYRIGHT file at the top level of
   this repository contains the full copyright notices and license terms. */

(function() {
    'use strict';

    var Translate = {};
    Translate.translate_view = function(data) {
        var model = data.model;
        Sao.Tab.create({
            model: 'ir.translation',
            domain: [['model', '=', model]],
            mode: ['tree', 'form'],
            name: Sao.i18n.gettext('Translate view'),
        });
    };
    Translate.get_plugins = function(model) {
        var access = Sao.common.MODELACCESS.get('ir.translation');
        if (access.read && access.write) {
            return [
                [Sao.i18n.gettext('Translate view'), Translate.translate_view],
            ];
        } else {
            return [];
        }
    };

    Sao.Plugins.push(Translate);
}());

/*
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:

The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.

THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
*/

(function () {
    'use strict';

    var tag_whitelist = {
        B: true,
        BODY: true,
        BR: true,
        DIV: true,
        FONT: true,
        I: true,
        U: true,
        SVG: true,
        PATH: true,
        CIRCLE: true,
        RECT: true,
        G: true,
        LINE: true,
        POLYGON: true,
        POLYLINE: true,
        TEXT: true,
    };

    var attribute_whitelist = {
        align: true,
        color: true,
        face: true,
        size: true,
        fill: true,
        stroke: true,
        d: true,
        cx: true,
        cy: true,
        r: true,
        x: true,
        y: true,
        width: true,
        height: true,
        viewBox: true,
    };

    Sao.HtmlSanitizer = {};
    Sao.HtmlSanitizer.sanitize = function(input) {
        input = input.trim();
        // to save performance and not create iframe
        if (input == "") return "";

        // firefox "bogus node" workaround
        if (input == "<br>") return "";

        var iframe = document.createElement('iframe');
        if (iframe.sandbox === undefined) {
            // Browser does not support sandboxed iframes
            Sao.Logger.warn("Your browser do not support sandboxed iframes," +
                " unable to sanitize HTML.");
            return input;
        }
        iframe.sandbox = 'allow-same-origin';
        iframe.style.display = 'none';
        // necessary so the iframe contains a document
        document.body.appendChild(iframe);
        var iframedoc = (iframe.contentDocument ||
            iframe.contentWindow.document);
        // null in IE
        if (iframedoc.body == null) {
            iframedoc.write("<body></body>");
        }
        iframedoc.body.innerHTML = input;

        function make_sanitized_copy(node) {
            var new_node;
            if (node.nodeType == Node.TEXT_NODE) {
                new_node = node.cloneNode(true);
            } else if (node.nodeType == Node.ELEMENT_NODE &&
                    tag_whitelist[node.tagName]) {
                //remove useless empty tags
                if ((node.tagName != "BR") && node.innerHTML.trim() == "") {
                    return document.createDocumentFragment();
                }

                new_node = iframedoc.createElement(node.tagName);

                for (var i = 0; i < node.attributes.length; i++) {
                    var attr = node.attributes[i];
                    if (attribute_whitelist[attr.name]) {
                        new_node.setAttribute(attr.name, attr.value);
                    }
                }
                for (i = 0; i < node.childNodes.length; i++) {
                    var sub_copy = make_sanitized_copy(node.childNodes[i]);
                    new_node.appendChild(sub_copy, false);
                }
            } else {
                new_node = document.createDocumentFragment();
                if (node.tagName != 'SCRIPT') {
                    new_node.textContent = node.textContent;
                }
            }
            return new_node;
        }

        var result_element = make_sanitized_copy(iframedoc.body);
        document.body.removeChild(iframe);
        // replace is just for cleaner code
        return result_element.innerHTML
            .replace(/<br[^>]*>(\S)/g, "<br>\n$1")
            .replace(/div><div/g, "div>\n<div");
    };
})();
