summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/kanban
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/kanban
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/kanban')
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column.js411
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column_progressbar.js288
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_column_quick_create.js246
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_controller.js537
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_examples_registry.js27
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_model.js445
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record.js761
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_record_quick_create.js315
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js684
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_view.js119
-rw-r--r--addons/web/static/src/js/views/kanban/quick_create_form_view.js123
11 files changed, 3956 insertions, 0 deletions
diff --git a/addons/web/static/src/js/views/kanban/kanban_column.js b/addons/web/static/src/js/views/kanban/kanban_column.js
new file mode 100644
index 00000000..4aeb5404
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column.js
@@ -0,0 +1,411 @@
+odoo.define('web.KanbanColumn', function (require) {
+"use strict";
+
+var config = require('web.config');
+var core = require('web.core');
+var session = require('web.session');
+var Dialog = require('web.Dialog');
+var KanbanRecord = require('web.KanbanRecord');
+var RecordQuickCreate = require('web.kanban_record_quick_create');
+var view_dialogs = require('web.view_dialogs');
+var viewUtils = require('web.viewUtils');
+var Widget = require('web.Widget');
+var KanbanColumnProgressBar = require('web.KanbanColumnProgressBar');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var KanbanColumn = Widget.extend({
+ template: 'KanbanView.Group',
+ custom_events: {
+ cancel_quick_create: '_onCancelQuickCreate',
+ quick_create_add_record: '_onQuickCreateAddRecord',
+ tweak_column: '_onTweakColumn',
+ tweak_column_records: '_onTweakColumnRecords',
+ },
+ events: {
+ 'click .o_column_edit': '_onEditColumn',
+ 'click .o_column_delete': '_onDeleteColumn',
+ 'click .o_kanban_quick_add': '_onAddQuickCreate',
+ 'click .o_kanban_load_more': '_onLoadMore',
+ 'click .o_kanban_toggle_fold': '_onToggleFold',
+ 'click .o_column_archive_records': '_onArchiveRecords',
+ 'click .o_column_unarchive_records': '_onUnarchiveRecords',
+ 'click .o_kanban_config .dropdown-menu': '_onConfigDropdownClicked',
+ },
+ /**
+ * @override
+ */
+ init: function (parent, data, options, recordOptions) {
+ this._super(parent);
+ this.db_id = data.id;
+ this.data_records = data.data;
+ this.data = data;
+
+ var value = data.value;
+ this.id = data.res_id;
+ this.folded = !data.isOpen;
+ this.has_active_field = 'active' in data.fields;
+ this.fields = data.fields;
+ this.records = [];
+ this.modelName = data.model;
+
+ this.quick_create = options.quick_create;
+ this.quickCreateView = options.quickCreateView;
+ this.groupedBy = options.groupedBy;
+ this.grouped_by_m2o = options.grouped_by_m2o;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.archivable = options.archivable;
+ this.draggable = options.draggable;
+ this.KanbanRecord = options.KanbanRecord || KanbanRecord; // the KanbanRecord class to use
+ this.records_editable = options.records_editable;
+ this.records_deletable = options.records_deletable;
+ this.recordsDraggable = options.recordsDraggable;
+ this.relation = options.relation;
+ this.offset = 0;
+ this.remaining = data.count - this.data_records.length;
+ this.canBeFolded = this.folded;
+
+ if (options.hasProgressBar) {
+ this.barOptions = {
+ columnID: this.db_id,
+ progressBarStates: options.progressBarStates,
+ };
+ }
+
+ this.record_options = _.clone(recordOptions);
+
+ if (options.grouped_by_m2o || options.grouped_by_date ) {
+ // For many2one and datetime, a false value means that the field is not set.
+ this.title = value ? value : _t('Undefined');
+ } else {
+ // False and 0 might be valid values for these fields.
+ this.title = value === undefined ? _t('Undefined') : value;
+ }
+
+ if (options.group_by_tooltip) {
+ this.tooltipInfo = _.compact(_.map(options.group_by_tooltip, function (help, field) {
+ help = help ? help + "</br>" : '';
+ return (data.tooltipData && data.tooltipData[field] && "<div>" + help + data.tooltipData[field] + "</div>") || '';
+ }));
+ this.tooltipInfo = this.tooltipInfo.join("<div class='dropdown-divider' role='separator' />");
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+ var defs = [this._super.apply(this, arguments)];
+ this.$header = this.$('.o_kanban_header');
+
+ for (var i = 0; i < this.data_records.length; i++) {
+ defs.push(this._addRecord(this.data_records[i]));
+ }
+
+ if (this.recordsDraggable) {
+ this.$el.sortable({
+ connectWith: '.o_kanban_group',
+ containment: this.draggable ? false : 'parent',
+ revert: 0,
+ delay: 0,
+ items: '> .o_kanban_record:not(.o_updating)',
+ cursor: 'move',
+ over: function () {
+ self.$el.addClass('o_kanban_hover');
+ },
+ out: function () {
+ self.$el.removeClass('o_kanban_hover');
+ },
+ start: function (event, ui) {
+ ui.item.addClass('o_currently_dragged');
+ },
+ stop: function (event, ui) {
+ var item = ui.item;
+ setTimeout(function () {
+ item.removeClass('o_currently_dragged');
+ });
+ },
+ update: function (event, ui) {
+ var record = ui.item.data('record');
+ var index = self.records.indexOf(record);
+ record.$el.removeAttr('style'); // jqueryui sortable add display:block inline
+ if (index >= 0) {
+ if ($.contains(self.$el[0], record.$el[0])) {
+ // resequencing records
+ self.trigger_up('kanban_column_resequence', {ids: self._getIDs()});
+ }
+ } else {
+ // adding record to this column
+ ui.item.addClass('o_updating');
+ self.trigger_up('kanban_column_add_record', {record: record, ids: self._getIDs()});
+ }
+ }
+ });
+ }
+ this.$el.click(function (event) {
+ if (self.folded) {
+ self._onToggleFold(event);
+ }
+ });
+ if (this.barOptions) {
+ this.$el.addClass('o_kanban_has_progressbar');
+ this.progressBar = new KanbanColumnProgressBar(this, this.barOptions, this.data);
+ defs.push(this.progressBar.appendTo(this.$header));
+ }
+
+ var title = this.folded ? this.title + ' (' + this.data.count + ')' : this.title;
+ this.$header.find('.o_column_title').text(title);
+
+ this.$el.toggleClass('o_column_folded', this.canBeFolded);
+ if (this.tooltipInfo) {
+ this.$header.find('.o_kanban_header_title').tooltip({}).attr('data-original-title', this.tooltipInfo);
+ }
+ if (!this.remaining) {
+ this.$('.o_kanban_load_more').remove();
+ } else {
+ this.$('.o_kanban_load_more').html(QWeb.render('KanbanView.LoadMore', {widget: this}));
+ }
+
+ return Promise.all(defs);
+ },
+ /**
+ * Called when a record has been quick created, as a new column is rendered
+ * and appended into a fragment, before replacing the old column in the DOM.
+ * When this happens, the quick create widget is inserted into the new
+ * column directly, and it should be focused. However, as it is rendered
+ * into a fragment, the focus has to be set manually once in the DOM.
+ */
+ on_attach_callback: function () {
+ _.invoke(this.records, 'on_attach_callback');
+ if (this.quickCreateWidget) {
+ this.quickCreateWidget.on_attach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds the quick create record to the top of the column.
+ *
+ * @returns {Promise}
+ */
+ addQuickCreate: async function () {
+ if (this.folded) {
+ // first open the column, and then add the quick create
+ this.trigger_up('column_toggle_fold', {
+ openQuickCreate: true,
+ });
+ return;
+ }
+
+ if (this.quickCreateWidget) {
+ return Promise.reject();
+ }
+ this.trigger_up('close_quick_create'); // close other quick create widgets
+ var context = this.data.getContext();
+ context['default_' + this.groupedBy] = viewUtils.getGroupValue(this.data, this.groupedBy);
+ this.quickCreateWidget = new RecordQuickCreate(this, {
+ context: context,
+ formViewRef: this.quickCreateView,
+ model: this.modelName,
+ });
+ await this.quickCreateWidget.appendTo(document.createDocumentFragment());
+ this.trigger_up('start_quick_create');
+ this.quickCreateWidget.$el.insertAfter(this.$header);
+ this.quickCreateWidget.on_attach_callback();
+ },
+ /**
+ * Closes the quick create widget if it isn't dirty.
+ */
+ cancelQuickCreate: function () {
+ if (this.quickCreateWidget) {
+ this.quickCreateWidget.cancel();
+ }
+ },
+ /**
+ * @returns {Boolean} true iff the column is empty
+ */
+ isEmpty: function () {
+ return !this.records.length;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a record in the column.
+ *
+ * @private
+ * @param {Object} recordState
+ * @param {Object} [options]
+ * @param {string} [options.position]
+ * 'before' to add at the top, add at the bottom by default
+ * @return {Promise}
+ */
+ _addRecord: function (recordState, options) {
+ var record = new this.KanbanRecord(this, recordState, this.record_options);
+ this.records.push(record);
+ if (options && options.position === 'before') {
+ return record.insertAfter(this.quickCreateWidget ? this.quickCreateWidget.$el : this.$header);
+ } else {
+ var $load_more = this.$('.o_kanban_load_more');
+ if ($load_more.length) {
+ return record.insertBefore($load_more);
+ } else {
+ return record.appendTo(this.$el);
+ }
+ }
+ },
+ /**
+ * Destroys the QuickCreate widget.
+ *
+ * @private
+ */
+ _cancelQuickCreate: function () {
+ this.quickCreateWidget.destroy();
+ this.quickCreateWidget = undefined;
+ },
+ /**
+ * @returns {integer[]} the res_ids of the records in the column
+ */
+ _getIDs: function () {
+ var ids = [];
+ this.$('.o_kanban_record').each(function (index, r) {
+ ids.push($(r).data('record').id);
+ });
+ return ids;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onAddQuickCreate: function () {
+ this.trigger_up('add_quick_create', { groupId: this.db_id });
+ },
+ /**
+ * @private
+ */
+ _onCancelQuickCreate: function () {
+ this._cancelQuickCreate();
+ },
+ /**
+ * Prevent from closing the config dropdown when the user clicks on a
+ * disabled item (e.g. 'Fold' in sample mode).
+ *
+ * @private
+ */
+ _onConfigDropdownClicked(ev) {
+ ev.stopPropagation();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onDeleteColumn: function (event) {
+ event.preventDefault();
+ var buttons = [
+ {
+ text: _t("Ok"),
+ classes: 'btn-primary',
+ close: true,
+ click: this.trigger_up.bind(this, 'kanban_column_delete'),
+ },
+ {text: _t("Cancel"), close: true}
+ ];
+ new Dialog(this, {
+ size: 'medium',
+ buttons: buttons,
+ $content: $('<div>', {
+ text: _t("Are you sure that you want to remove this column ?")
+ }),
+ }).open();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onEditColumn: function (event) {
+ event.preventDefault();
+ new view_dialogs.FormViewDialog(this, {
+ res_model: this.relation,
+ res_id: this.id,
+ context: session.user_context,
+ title: _t("Edit Column"),
+ on_saved: this.trigger_up.bind(this, 'reload'),
+ }).open();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onLoadMore: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_load_more');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} event
+ */
+ _onQuickCreateAddRecord: function (event) {
+ this.trigger_up('quick_create_record', event.data);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onToggleFold: function (event) {
+ event.preventDefault();
+ this.trigger_up('column_toggle_fold');
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumn: function (ev) {
+ ev.data.callback(this.$el);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onTweakColumnRecords: function (ev) {
+ _.each(this.records, function (record) {
+ ev.data.callback(record.$el, record.state.data);
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onArchiveRecords: function (event) {
+ event.preventDefault();
+ Dialog.confirm(this, _t("Are you sure that you want to archive all the records from this column?"), {
+ confirm_callback: this.trigger_up.bind(this, 'kanban_column_records_toggle_active', {
+ archive: true,
+ }),
+ });
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onUnarchiveRecords: function (event) {
+ event.preventDefault();
+ this.trigger_up('kanban_column_records_toggle_active', {
+ archive: false,
+ });
+ }
+});
+
+return KanbanColumn;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
new file mode 100644
index 00000000..752d2b2d
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column_progressbar.js
@@ -0,0 +1,288 @@
+odoo.define('web.KanbanColumnProgressBar', function (require) {
+'use strict';
+
+const core = require('web.core');
+var session = require('web.session');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+
+const _t = core._t;
+
+var KanbanColumnProgressBar = Widget.extend({
+ template: 'KanbanView.ColumnProgressBar',
+ events: {
+ 'click .o_kanban_counter_progress': '_onProgressBarParentClick',
+ 'click .progress-bar': '_onProgressBarClick',
+ },
+ /**
+ * Allows to disable animations for tests.
+ * @type {boolean}
+ */
+ ANIMATE: true,
+
+ /**
+ * @constructor
+ */
+ init: function (parent, options, columnState) {
+ this._super.apply(this, arguments);
+
+ this.columnID = options.columnID;
+ this.columnState = columnState;
+
+ // <progressbar/> attributes
+ this.fieldName = columnState.progressBarValues.field;
+ this.colors = _.extend({}, columnState.progressBarValues.colors, {
+ __false: 'muted', // color to use for false value
+ });
+ this.sumField = columnState.progressBarValues.sum_field;
+
+ // Previous progressBar state
+ var state = options.progressBarStates[this.columnID];
+ if (state) {
+ this.groupCount = state.groupCount;
+ this.subgroupCounts = state.subgroupCounts;
+ this.totalCounterValue = state.totalCounterValue;
+ this.activeFilter = state.activeFilter;
+ }
+
+ // Prepare currency (TODO this should be automatic... use a field ?)
+ var sumFieldInfo = this.sumField && columnState.fieldsInfo.kanban[this.sumField];
+ var currencyField = sumFieldInfo && sumFieldInfo.options && sumFieldInfo.options.currency_field;
+ if (currencyField && columnState.data.length) {
+ this.currency = session.currencies[columnState.data[0].data[currencyField].res_id];
+ }
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ var self = this;
+
+ this.$bars = {};
+ _.each(this.colors, function (val, key) {
+ self.$bars[key] = self.$(`.progress-bar[data-filter=${key}]`);
+ });
+ this.$counter = this.$('.o_kanban_counter_side');
+ this.$number = this.$counter.find('b');
+
+ if (this.currency) {
+ var $currency = $('<span/>', {
+ text: this.currency.symbol,
+ });
+ if (this.currency.position === 'before') {
+ $currency.prependTo(this.$counter);
+ } else {
+ $currency.appendTo(this.$counter);
+ }
+ }
+
+ return this._super.apply(this, arguments).then(function () {
+ // This should be executed when the progressbar is fully rendered
+ // and is in the DOM, this happens to be always the case with
+ // current use of progressbars
+ self.computeCounters();
+ self._notifyState();
+ self._render();
+ });
+ },
+ /**
+ * Computes the count of each sub group and the total count
+ */
+ computeCounters() {
+ const subgroupCounts = {};
+ let allSubgroupCount = 0;
+ for (const key of Object.keys(this.colors)) {
+ const subgroupCount = this.columnState.progressBarValues.counts[key] || 0;
+ if (this.activeFilter === key && subgroupCount === 0) {
+ this.activeFilter = false;
+ }
+ subgroupCounts[key] = subgroupCount;
+ allSubgroupCount += subgroupCount;
+ };
+ subgroupCounts.__false = this.columnState.count - allSubgroupCount;
+
+ this.groupCount = this.columnState.count;
+ this.subgroupCounts = subgroupCounts;
+ this.prevTotalCounterValue = this.totalCounterValue;
+ this.totalCounterValue = this.sumField ? (this.columnState.aggregateValues[this.sumField] || 0) : this.columnState.count;
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Updates the rendering according to internal data. This is done without
+ * qweb rendering because there are animations.
+ *
+ * @private
+ */
+ _render: function () {
+ var self = this;
+
+ // Update column display according to active filter
+ this.trigger_up('tweak_column', {
+ callback: function ($el) {
+ $el.removeClass('o_kanban_group_show');
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('o_kanban_group_show_' + val);
+ });
+ if (self.activeFilter) {
+ $el.addClass('o_kanban_group_show o_kanban_group_show_' + self.colors[self.activeFilter]);
+ }
+ },
+ });
+ this.trigger_up('tweak_column_records', {
+ callback: function ($el, recordData) {
+ var categoryValue = recordData[self.fieldName] ? recordData[self.fieldName] : '__false';
+ _.each(self.colors, function (val, key) {
+ $el.removeClass('oe_kanban_card_' + val);
+ });
+ if (self.colors[categoryValue]) {
+ $el.addClass('oe_kanban_card_' + self.colors[categoryValue]);
+ }
+ },
+ });
+
+ // Display and animate the progress bars
+ var barNumber = 0;
+ var barMinWidth = 6; // In %
+ const selection = self.columnState.fields[self.fieldName].selection;
+ _.each(self.colors, function (val, key) {
+ var $bar = self.$bars[key];
+ var count = self.subgroupCounts && self.subgroupCounts[key] || 0;
+
+ if (!$bar) {
+ return;
+ }
+
+ // Adapt tooltip
+ let value;
+ if (selection) { // progressbar on a field of type selection
+ const option = selection.find(option => option[0] === key);
+ value = option && option[1] || _t('Other');
+ } else {
+ value = key;
+ }
+ $bar.attr('data-original-title', count + ' ' + value);
+ $bar.tooltip({
+ delay: 0,
+ trigger: 'hover',
+ });
+
+ // Adapt active state
+ $bar.toggleClass('progress-bar-animated progress-bar-striped', key === self.activeFilter);
+
+ // Adapt width
+ $bar.removeClass('o_bar_has_records transition-off');
+ window.getComputedStyle($bar[0]).getPropertyValue('width'); // Force reflow so that animations work
+ if (count > 0) {
+ $bar.addClass('o_bar_has_records');
+ // Make sure every bar that has records has some space
+ // and that everything adds up to 100%
+ var maxWidth = 100 - barMinWidth * barNumber;
+ self.$('.progress-bar.o_bar_has_records').css('max-width', maxWidth + '%');
+ $bar.css('width', (count * 100 / self.groupCount) + '%');
+ barNumber++;
+ $bar.attr('aria-valuemin', 0);
+ $bar.attr('aria-valuemax', self.groupCount);
+ $bar.attr('aria-valuenow', count);
+ } else {
+ $bar.css('width', '');
+ }
+ });
+ this.$('.progress-bar').css('min-width', '');
+ this.$('.progress-bar.o_bar_has_records').css('min-width', barMinWidth + '%');
+
+ // Display and animate the counter number
+ var start = this.prevTotalCounterValue;
+ var end = this.totalCounterValue;
+
+ if (this.activeFilter) {
+ if (this.sumField) {
+ end = 0;
+ _.each(self.columnState.data, function (record) {
+ var recordData = record.data;
+ if (self.activeFilter === recordData[self.fieldName] ||
+ (self.activeFilter === '__false' && !recordData[self.fieldName])) {
+ end += parseFloat(recordData[self.sumField]);
+ }
+ });
+ } else {
+ end = this.subgroupCounts[this.activeFilter];
+ }
+ }
+ this.prevTotalCounterValue = end;
+ var animationClass = start > 999 ? 'o_kanban_grow' : 'o_kanban_grow_huge';
+
+ if (start !== undefined && (end > start || this.activeFilter) && this.ANIMATE) {
+ $({currentValue: start}).animate({currentValue: end}, {
+ duration: 1000,
+ start: function () {
+ self.$counter.addClass(animationClass);
+ },
+ step: function () {
+ self.$number.html(_getCounterHTML(this.currentValue));
+ },
+ complete: function () {
+ self.$number.html(_getCounterHTML(this.currentValue));
+ self.$counter.removeClass(animationClass);
+ },
+ });
+ } else {
+ this.$number.html(_getCounterHTML(end));
+ }
+
+ function _getCounterHTML(value) {
+ return utils.human_number(value, 0, 3);
+ }
+ },
+ /**
+ * Notifies the new progressBar state so that if a full rerender occurs, the
+ * new progressBar that would replace this one will be initialized with
+ * current state, so that animations are correct.
+ *
+ * @private
+ */
+ _notifyState: function () {
+ this.trigger_up('set_progress_bar_state', {
+ columnID: this.columnID,
+ values: {
+ groupCount: this.groupCount,
+ subgroupCounts: this.subgroupCounts,
+ totalCounterValue: this.totalCounterValue,
+ activeFilter: this.activeFilter,
+ },
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarClick: function (ev) {
+ this.$clickedBar = $(ev.currentTarget);
+ var filter = this.$clickedBar.data('filter');
+ this.activeFilter = (this.activeFilter === filter ? false : filter);
+ this._notifyState();
+ this._render();
+ },
+ /**
+ * @private
+ * @param {Event} ev
+ */
+ _onProgressBarParentClick: function (ev) {
+ if (ev.target !== ev.currentTarget) {
+ return;
+ }
+ this.activeFilter = false;
+ this._notifyState();
+ this._render();
+ },
+});
+return KanbanColumnProgressBar;
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js
new file mode 100644
index 00000000..c4bed5fa
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_column_quick_create.js
@@ -0,0 +1,246 @@
+odoo.define('web.kanban_column_quick_create', function (require) {
+"use strict";
+
+/**
+ * This file defines the ColumnQuickCreate widget for Kanban. It allows to
+ * create kanban columns directly from the Kanban view.
+ */
+
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var Widget = require('web.Widget');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var ColumnQuickCreate = Widget.extend({
+ template: 'KanbanView.ColumnQuickCreate',
+ events: {
+ 'click .o_quick_create_folded': '_onUnfold',
+ 'click .o_kanban_add': '_onAddClicked',
+ 'click .o_kanban_examples': '_onShowExamples',
+ 'keydown': '_onKeydown',
+ 'keypress input': '_onKeypress',
+ 'blur input': '_onInputBlur',
+ 'focus input': '_onInputFocus',
+ },
+
+ /**
+ * @override
+ * @param {Object} [options]
+ * @param {Object} [options.examples]
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.applyExamplesText = options.applyExampleText || _t("Use This For My Kanban");
+ this.examples = options.examples;
+ this.folded = true;
+ this.isMobile = false;
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$quickCreateFolded = this.$('.o_quick_create_folded');
+ this.$quickCreateUnfolded = this.$('.o_quick_create_unfolded');
+ this.$input = this.$('input');
+
+ // destroy the quick create when the user clicks outside
+ core.bus.on('click', this, this._onWindowClicked);
+
+ this._update();
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Called each time the quick create is attached into the DOM
+ */
+ on_attach_callback: function () {
+ if (!this.folded) {
+ this.$input.focus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Folds/unfolds the Column quick create widget
+ */
+ toggleFold: function () {
+ this.folded = !this.folded;
+ this._update();
+ if (!this.folded) {
+ this.$input.focus();
+ this.trigger_up('scrollTo', {selector: '.o_column_quick_create'});
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Clears the input value and notify the environment to create a column
+ *
+ * @private
+ */
+ _add: function () {
+ var value = this.$input.val().trim();
+ if (!value.length) {
+ this._cancel();
+ return;
+ }
+ this.$input.val('');
+ this.trigger_up('quick_create_add_column', {value: value});
+ this.$input.focus();
+ },
+ /**
+ * Cancels the quick creation
+ *
+ * @private
+ */
+ _cancel: function () {
+ if (!this.folded) {
+ this.$input.val('');
+ this.folded = true;
+ this._update();
+ }
+ },
+ /**
+ * Updates the rendering according to the current state (folded/unfolded)
+ *
+ * @private
+ */
+ _update: function () {
+ this.$quickCreateFolded.toggle(this.folded);
+ this.$quickCreateUnfolded.toggle(!this.folded);
+ this.trigger_up('quick_create_column_updated');
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onAddClicked: function (event) {
+ event.stopPropagation();
+ this._add();
+ },
+ /**
+ * When the input is not focused, no key event may occur in the column, so
+ * the discard feature will not work by pressing ESC. We simply hide the
+ * help message in that case, so we do not mislead our users.
+ *
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onInputBlur: function () {
+ this.$('.o_discard_msg').hide();
+ },
+ /**
+ * When the input is focused, we need to show the discard help message (it
+ * might have been hidden, @see _onInputBlur)
+ *
+ * @private
+ * @param {KeyboardEvent} event
+ */
+ _onInputFocus: function () {
+ this.$('.o_discard_msg').show();
+ },
+ /**
+ * Cancels quick creation on escape keydown event
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeydown: function (event) {
+ if (event.keyCode === $.ui.keyCode.ESCAPE) {
+ this._cancel();
+ }
+ },
+ /**
+ * Validates quick creation on enter keypress event
+ *
+ * @private
+ * @param {KeyEvent} event
+ */
+ _onKeypress: function (event) {
+ if (event.keyCode === $.ui.keyCode.ENTER) {
+ this._add();
+ }
+ },
+ /**
+ * Opens a dialog containing examples of Kanban processes
+ *
+ * @private
+ */
+ _onShowExamples: function () {
+ var self = this;
+ var dialog = new Dialog(this, {
+ $content: $(QWeb.render('KanbanView.ExamplesDialog', {
+ examples: this.examples,
+ })),
+ buttons: [{
+ classes: 'btn-primary float-right',
+ text: this.applyExamplesText,
+ close: true,
+ click: function () {
+ const activeExample = self.examples[this.$('.nav-link.active').data("exampleIndex")];
+ activeExample.columns.forEach(column => {
+ self.trigger_up('quick_create_add_column', { value: column.toString(), foldQuickCreate: true });
+ });
+ }
+ }, {
+ classes: 'btn-secondary float-right',
+ close: true,
+ text: _t('Close'),
+ }],
+ size: "large",
+ title: "Kanban Examples",
+ }).open();
+ dialog.on('closed', this, function () {
+ self.$input.focus();
+ });
+ },
+ /**
+ * @private
+ */
+ _onUnfold: function () {
+ if (this.folded) {
+ this.toggleFold();
+ }
+ },
+ /**
+ * When a click happens outside the quick create, we want to close it.
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onWindowClicked: function (event) {
+ // ignore clicks if the quick create is not in the dom
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // ignore clicks in modals
+ if ($(event.target).closest('.modal').length) {
+ return;
+ }
+
+ // ignore clicks if target is inside the quick create
+ if (this.el.contains(event.target)) {
+ return;
+ }
+
+ this._cancel();
+ },
+});
+
+return ColumnQuickCreate;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_controller.js b/addons/web/static/src/js/views/kanban/kanban_controller.js
new file mode 100644
index 00000000..1b6e6301
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_controller.js
@@ -0,0 +1,537 @@
+odoo.define('web.KanbanController', function (require) {
+"use strict";
+
+/**
+ * The KanbanController is the class that coordinates the kanban model and the
+ * kanban renderer. It also makes sure that update from the search view are
+ * properly interpreted.
+ */
+
+var BasicController = require('web.BasicController');
+var Context = require('web.Context');
+var core = require('web.core');
+var Dialog = require('web.Dialog');
+var Domain = require('web.Domain');
+var view_dialogs = require('web.view_dialogs');
+var viewUtils = require('web.viewUtils');
+
+var _t = core._t;
+var qweb = core.qweb;
+
+var KanbanController = BasicController.extend({
+ buttons_template: 'KanbanView.buttons',
+ custom_events: _.extend({}, BasicController.prototype.custom_events, {
+ add_quick_create: '_onAddQuickCreate',
+ quick_create_add_column: '_onAddColumn',
+ quick_create_record: '_onQuickCreateRecord',
+ resequence_columns: '_onResequenceColumn',
+ button_clicked: '_onButtonClicked',
+ kanban_record_delete: '_onRecordDelete',
+ kanban_record_update: '_onUpdateRecord',
+ kanban_column_delete: '_onDeleteColumn',
+ kanban_column_add_record: '_onAddRecordToColumn',
+ kanban_column_resequence: '_onColumnResequence',
+ kanban_load_more: '_onLoadMore',
+ column_toggle_fold: '_onToggleColumn',
+ kanban_column_records_toggle_active: '_onToggleActiveRecords',
+ }),
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.quickCreateEnabled set to false to disable the
+ * quick create feature
+ * @param {SearchPanel} [params.searchPanel]
+ * @param {Array[]} [params.controlPanelDomain=[]] initial domain coming
+ * from the controlPanel
+ */
+ init: function (parent, model, renderer, params) {
+ this._super.apply(this, arguments);
+ this.on_create = params.on_create;
+ this.hasButtons = params.hasButtons;
+ this.quickCreateEnabled = params.quickCreateEnabled;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @param {jQuery} [$node]
+ */
+ renderButtons: function ($node) {
+ if (!this.hasButtons || !this.is_action_enabled('create')) {
+ return;
+ }
+ this.$buttons = $(qweb.render(this.buttons_template, {
+ btnClass: 'btn-primary',
+ widget: this,
+ }));
+ this.$buttons.on('click', 'button.o-kanban-button-new', this._onButtonNew.bind(this));
+ this.$buttons.on('keydown', this._onButtonsKeyDown.bind(this));
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+ /**
+ * In grouped mode, set 'Create' button as btn-secondary if there is no column
+ * (except if we can't create new columns)
+ *
+ * @override
+ */
+ updateButtons: function () {
+ if (!this.$buttons) {
+ return;
+ }
+ var state = this.model.get(this.handle, {raw: true});
+ var createHidden = this.is_action_enabled('group_create') && state.isGroupedByM2ONoColumn;
+ this.$buttons.find('.o-kanban-button-new').toggleClass('o_hidden', createHidden);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the record quick create widget in the requested column, given its
+ * id (in the first column by default). Ensures that we removed sample data
+ * if any, before displaying the quick create.
+ *
+ * @private
+ * @param {string} [groupId]
+ */
+ _addQuickCreate(groupId) {
+ this._removeSampleData(async () => {
+ await this.update({ shouldUpdateSearchComponents: false }, { reload: false });
+ return this.renderer.addQuickCreate(groupId);
+ });
+ },
+ /**
+ * @override method comes from field manager mixin
+ * @private
+ * @param {string} id local id from the basic record data
+ * @returns {Promise}
+ */
+ _confirmSave: function (id) {
+ var data = this.model.get(this.handle, {raw: true});
+ var grouped = data.groupedBy.length;
+ if (grouped) {
+ var columnState = this.model.getColumn(id);
+ return this.renderer.updateColumn(columnState.id, columnState);
+ }
+ return this.renderer.updateRecord(this.model.get(id));
+ },
+ /**
+ * Only display the pager in the ungrouped case, with data.
+ *
+ * @override
+ * @private
+ */
+ _getPagingInfo: function (state) {
+ if (!(state.count && !state.groupedBy.length)) {
+ return null;
+ }
+ return this._super(...arguments);
+ },
+ /**
+ * @private
+ * @param {Widget} kanbanRecord
+ * @param {Object} params
+ */
+ _reloadAfterButtonClick: function (kanbanRecord, params) {
+ var self = this;
+ var recordModel = this.model.localData[params.record.id];
+ var group = this.model.localData[recordModel.parentID];
+ var parent = this.model.localData[group.parentID];
+
+ this.model.reload(params.record.id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ kanbanRecord.update(data);
+
+ // Check if we still need to display the record. Some fields of the domain are
+ // not guaranteed to be in data. This is for example the case if the action
+ // contains a domain on a field which is not in the Kanban view. Therefore,
+ // we need to handle multiple cases based on 3 variables:
+ // domInData: all domain fields are in the data
+ // activeInDomain: 'active' is already in the domain
+ // activeInData: 'active' is available in the data
+
+ var domain = (parent ? parent.domain : group.domain) || [];
+ var domInData = _.every(domain, function (d) {
+ return d[0] in data.data;
+ });
+ var activeInDomain = _.pluck(domain, 0).indexOf('active') !== -1;
+ var activeInData = 'active' in data.data;
+
+ // Case # | domInData | activeInDomain | activeInData
+ // 1 | true | true | true => no domain change
+ // 2 | true | true | false => not possible
+ // 3 | true | false | true => add active in domain
+ // 4 | true | false | false => no domain change
+ // 5 | false | true | true => no evaluation
+ // 6 | false | true | false => no evaluation
+ // 7 | false | false | true => replace domain
+ // 8 | false | false | false => no evaluation
+
+ // There are 3 cases which cannot be evaluated since we don't have all the
+ // necessary information. The complete solution would be to perform a RPC in
+ // these cases, but this is out of scope. A simpler one is to do a try / catch.
+
+ if (domInData && !activeInDomain && activeInData) {
+ domain = domain.concat([['active', '=', true]]);
+ } else if (!domInData && !activeInDomain && activeInData) {
+ domain = [['active', '=', true]];
+ }
+ try {
+ var visible = new Domain(domain).compute(data.evalContext);
+ } catch (e) {
+ return;
+ }
+ if (!visible) {
+ kanbanRecord.destroy();
+ }
+ });
+ },
+ /**
+ * @param {number[]} ids
+ * @private
+ * @returns {Promise}
+ */
+ _resequenceColumns: function (ids) {
+ var state = this.model.get(this.handle, {raw: true});
+ var model = state.fields[state.groupedBy[0]].relation;
+ return this.model.resequence(model, ids, this.handle);
+ },
+ /**
+ * This method calls the server to ask for a resequence. Note that this
+ * does not rerender the user interface, because in most case, the
+ * resequencing operation has already been displayed by the renderer.
+ *
+ * @private
+ * @param {string} column_id
+ * @param {string[]} ids
+ * @returns {Promise}
+ */
+ _resequenceRecords: function (column_id, ids) {
+ var self = this;
+ return this.model.resequence(this.modelName, ids, column_id);
+ },
+ /**
+ * @override
+ */
+ _shouldBounceOnClick(element) {
+ const state = this.model.get(this.handle, {raw: true});
+ if (!state.count || state.isSample) {
+ const classesList = [
+ 'o_kanban_view',
+ 'o_kanban_group',
+ 'o_kanban_header',
+ 'o_column_quick_create',
+ 'o_view_nocontent_smiling_face',
+ ];
+ return classesList.some(c => element.classList.contains(c));
+ }
+ return false;
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * This handler is called when an event (from the quick create add column)
+ * event bubbles up. When that happens, we need to ask the model to create
+ * a group and to update the renderer
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAddColumn: function (ev) {
+ var self = this;
+ this.mutex.exec(function () {
+ return self.model.createGroup(ev.data.value, self.handle).then(function () {
+ var state = self.model.get(self.handle, {raw: true});
+ var ids = _.pluck(state.data, 'res_id').filter(_.isNumber);
+ return self._resequenceColumns(ids);
+ }).then(function () {
+ return self.update({}, {reload: false});
+ }).then(function () {
+ let quickCreateFolded = self.renderer.quickCreate.folded;
+ if (ev.data.foldQuickCreate ? !quickCreateFolded : quickCreateFolded) {
+ self.renderer.quickCreateToggleFold();
+ }
+ self.renderer.trigger_up("quick_create_column_created");
+ });
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAddRecordToColumn: function (ev) {
+ var self = this;
+ var record = ev.data.record;
+ var column = ev.target;
+ this.alive(this.model.moveRecord(record.db_id, column.db_id, this.handle))
+ .then(function (column_db_ids) {
+ return self._resequenceRecords(column.db_id, ev.data.ids)
+ .then(function () {
+ _.each(column_db_ids, function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.updateColumn(db_id, data);
+ });
+ });
+ }).guardedCatch(this.reload.bind(this));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @returns {string} ev.data.groupId
+ */
+ _onAddQuickCreate(ev) {
+ ev.stopPropagation();
+ this._addQuickCreate(ev.data.groupId);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onButtonClicked: function (ev) {
+ var self = this;
+ ev.stopPropagation();
+ var attrs = ev.data.attrs;
+ var record = ev.data.record;
+ var def = Promise.resolve();
+ if (attrs.context) {
+ attrs.context = new Context(attrs.context)
+ .set_eval_context({
+ active_id: record.res_id,
+ active_ids: [record.res_id],
+ active_model: record.model,
+ });
+ }
+ if (attrs.confirm) {
+ def = new Promise(function (resolve, reject) {
+ Dialog.confirm(this, attrs.confirm, {
+ confirm_callback: resolve,
+ cancel_callback: reject,
+ }).on("closed", null, reject);
+ });
+ }
+ def.then(function () {
+ self.trigger_up('execute_action', {
+ action_data: attrs,
+ env: {
+ context: record.getContext(),
+ currentID: record.res_id,
+ model: record.model,
+ resIDs: record.res_ids,
+ },
+ on_closed: self._reloadAfterButtonClick.bind(self, ev.target, ev.data),
+ });
+ });
+ },
+ /**
+ * @private
+ */
+ _onButtonNew: function () {
+ var state = this.model.get(this.handle, {raw: true});
+ var quickCreateEnabled = this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(state);
+ if (this.on_create === 'quick_create' && quickCreateEnabled && state.data.length) {
+ // activate the quick create in the first column when the mutex is
+ // unlocked, to ensure that there is no pending re-rendering that
+ // would remove it (e.g. if we are currently adding a new column)
+ this.mutex.getUnlockedDef().then(this._addQuickCreate.bind(this, null));
+ } else if (this.on_create && this.on_create !== 'quick_create') {
+ // Execute the given action
+ this.do_action(this.on_create, {
+ on_close: this.reload.bind(this, {}),
+ additional_context: state.context,
+ });
+ } else {
+ // Open the form view
+ this.trigger_up('switch_view', {
+ view_type: 'form',
+ res_id: undefined
+ });
+ }
+ },
+ /**
+ * Moves the focus from the controller buttons to the first kanban record
+ *
+ * @private
+ * @param {jQueryEvent} ev
+ */
+ _onButtonsKeyDown: function (ev) {
+ switch(ev.keyCode) {
+ case $.ui.keyCode.DOWN:
+ this._giveFocus();
+ }
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onColumnResequence: function (ev) {
+ this._resequenceRecords(ev.target.db_id, ev.data.ids);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onDeleteColumn: function (ev) {
+ var column = ev.target;
+ var state = this.model.get(this.handle, {raw: true});
+ var relatedModelName = state.fields[state.groupedBy[0]].relation;
+ this.model
+ .deleteRecords([column.db_id], relatedModelName)
+ .then(this.update.bind(this, {}, {}));
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onLoadMore: function (ev) {
+ var self = this;
+ var column = ev.target;
+ this.model.loadMore(column.db_id).then(function (db_id) {
+ var data = self.model.get(db_id);
+ self.renderer.updateColumn(db_id, data);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {KanbanColumn} ev.target the column in which the record should
+ * be added
+ * @param {Object} ev.data.values the field values of the record to
+ * create; if values only contains the value of the 'display_name', a
+ * 'name_create' is performed instead of 'create'
+ * @param {function} [ev.data.onFailure] called when the quick creation
+ * failed
+ */
+ _onQuickCreateRecord: function (ev) {
+ var self = this;
+ var values = ev.data.values;
+ var column = ev.target;
+ var onFailure = ev.data.onFailure || function () {};
+
+ // function that updates the kanban view once the record has been added
+ // it receives the local id of the created record in arguments
+ var update = function (db_id) {
+
+ var columnState = self.model.getColumn(db_id);
+ var state = self.model.get(self.handle);
+ return self.renderer
+ .updateColumn(columnState.id, columnState, {openQuickCreate: true, state: state})
+ .then(function () {
+ if (ev.data.openRecord) {
+ self.trigger_up('open_record', {id: db_id, mode: 'edit'});
+ }
+ });
+ };
+
+ this.model.createRecordInGroup(column.db_id, values)
+ .then(update)
+ .guardedCatch(function (reason) {
+ reason.event.preventDefault();
+ var columnState = self.model.get(column.db_id, {raw: true});
+ var context = columnState.getContext();
+ var state = self.model.get(self.handle, {raw: true});
+ var groupedBy = state.groupedBy[0];
+ context['default_' + groupedBy] = viewUtils.getGroupValue(columnState, groupedBy);
+ new view_dialogs.FormViewDialog(self, {
+ res_model: state.model,
+ context: _.extend({default_name: values.name || values.display_name}, context),
+ title: _t("Create"),
+ disable_multiple_selection: true,
+ on_saved: function (record) {
+ self.model.addRecordToGroup(column.db_id, record.res_id)
+ .then(update);
+ },
+ }).open().opened(onFailure);
+ });
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onRecordDelete: function (ev) {
+ this._deleteRecords([ev.data.id]);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onResequenceColumn: function (ev) {
+ var self = this;
+ this._resequenceColumns(ev.data.ids);
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ * @param {boolean} [ev.data.openQuickCreate=false] if true, opens the
+ * QuickCreate in the toggled column (it assumes that we are opening it)
+ */
+ _onToggleColumn: function (ev) {
+ var self = this;
+ const columnID = ev.target.db_id || ev.data.db_id;
+ this.model.toggleGroup(columnID)
+ .then(function (db_id) {
+ var data = self.model.get(db_id);
+ var options = {
+ openQuickCreate: !!ev.data.openQuickCreate,
+ };
+ return self.renderer.updateColumn(db_id, data, options);
+ })
+ .then(function () {
+ if (ev.data.onSuccess) {
+ ev.data.onSuccess();
+ }
+ });
+ },
+ /**
+ * @todo should simply use field_changed event...
+ *
+ * @private
+ * @param {OdooEvent} ev
+ * @param {function} [ev.data.onSuccess] callback to execute after applying
+ * changes
+ */
+ _onUpdateRecord: function (ev) {
+ var onSuccess = ev.data.onSuccess;
+ delete ev.data.onSuccess;
+ var changes = _.clone(ev.data);
+ ev.data.force_save = true;
+ this._applyChanges(ev.target.db_id, changes, ev).then(onSuccess);
+ },
+ /**
+ * Allow the user to archive/restore all the records of a column.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onToggleActiveRecords: function (ev) {
+ var self = this;
+ var archive = ev.data.archive;
+ var column = ev.target;
+ var recordIds = _.pluck(column.records, 'id');
+ if (recordIds.length) {
+ var prom = archive ?
+ this.model.actionArchive(recordIds, column.db_id) :
+ this.model.actionUnarchive(recordIds, column.db_id);
+ prom.then(function (dbID) {
+ var data = self.model.get(dbID);
+ if (data) { // Could be null if a wizard is returned for example
+ self.model.reload(self.handle).then(function () {
+ const state = self.model.get(self.handle);
+ self.renderer.updateColumn(dbID, data, { state });
+ });
+ }
+ });
+ }
+ },
+});
+
+return KanbanController;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_examples_registry.js b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js
new file mode 100644
index 00000000..effdd7ff
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_examples_registry.js
@@ -0,0 +1,27 @@
+odoo.define('web.kanban_examples_registry', function (require) {
+"use strict";
+
+/**
+ * This file instantiates and exports a registry. The purpose of this registry
+ * is to store static data displayed in a dialog to help the end user to
+ * configure its columns in the grouped Kanban view.
+ *
+ * To activate a link on the ColumnQuickCreate widget on open such a dialog, the
+ * attribute 'examples' on the root arch node must be set to a valid key in this
+ * registry.
+ *
+ * Each value in this registry must be an array of Objects containing the
+ * following keys:
+ * - name (string)
+ * - columns (Array[string])
+ * - description (string, optional) BE CAREFUL [*]
+ *
+ * [*] The description is added with a t-raw so the translated texts must be
+ * properly escaped.
+ */
+
+var Registry = require('web.Registry');
+
+return new Registry();
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_model.js b/addons/web/static/src/js/views/kanban/kanban_model.js
new file mode 100644
index 00000000..7dcfe408
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_model.js
@@ -0,0 +1,445 @@
+odoo.define('web.KanbanModel', function (require) {
+"use strict";
+
+/**
+ * The KanbanModel extends the BasicModel to add Kanban specific features like
+ * moving a record from a group to another, resequencing records...
+ */
+
+var BasicModel = require('web.BasicModel');
+var viewUtils = require('web.viewUtils');
+
+var KanbanModel = BasicModel.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Adds a record to a group in the localData, and fetch the record.
+ *
+ * @param {string} groupID localID of the group
+ * @param {integer} resId id of the record
+ * @returns {Promise<string>} resolves to the local id of the new record
+ */
+ addRecordToGroup: function (groupID, resId) {
+ var self = this;
+ var group = this.localData[groupID];
+ var new_record = this._makeDataPoint({
+ res_id: resId,
+ modelName: group.model,
+ fields: group.fields,
+ fieldsInfo: group.fieldsInfo,
+ viewType: group.viewType,
+ parentID: groupID,
+ });
+
+ var def = this._fetchRecord(new_record).then(function (result) {
+ group.data.unshift(new_record.id);
+ group.res_ids.unshift(resId);
+ group.count++;
+
+ // update the res_ids and count of the parent
+ self.localData[group.parentID].count++;
+ self._updateParentResIDs(group);
+
+ return result.id;
+ });
+ return this._reloadProgressBarGroupFromRecord(new_record.id, def);
+ },
+ /**
+ * Creates a new group from a name (performs a name_create).
+ *
+ * @param {string} name
+ * @param {string} parentID localID of the parent of the group
+ * @returns {Promise<string>} resolves to the local id of the new group
+ */
+ createGroup: function (name, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var groupBy = parent.groupedBy[0];
+ var groupByField = parent.fields[groupBy];
+ if (!groupByField || groupByField.type !== 'many2one') {
+ return Promise.reject(); // only supported when grouped on m2o
+ }
+ return this._rpc({
+ model: groupByField.relation,
+ method: 'name_create',
+ args: [name],
+ context: parent.context, // todo: combine with view context
+ })
+ .then(function (result) {
+ const createGroupDataPoint = (model, parent) => {
+ const newGroup = model._makeDataPoint({
+ modelName: parent.model,
+ context: parent.context,
+ domain: parent.domain.concat([[groupBy, "=", result[0]]]),
+ fields: parent.fields,
+ fieldsInfo: parent.fieldsInfo,
+ isOpen: true,
+ limit: parent.limit,
+ parentID: parent.id,
+ openGroupByDefault: true,
+ orderedBy: parent.orderedBy,
+ value: result,
+ viewType: parent.viewType,
+ });
+ if (parent.progressBar) {
+ newGroup.progressBarValues = _.extend({
+ counts: {},
+ }, parent.progressBar);
+ }
+ return newGroup;
+ };
+ const newGroup = createGroupDataPoint(self, parent);
+ parent.data.push(newGroup.id);
+ if (self.isInSampleMode()) {
+ // in sample mode, create the new group in both models (main + sample)
+ const sampleParent = self.sampleModel.localData[parentID];
+ const newSampleGroup = createGroupDataPoint(self.sampleModel, sampleParent);
+ sampleParent.data.push(newSampleGroup.id);
+ }
+ return newGroup.id;
+ });
+ },
+ /**
+ * Creates a new record from the given value, and add it to the given group.
+ *
+ * @param {string} groupID
+ * @param {Object} values
+ * @returns {Promise} resolved with the local id of the created record
+ */
+ createRecordInGroup: function (groupID, values) {
+ var self = this;
+ var group = this.localData[groupID];
+ var context = this._getContext(group);
+ var parent = this.localData[group.parentID];
+ var groupedBy = parent.groupedBy;
+ context['default_' + groupedBy] = viewUtils.getGroupValue(group, groupedBy);
+ var def;
+ if (Object.keys(values).length === 1 && 'display_name' in values) {
+ // only 'display_name is given, perform a 'name_create'
+ def = this._rpc({
+ model: parent.model,
+ method: 'name_create',
+ args: [values.display_name],
+ context: context,
+ }).then(function (records) {
+ return records[0];
+ });
+ } else {
+ // other fields are specified, perform a classical 'create'
+ def = this._rpc({
+ model: parent.model,
+ method: 'create',
+ args: [values],
+ context: context,
+ });
+ }
+ return def.then(function (resID) {
+ return self.addRecordToGroup(group.id, resID);
+ });
+ },
+ /**
+ * Add the following (kanban specific) keys when performing a `get`:
+ *
+ * - tooltipData
+ * - progressBarValues
+ * - isGroupedByM2ONoColumn
+ *
+ * @override
+ * @see _readTooltipFields
+ * @returns {Object}
+ */
+ __get: function () {
+ var result = this._super.apply(this, arguments);
+ var dp = result && this.localData[result.id];
+ if (dp) {
+ if (dp.tooltipData) {
+ result.tooltipData = $.extend(true, {}, dp.tooltipData);
+ }
+ if (dp.progressBarValues) {
+ result.progressBarValues = $.extend(true, {}, dp.progressBarValues);
+ }
+ if (dp.fields[dp.groupedBy[0]]) {
+ var groupedByM2O = dp.fields[dp.groupedBy[0]].type === 'many2one';
+ result.isGroupedByM2ONoColumn = !dp.data.length && groupedByM2O;
+ } else {
+ result.isGroupedByM2ONoColumn = false;
+ }
+ }
+ return result;
+ },
+ /**
+ * Same as @see get but getting the parent element whose ID is given.
+ *
+ * @param {string} id
+ * @returns {Object}
+ */
+ getColumn: function (id) {
+ var element = this.localData[id];
+ if (element) {
+ return this.get(element.parentID);
+ }
+ return null;
+ },
+ /**
+ * @override
+ */
+ __load: function (params) {
+ this.defaultGroupedBy = params.groupBy || [];
+ params.groupedBy = (params.groupedBy && params.groupedBy.length) ? params.groupedBy : this.defaultGroupedBy;
+ return this._super(params);
+ },
+ /**
+ * Load more records in a group.
+ *
+ * @param {string} groupID localID of the group
+ * @returns {Promise<string>} resolves to the localID of the group
+ */
+ loadMore: function (groupID) {
+ var group = this.localData[groupID];
+ var offset = group.loadMoreOffset + group.limit;
+ return this.reload(group.id, {
+ loadMoreOffset: offset,
+ });
+ },
+ /**
+ * Moves a record from a group to another.
+ *
+ * @param {string} recordID localID of the record
+ * @param {string} groupID localID of the new group of the record
+ * @param {string} parentID localID of the parent
+ * @returns {Promise<string[]>} resolves to a pair [oldGroupID, newGroupID]
+ */
+ moveRecord: function (recordID, groupID, parentID) {
+ var self = this;
+ var parent = this.localData[parentID];
+ var new_group = this.localData[groupID];
+ var changes = {};
+ var groupedFieldName = parent.groupedBy[0];
+ var groupedField = parent.fields[groupedFieldName];
+ if (groupedField.type === 'many2one') {
+ changes[groupedFieldName] = {
+ id: new_group.res_id,
+ display_name: new_group.value,
+ };
+ } else if (groupedField.type === 'selection') {
+ var value = _.findWhere(groupedField.selection, {1: new_group.value});
+ changes[groupedFieldName] = value && value[0] || false;
+ } else {
+ changes[groupedFieldName] = new_group.value;
+ }
+
+ // Manually updates groups data. Note: this is done before the actual
+ // save as it might need to perform a read group in some cases so those
+ // updated data might be overridden again.
+ var record = self.localData[recordID];
+ var resID = record.res_id;
+ // Remove record from its current group
+ var old_group;
+ for (var i = 0; i < parent.data.length; i++) {
+ old_group = self.localData[parent.data[i]];
+ var index = _.indexOf(old_group.data, recordID);
+ if (index >= 0) {
+ old_group.data.splice(index, 1);
+ old_group.count--;
+ old_group.res_ids = _.without(old_group.res_ids, resID);
+ self._updateParentResIDs(old_group);
+ break;
+ }
+ }
+ // Add record to its new group
+ new_group.data.push(recordID);
+ new_group.res_ids.push(resID);
+ new_group.count++;
+
+ return this.notifyChanges(recordID, changes).then(function () {
+ return self.save(recordID);
+ }).then(function () {
+ record.parentID = new_group.id;
+ return [old_group.id, new_group.id];
+ });
+ },
+ /**
+ * @override
+ */
+ reload: function (id, options) {
+ // if the groupBy is given in the options and if it is an empty array,
+ // fallback on the default groupBy
+ if (options && options.groupBy && !options.groupBy.length) {
+ options.groupBy = this.defaultGroupedBy;
+ }
+ return this._super(id, options);
+ },
+ /**
+ * @override
+ */
+ __reload: function (id, options) {
+ var def = this._super(id, options);
+ if (options && options.loadMoreOffset) {
+ return def;
+ }
+ return this._reloadProgressBarGroupFromRecord(id, def);
+ },
+ /**
+ * @override
+ */
+ save: function (recordID) {
+ var def = this._super.apply(this, arguments);
+ return this._reloadProgressBarGroupFromRecord(recordID, def);
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ */
+ _makeDataPoint: function (params) {
+ var dataPoint = this._super.apply(this, arguments);
+ if (params.progressBar) {
+ dataPoint.progressBar = params.progressBar;
+ }
+ return dataPoint;
+ },
+ /**
+ * @override
+ */
+ _load: function (dataPoint, options) {
+ if (dataPoint.groupedBy.length && dataPoint.progressBar) {
+ return this._readProgressBarGroup(dataPoint, options);
+ }
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Ensures that there is no nested groups in Kanban (only the first grouping
+ * level is taken into account).
+ *
+ * @override
+ * @private
+ * @param {Object} list valid resource object
+ */
+ _readGroup: function (list) {
+ var self = this;
+ if (list.groupedBy.length > 1) {
+ list.groupedBy = [list.groupedBy[0]];
+ }
+ return this._super.apply(this, arguments).then(function (result) {
+ return self._readTooltipFields(list).then(_.constant(result));
+ });
+ },
+ /**
+ * @private
+ * @param {Object} dataPoint
+ * @returns {Promise<Object>}
+ */
+ _readProgressBarGroup: function (list, options) {
+ var self = this;
+ var groupsDef = this._readGroup(list, options);
+ var progressBarDef = this._rpc({
+ model: list.model,
+ method: 'read_progress_bar',
+ kwargs: {
+ domain: list.domain,
+ group_by: list.groupedBy[0],
+ progress_bar: list.progressBar,
+ context: list.context,
+ },
+ });
+ return Promise.all([groupsDef, progressBarDef]).then(function (results) {
+ var data = results[1];
+ _.each(list.data, function (groupID) {
+ var group = self.localData[groupID];
+ group.progressBarValues = _.extend({
+ counts: data[group.value] || {},
+ }, list.progressBar);
+ });
+ return list;
+ });
+ },
+ /**
+ * Fetches tooltip specific fields on the group by relation and stores it in
+ * the column datapoint in a special key `tooltipData`.
+ * Data for the tooltips (group_by_tooltip) are fetched in batch for all
+ * groups, to avoid doing multiple calls.
+ * Data are stored in a special key `tooltipData` on the datapoint.
+ * Note that the option `group_by_tooltip` is only for m2o fields.
+ *
+ * @private
+ * @param {Object} list a list of groups
+ * @returns {Promise}
+ */
+ _readTooltipFields: function (list) {
+ var self = this;
+ var groupedByField = list.fields[list.groupedBy[0].split(':')[0]];
+ if (groupedByField.type !== 'many2one') {
+ return Promise.resolve();
+ }
+ var groupIds = _.reduce(list.data, function (groupIds, id) {
+ var res_id = self.get(id, {raw: true}).res_id;
+ // The field on which we are grouping might not be set on all records
+ if (res_id) {
+ groupIds.push(res_id);
+ }
+ return groupIds;
+ }, []);
+ var tooltipFields = [];
+ var groupedByFieldInfo = list.fieldsInfo.kanban[list.groupedBy[0]];
+ if (groupedByFieldInfo && groupedByFieldInfo.options) {
+ tooltipFields = Object.keys(groupedByFieldInfo.options.group_by_tooltip || {});
+ }
+ if (groupIds.length && tooltipFields.length) {
+ var fieldNames = _.union(['display_name'], tooltipFields);
+ return this._rpc({
+ model: groupedByField.relation,
+ method: 'read',
+ args: [groupIds, fieldNames],
+ context: list.context,
+ }).then(function (result) {
+ _.each(list.data, function (id) {
+ var dp = self.localData[id];
+ dp.tooltipData = _.findWhere(result, {id: dp.res_id});
+ });
+ });
+ }
+ return Promise.resolve();
+ },
+ /**
+ * Reloads all progressbar data. This is done after given promise and
+ * insures that the given promise's result is not lost.
+ *
+ * @private
+ * @param {string} recordID
+ * @param {Promise} def
+ * @returns {Promise}
+ */
+ _reloadProgressBarGroupFromRecord: function (recordID, def) {
+ var element = this.localData[recordID];
+ if (element.type === 'list' && !element.parentID) {
+ // we are reloading the whole view, so there is no need to manually
+ // reload the progressbars
+ return def;
+ }
+
+ // If we updated a record, then we must potentially update columns'
+ // progressbars, so we need to load groups info again
+ var self = this;
+ while (element) {
+ if (element.progressBar) {
+ return def.then(function (data) {
+ return self._load(element, {
+ keepEmptyGroups: true,
+ onlyGroups: true,
+ }).then(function () {
+ return data;
+ });
+ });
+ }
+ element = this.localData[element.parentID];
+ }
+ return def;
+ },
+});
+return KanbanModel;
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_record.js b/addons/web/static/src/js/views/kanban/kanban_record.js
new file mode 100644
index 00000000..02dc22e9
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_record.js
@@ -0,0 +1,761 @@
+odoo.define('web.KanbanRecord', function (require) {
+"use strict";
+
+/**
+ * This file defines the KanbanRecord widget, which corresponds to a card in
+ * a Kanban view.
+ */
+var config = require('web.config');
+var core = require('web.core');
+var Domain = require('web.Domain');
+var Dialog = require('web.Dialog');
+var field_utils = require('web.field_utils');
+const FieldWrapper = require('web.FieldWrapper');
+var utils = require('web.utils');
+var Widget = require('web.Widget');
+var widgetRegistry = require('web.widget_registry');
+
+var _t = core._t;
+var QWeb = core.qweb;
+
+var KANBAN_RECORD_COLORS = require('web.basic_fields').FieldColorPicker.prototype.RECORD_COLORS;
+var NB_KANBAN_RECORD_COLORS = KANBAN_RECORD_COLORS.length;
+
+var KanbanRecord = Widget.extend({
+ events: {
+ 'click .oe_kanban_action': '_onKanbanActionClicked',
+ 'click .o_kanban_manage_toggle_button': '_onManageTogglerClicked',
+ },
+ /**
+ * @override
+ */
+ init: function (parent, state, options) {
+ this._super(parent);
+
+ this.fields = state.fields;
+ this.fieldsInfo = state.fieldsInfo.kanban;
+ this.modelName = state.model;
+
+ this.options = options;
+ this.editable = options.editable;
+ this.deletable = options.deletable;
+ this.read_only_mode = options.read_only_mode;
+ this.selectionMode = options.selectionMode;
+ this.qweb = options.qweb;
+ this.subWidgets = {};
+
+ this._setState(state);
+ // avoid quick multiple clicks
+ this._onKanbanActionClicked = _.debounce(this._onKanbanActionClicked, 300, true);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ return Promise.all([this._super.apply(this, arguments), this._render()]);
+ },
+ /**
+ * Called each time the record is attached to the DOM.
+ */
+ on_attach_callback: function () {
+ this.isInDOM = true;
+ _.invoke(this.subWidgets, 'on_attach_callback');
+ },
+ /**
+ * Called each time the record is detached from the DOM.
+ */
+ on_detach_callback: function () {
+ this.isInDOM = false;
+ _.invoke(this.subWidgets, 'on_detach_callback');
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Re-renders the record with a new state
+ *
+ * @param {Object} state
+ * @returns {Promise}
+ */
+ update: function (state) {
+ // detach the widgets because the record will empty its $el, which will
+ // remove all event handlers on its descendants, and we want to keep
+ // those handlers alive as we will re-use these widgets
+ _.invoke(_.pluck(this.subWidgets, '$el'), 'detach');
+ this._setState(state);
+ return this._render();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _attachTooltip: function () {
+ var self = this;
+ this.$('[tooltip]').each(function () {
+ var $el = $(this);
+ var tooltip = $el.attr('tooltip');
+ if (tooltip) {
+ $el.tooltip({
+ title: self.qweb.render(tooltip, self.qweb_context)
+ });
+ }
+ });
+ },
+ /**
+ * @private
+ * @param {string} d a stringified domain
+ * @returns {boolean} the domain evaluted with the current values
+ */
+ _computeDomain: function (d) {
+ return new Domain(d).compute(this.state.evalContext);
+ },
+ /**
+ * Generates the color classname from a given variable
+ *
+ * @private
+ * @param {number | string} variable
+ * @return {string} the classname
+ */
+ _getColorClassname: function (variable) {
+ var color = this._getColorID(variable);
+ return 'oe_kanban_color_' + color;
+ },
+ /**
+ * Computes a color id between 0 and 10 from a given value
+ *
+ * @private
+ * @param {number | string} variable
+ * @returns {integer} the color id
+ */
+ _getColorID: function (variable) {
+ if (typeof variable === 'number') {
+ return Math.round(variable) % NB_KANBAN_RECORD_COLORS;
+ }
+ if (typeof variable === 'string') {
+ var index = 0;
+ for (var i = 0; i < variable.length; i++) {
+ index += variable.charCodeAt(i);
+ }
+ return index % NB_KANBAN_RECORD_COLORS;
+ }
+ return 0;
+ },
+ /**
+ * Computes a color name from value
+ *
+ * @private
+ * @param {number | string} variable
+ * @returns {integer} the color name
+ */
+ _getColorname: function (variable) {
+ var colorID = this._getColorID(variable);
+ return KANBAN_RECORD_COLORS[colorID];
+ },
+ file_type_magic_word: {
+ '/': 'jpg',
+ 'R': 'gif',
+ 'i': 'png',
+ 'P': 'svg+xml',
+ },
+ /**
+ * @private
+ * @param {string} model the name of the model
+ * @param {string} field the name of the field
+ * @param {integer} id the id of the resource
+ * @param {string} placeholder
+ * @returns {string} the url of the image
+ */
+ _getImageURL: function (model, field, id, placeholder) {
+ id = (_.isArray(id) ? id[0] : id) || null;
+ var isCurrentRecord = this.modelName === model && (this.recordData.id === id || (!this.recordData.id && !id));
+ var url;
+ if (isCurrentRecord && this.record[field] && this.record[field].raw_value && !utils.is_bin_size(this.record[field].raw_value)) {
+ // Use magic-word technique for detecting image type
+ url = 'data:image/' + this.file_type_magic_word[this.record[field].raw_value[0]] + ';base64,' + this.record[field].raw_value;
+ } else if (placeholder && (!model || !field || !id || (isCurrentRecord && this.record[field] && !this.record[field].raw_value))) {
+ url = placeholder;
+ } else {
+ var session = this.getSession();
+ var params = {
+ model: model,
+ field: field,
+ id: id
+ };
+ if (isCurrentRecord) {
+ params.unique = this.record.__last_update && this.record.__last_update.value.replace(/[^0-9]/g, '');
+ }
+ url = session.url('/web/image', params);
+ }
+ return url;
+ },
+ /**
+ * Triggers up an event to open the record
+ *
+ * @private
+ */
+ _openRecord: function () {
+ if (this.$el.hasClass('o_currently_dragged')) {
+ // this record is currently being dragged and dropped, so we do not
+ // want to open it.
+ return;
+ }
+ var editMode = this.$el.hasClass('oe_kanban_global_click_edit');
+ this.trigger_up('open_record', {
+ id: this.db_id,
+ mode: editMode ? 'edit' : 'readonly',
+ });
+ },
+ /**
+ * Processes each 'field' tag and replaces it by the specified widget, if
+ * any, or directly by the formatted value
+ *
+ * @private
+ */
+ _processFields: function () {
+ var self = this;
+ this.$("field").each(function () {
+ var $field = $(this);
+ var field_name = $field.attr("name");
+ var field_widget = $field.attr("widget");
+
+ // a widget is specified for that field or a field is a many2many ;
+ // in this latest case, we want to display the widget many2manytags
+ // even if it is not specified in the view.
+ if (field_widget || self.fields[field_name].type === 'many2many') {
+ var widget = self.subWidgets[field_name];
+ if (!widget) {
+ // the widget doesn't exist yet, so instanciate it
+ var Widget = self.fieldsInfo[field_name].Widget;
+ if (Widget) {
+ widget = self._processWidget($field, field_name, Widget);
+ self.subWidgets[field_name] = widget;
+ } else if (config.isDebug()) {
+ // the widget is not implemented
+ $field.replaceWith($('<span>', {
+ text: _.str.sprintf(_t('[No widget %s]'), field_widget),
+ }));
+ }
+ } else {
+ // a widget already exists for that field, so reset it with the new state
+ widget.reset(self.state);
+ $field.replaceWith(widget.$el);
+ if (self.isInDOM && widget.on_attach_callback) {
+ widget.on_attach_callback();
+ }
+ }
+ } else {
+ self._processField($field, field_name);
+ }
+ });
+ },
+ /**
+ * Replace a field by its formatted value.
+ *
+ * @private
+ * @param {JQuery} $field
+ * @param {String} field_name
+ * @returns {Jquery} the modified node
+ */
+ _processField: function ($field, field_name) {
+ // no widget specified for that field, so simply use a formatter
+ // note: we could have used the widget corresponding to the field's type, but
+ // it is much more efficient to use a formatter
+ var field = this.fields[field_name];
+ var value = this.recordData[field_name];
+ var options = { data: this.recordData, forceString: true };
+ var formatted_value = field_utils.format[field.type](value, field, options);
+ var $result = $('<span>', {
+ text: formatted_value,
+ });
+ $field.replaceWith($result);
+ this._setFieldDisplay($result, field_name);
+ return $result;
+ },
+ /**
+ * Replace a field by its corresponding widget.
+ *
+ * @private
+ * @param {JQuery} $field
+ * @param {String} field_name
+ * @param {Class} Widget
+ * @returns {Widget} the widget instance
+ */
+ _processWidget: function ($field, field_name, Widget) {
+ // some field's attrs might be record dependent (they start with
+ // 't-att-') and should thus be evaluated, which is done by qweb
+ // we here replace those attrs in the dict of attrs of the state
+ // by their evaluted value, to make it transparent from the
+ // field's widgets point of view
+ // that dict being shared between records, we don't modify it
+ // in place
+ var self = this;
+ var attrs = Object.create(null);
+ _.each(this.fieldsInfo[field_name], function (value, key) {
+ if (_.str.startsWith(key, 't-att-')) {
+ key = key.slice(6);
+ value = $field.attr(key);
+ }
+ attrs[key] = value;
+ });
+ var options = _.extend({}, this.options, { attrs: attrs });
+ let widget;
+ let def;
+ if (utils.isComponent(Widget)) {
+ widget = new FieldWrapper(this, Widget, {
+ fieldName: field_name,
+ record: this.state,
+ options: options,
+ });
+ def = widget.mount(document.createDocumentFragment())
+ .then(() => {
+ $field.replaceWith(widget.$el);
+ });
+ } else {
+ widget = new Widget(this, field_name, this.state, options);
+ def = widget.replace($field);
+ }
+ this.defs.push(def);
+ def.then(function () {
+ self._setFieldDisplay(widget.$el, field_name);
+ });
+ return widget;
+ },
+ _processWidgets: function () {
+ var self = this;
+ this.$("widget").each(function () {
+ var $field = $(this);
+ var Widget = widgetRegistry.get($field.attr('name'));
+ var widget = new Widget(self, self.state);
+
+ var def = widget._widgetRenderAndInsert(function () { });
+ self.defs.push(def);
+ def.then(function () {
+ $field.replaceWith(widget.$el);
+ widget.$el.addClass('o_widget');
+ });
+ });
+ },
+ /**
+ * Renders the record
+ *
+ * @returns {Promise}
+ */
+ _render: function () {
+ this.defs = [];
+ // call 'on_detach_callback' on each subwidget as they will be removed
+ // from the DOM at the next line
+ _.invoke(this.subWidgets, 'on_detach_callback');
+ this._replaceElement(this.qweb.render('kanban-box', this.qweb_context));
+ this.$el.addClass('o_kanban_record').attr("tabindex", 0);
+ this.$el.attr('role', 'article');
+ this.$el.data('record', this);
+ // forcefully add class oe_kanban_global_click to have clickable record always to select it
+ if (this.selectionMode) {
+ this.$el.addClass('oe_kanban_global_click');
+ }
+ if (this.$el.hasClass('oe_kanban_global_click') ||
+ this.$el.hasClass('oe_kanban_global_click_edit')) {
+ this.$el.on('click', this._onGlobalClick.bind(this));
+ this.$el.on('keydown', this._onKeyDownCard.bind(this));
+ } else {
+ this.$el.on('keydown', this._onKeyDownOpenFirstLink.bind(this));
+ }
+ this._processFields();
+ this._processWidgets();
+ this._setupColor();
+ this._setupColorPicker();
+ this._attachTooltip();
+
+ // We use boostrap tooltips for better and faster display
+ this.$('span.o_tag').tooltip({ delay: { 'show': 50 } });
+
+ return Promise.all(this.defs);
+ },
+ /**
+ * Sets cover image on a kanban card through an attachment dialog.
+ *
+ * @private
+ * @param {string} fieldName field used to set cover image
+ * @param {boolean} autoOpen automatically open the file choser if there are no attachments
+ */
+ _setCoverImage: function (fieldName, autoOpen) {
+ var self = this;
+ this._rpc({
+ model: 'ir.attachment',
+ method: 'search_read',
+ domain: [
+ ['res_model', '=', this.modelName],
+ ['res_id', '=', this.id],
+ ['mimetype', 'ilike', 'image']
+ ],
+ fields: ['id', 'name'],
+ }).then(function (attachmentIds) {
+ self.imageUploadID = _.uniqueId('o_cover_image_upload');
+ self.accepted_file_extensions = 'image/*'; // prevent uploading of other file types
+ self.attachment_count = attachmentIds.length;
+ var coverId = self.record[fieldName] && self.record[fieldName].raw_value;
+ var $content = $(QWeb.render('KanbanView.SetCoverModal', {
+ coverId: coverId,
+ attachmentIds: attachmentIds,
+ widget: self,
+ }));
+ var $imgs = $content.find('.o_kanban_cover_image');
+ var dialog = new Dialog(self, {
+ title: _t("Set a Cover Image"),
+ $content: $content,
+ buttons: [{
+ text: _t("Select"),
+ classes: attachmentIds.length ? 'btn-primary' : 'd-none',
+ close: true,
+ disabled: !coverId,
+ click: function () {
+ var $img = $imgs.filter('.o_selected').find('img');
+ var data = {};
+ data[fieldName] = {
+ id: $img.data('id'),
+ display_name: $img.data('name')
+ };
+ self.trigger_up('kanban_record_update', data);
+ },
+ }, {
+ text: _t('Upload and Set'),
+ classes: attachmentIds.length ? '' : 'btn-primary',
+ close: false,
+ click: function () {
+ $content.find('input.o_input_file').click();
+ },
+ }, {
+ text: _t("Remove Cover Image"),
+ classes: coverId ? '' : 'd-none',
+ close: true,
+ click: function () {
+ var data = {};
+ data[fieldName] = false;
+ self.trigger_up('kanban_record_update', data);
+ },
+ }, {
+ text: _t("Discard"),
+ close: true,
+ }],
+ });
+ dialog.opened().then(function () {
+ var $selectBtn = dialog.$footer.find('.btn-primary');
+ if (autoOpen && !self.attachment_count) {
+ $selectBtn.click();
+ }
+
+ $content.on('click', '.o_kanban_cover_image', function (ev) {
+ $imgs.not(ev.currentTarget).removeClass('o_selected');
+ $selectBtn.prop('disabled', !$(ev.currentTarget).toggleClass('o_selected').hasClass('o_selected'));
+ });
+
+ $content.on('dblclick', '.o_kanban_cover_image', function (ev) {
+ var $img = $(ev.currentTarget).find('img');
+ var data = {};
+ data[fieldName] = {
+ id: $img.data('id'),
+ display_name: $img.data('name')
+ };
+ self.trigger_up('kanban_record_update', data);
+ dialog.close();
+ });
+ $content.on('change', 'input.o_input_file', function () {
+ $content.find('form.o_form_binary_form').submit();
+ });
+ $(window).on(self.imageUploadID, function () {
+ var images = Array.prototype.slice.call(arguments, 1);
+ var data = {};
+ data[fieldName] = {
+ id: images[0].id,
+ display_name: images[0].filename
+ };
+ self.trigger_up('kanban_record_update', data);
+ dialog.close();
+ });
+ });
+ dialog.open();
+ });
+ },
+ /**
+ * Sets particular classnames on a field $el according to the
+ * field's attrs (display or bold attributes)
+ *
+ * @private
+ * @param {JQuery} $el
+ * @param {string} fieldName
+ */
+ _setFieldDisplay: function ($el, fieldName) {
+ // attribute display
+ if (this.fieldsInfo[fieldName].display === 'right') {
+ $el.addClass('float-right');
+ } else if (this.fieldsInfo[fieldName].display === 'full') {
+ $el.addClass('o_text_block');
+ }
+
+ // attribute bold
+ if (this.fieldsInfo[fieldName].bold) {
+ $el.addClass('o_text_bold');
+ }
+ },
+ /**
+ * Sets internal values of the kanban record according to the given state
+ *
+ * @private
+ * @param {Object} recordState
+ */
+ _setState: function (recordState) {
+ this.state = recordState;
+ this.id = recordState.res_id;
+ this.db_id = recordState.id;
+ this.recordData = recordState.data;
+ this.record = this._transformRecord(recordState.data);
+ this.qweb_context = {
+ context: this.state.getContext(),
+ kanban_image: this._getImageURL.bind(this),
+ kanban_color: this._getColorClassname.bind(this),
+ kanban_getcolor: this._getColorID.bind(this),
+ kanban_getcolorname: this._getColorname.bind(this),
+ kanban_compute_domain: this._computeDomain.bind(this),
+ selection_mode: this.selectionMode,
+ read_only_mode: this.read_only_mode,
+ record: this.record,
+ user_context: this.getSession().user_context,
+ widget: this,
+ };
+ },
+ /**
+ * If an attribute `color` is set on the kanban record, adds the
+ * corresponding color classname.
+ *
+ * @private
+ */
+ _setupColor: function () {
+ var color_field = this.$el.attr('color');
+ if (color_field && color_field in this.fields) {
+ var colorHelp = _.str.sprintf(_t("Card color: %s"), this._getColorname(this.recordData[color_field]));
+ var colorClass = this._getColorClassname(this.recordData[color_field]);
+ this.$el.addClass(colorClass);
+ this.$el.prepend('<span title="' + colorHelp + '" aria-label="' + colorHelp + '" role="img" class="oe_kanban_color_help"/>');
+ }
+ },
+ /**
+ * Renders the color picker in the kanban record, and binds the event handler
+ *
+ * @private
+ */
+ _setupColorPicker: function () {
+ var $colorpicker = this.$('ul.oe_kanban_colorpicker');
+ if (!$colorpicker.length) {
+ return;
+ }
+ $colorpicker.html(QWeb.render('KanbanColorPicker', { colors: KANBAN_RECORD_COLORS}));
+ $colorpicker.on('click', 'a', this._onColorChanged.bind(this));
+ },
+ /**
+ * Builds an object containing the formatted record data used in the
+ * template
+ *
+ * @private
+ * @param {Object} recordData
+ * @returns {Object} transformed record data
+ */
+ _transformRecord: function (recordData) {
+ var self = this;
+ var new_record = {};
+ _.each(this.state.getFieldNames(), function (name) {
+ var value = recordData[name];
+ var r = _.clone(self.fields[name] || {});
+
+ if ((r.type === 'date' || r.type === 'datetime') && value) {
+ r.raw_value = value.toDate();
+ } else if (r.type === 'one2many' || r.type === 'many2many') {
+ r.raw_value = value.count ? value.res_ids : [];
+ } else if (r.type === 'many2one') {
+ r.raw_value = value && value.res_id || false;
+ } else {
+ r.raw_value = value;
+ }
+
+ if (r.type) {
+ var formatter = field_utils.format[r.type];
+ r.value = formatter(value, self.fields[name], recordData, self.state);
+ } else {
+ r.value = value;
+ }
+
+ new_record[name] = r;
+ });
+ return new_record;
+ },
+ /**
+ * Notifies the controller that the record has changed
+ *
+ * @private
+ * @param {Object} data the new values
+ */
+ _updateRecord: function (data) {
+ this.trigger_up('kanban_record_update', data);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onColorChanged: function (event) {
+ event.preventDefault();
+ var data = {};
+ var color_field = $(event.delegateTarget).data('field') || 'color';
+ data[color_field] = $(event.currentTarget).data('color');
+ this.trigger_up('kanban_record_update', data);
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onGlobalClick: function (event) {
+ if ($(event.target).parents('.o_dropdown_kanban').length) {
+ return;
+ }
+ var trigger = true;
+ var elem = event.target;
+ var ischild = true;
+ var children = [];
+ while (elem) {
+ var events = $._data(elem, 'events');
+ if (elem === event.currentTarget) {
+ ischild = false;
+ }
+ var test_event = events && events.click && (events.click.length > 1 || events.click[0].namespace !== 'bs.tooltip');
+ var testLinkWithHref = elem.nodeName.toLowerCase() === 'a' && elem.href;
+ if (ischild) {
+ children.push(elem);
+ if (test_event || testLinkWithHref) {
+ // Do not trigger global click if one child has a click
+ // event registered (or it is a link with href)
+ trigger = false;
+ }
+ }
+ if (trigger && test_event) {
+ _.each(events.click, function (click_event) {
+ if (click_event.selector) {
+ // For each parent of original target, check if a
+ // delegated click is bound to any previously found children
+ _.each(children, function (child) {
+ if ($(child).is(click_event.selector)) {
+ trigger = false;
+ }
+ });
+ }
+ });
+ }
+ elem = elem.parentElement;
+ }
+ if (trigger) {
+ this._openRecord();
+ }
+ },
+ /**
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onKanbanActionClicked: function (event) {
+ event.preventDefault();
+
+ var $action = $(event.currentTarget);
+ var type = $action.data('type') || 'button';
+
+ switch (type) {
+ case 'edit':
+ this.trigger_up('open_record', { id: this.db_id, mode: 'edit' });
+ break;
+ case 'open':
+ this.trigger_up('open_record', { id: this.db_id });
+ break;
+ case 'delete':
+ this.trigger_up('kanban_record_delete', { id: this.db_id, record: this });
+ break;
+ case 'action':
+ case 'object':
+ var attrs = $action.data();
+ attrs.confirm = $action.attr('confirm');
+ this.trigger_up('button_clicked', {
+ attrs: attrs,
+ record: this.state,
+ });
+ break;
+ case 'set_cover':
+ var fieldName = $action.data('field');
+ var autoOpen = $action.data('auto-open');
+ if (this.fields[fieldName].type === 'many2one' &&
+ this.fields[fieldName].relation === 'ir.attachment' &&
+ this.fieldsInfo[fieldName].widget === 'attachment_image') {
+ this._setCoverImage(fieldName, autoOpen);
+ } else {
+ var warning = _.str.sprintf(_t('Could not set the cover image: incorrect field ("%s") is provided in the view.'), fieldName);
+ this.do_warn(warning);
+ }
+ break;
+ default:
+ this.do_warn(false, _t("Kanban: no action for type: ") + type);
+ }
+ },
+ /**
+ * This event is linked to the kanban card when there is a global_click
+ * class on this card
+ *
+ * @private
+ * @param {KeyDownEvent} event
+ */
+ _onKeyDownCard: function (event) {
+ switch (event.keyCode) {
+ case $.ui.keyCode.ENTER:
+ if ($(event.target).hasClass('oe_kanban_global_click')) {
+ event.preventDefault();
+ this._onGlobalClick(event);
+ break;
+ }
+ }
+ },
+ /**
+ * This event is linked ot the kanban card when there is no global_click
+ * class on the card
+ *
+ * @private
+ * @param {KeyDownEvent} event
+ */
+ _onKeyDownOpenFirstLink: function (event) {
+ switch (event.keyCode) {
+ case $.ui.keyCode.ENTER:
+ event.preventDefault();
+ $(event.target).find('a, button').first().click();
+ break;
+ }
+ },
+ /**
+ * Toggles the configuration panel of the record
+ *
+ * @private
+ * @param {MouseEvent} event
+ */
+ _onManageTogglerClicked: function (event) {
+ event.preventDefault();
+ this.$el.parent().find('.o_kanban_record').not(this.$el).removeClass('o_dropdown_open');
+ this.$el.toggleClass('o_dropdown_open');
+ var colorClass = this._getColorClassname(this.recordData.color || 0);
+ this.$('.o_kanban_manage_button_section').toggleClass(colorClass);
+ },
+});
+
+return KanbanRecord;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js
new file mode 100644
index 00000000..e7206917
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_record_quick_create.js
@@ -0,0 +1,315 @@
+odoo.define('web.kanban_record_quick_create', function (require) {
+"use strict";
+
+/**
+ * This file defines the RecordQuickCreate widget for Kanban. It allows to
+ * create kanban records directly from the Kanban view.
+ */
+
+var core = require('web.core');
+var QuickCreateFormView = require('web.QuickCreateFormView');
+const session = require('web.session');
+var Widget = require('web.Widget');
+
+var RecordQuickCreate = Widget.extend({
+ className: 'o_kanban_quick_create',
+ custom_events: {
+ add: '_onAdd',
+ cancel: '_onCancel',
+ },
+ events: {
+ 'click .o_kanban_add': '_onAddClicked',
+ 'click .o_kanban_edit': '_onEditClicked',
+ 'click .o_kanban_cancel': '_onCancelClicked',
+ 'mousedown': '_onMouseDown',
+ },
+ mouseDownInside: false,
+
+ /**
+ * @override
+ * @param {Widget} parent
+ * @param {Object} options
+ * @param {Object} options.context
+ * @param {string|null} options.formViewRef
+ * @param {string} options.model
+ */
+ init: function (parent, options) {
+ this._super.apply(this, arguments);
+ this.context = options.context;
+ this.formViewRef = options.formViewRef;
+ this.model = options.model;
+ this._disabled = false; // to prevent from creating multiple records (e.g. on double-clicks)
+ },
+ /**
+ * Loads the form fieldsView (if not provided), instantiates the form view
+ * and starts the form controller.
+ *
+ * @override
+ */
+ willStart: function () {
+ var self = this;
+ var superWillStart = this._super.apply(this, arguments);
+ var viewsLoaded;
+ if (this.formViewRef) {
+ var views = [[false, 'form']];
+ var context = _.extend({}, this.context, {
+ form_view_ref: this.formViewRef,
+ });
+ viewsLoaded = this.loadViews(this.model, context, views);
+ } else {
+ var fieldsView = {};
+ fieldsView.arch = '<form>' +
+ '<field name="display_name" placeholder="Title" modifiers=\'{"required": true}\'/>' +
+ '</form>';
+ var fields = {
+ display_name: {string: 'Display name', type: 'char'},
+ };
+ fieldsView.fields = fields;
+ fieldsView.viewFields = fields;
+ viewsLoaded = Promise.resolve({form: fieldsView});
+ }
+ viewsLoaded = viewsLoaded.then(function (fieldsViews) {
+ var formView = new QuickCreateFormView(fieldsViews.form, {
+ context: self.context,
+ modelName: self.model,
+ userContext: session.user_context,
+ });
+ return formView.getController(self).then(function (controller) {
+ self.controller = controller;
+ return self.controller.appendTo(document.createDocumentFragment());
+ });
+ });
+ return Promise.all([superWillStart, viewsLoaded]);
+ },
+ /**
+ * @override
+ */
+ start: function () {
+ this.$el.append(this.controller.$el);
+ this.controller.renderButtons(this.$el);
+
+ // focus the first field
+ this.controller.autofocus();
+
+ // destroy the quick create when the user clicks outside
+ core.bus.on('click', this, this._onWindowClicked);
+
+ return this._super.apply(this, arguments);
+ },
+ /**
+ * Called when the quick create is appended into the DOM.
+ */
+ on_attach_callback: function () {
+ if (this.controller) {
+ this.controller.autofocus();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Cancels the quick creation if the record isn't dirty, i.e. if no changes
+ * have been made yet
+ *
+ * @private
+ * @returns {Promise}
+ */
+ cancel: function () {
+ var self = this;
+ return this.controller.commitChanges().then(function () {
+ if (!self.controller.isDirty()) {
+ self._cancel();
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} [options]
+ * @param {boolean} [options.openRecord] set to true to directly open the
+ * newly created record in a form view (in edit mode)
+ */
+ _add: function (options) {
+ var self = this;
+ if (this._disabled) {
+ // don't do anything if we are already creating a record
+ return;
+ }
+ // disable the widget to prevent the user from creating multiple records
+ // with the current values ; if the create works, the widget will be
+ // destroyed and another one will be instantiated, so there is no need
+ // to re-enable it in that case
+ this._disableQuickCreate();
+ this.controller.commitChanges().then(function () {
+ var canBeSaved = self.controller.canBeSaved();
+ if (canBeSaved) {
+ self.trigger_up('quick_create_add_record', {
+ openRecord: options && options.openRecord || false,
+ values: self.controller.getChanges(),
+ onFailure: self._enableQuickCreate.bind(self),
+ });
+ } else {
+ self._enableQuickCreate();
+ }
+ }).guardedCatch(this._enableQuickCreate.bind(this));
+ },
+ /**
+ * Notifies the environment that the quick creation must be cancelled
+ *
+ * @private
+ * @returns {Promise}
+ */
+ _cancel: function () {
+ this.trigger_up('cancel_quick_create');
+ },
+ /**
+ * Disable the widget to indicate the user that it can't interact with it.
+ * This function must be called when a record is being created, to prevent
+ * it from being created twice.
+ *
+ * Note that if the record creation works as expected, there is no need to
+ * re-enable the widget as it will be destroyed anyway (and replaced by a
+ * new instance).
+ *
+ * @private
+ */
+ _disableQuickCreate: function () {
+ this._disabled = true; // ensures that the record won't be created twice
+ this.$el.addClass('o_disabled');
+ this.$('input:not(:disabled)')
+ .addClass('o_temporarily_disabled')
+ .attr('disabled', 'disabled');
+ },
+ /**
+ * Re-enable the widget to allow the user to create again.
+ *
+ * @private
+ */
+ _enableQuickCreate: function () {
+ this._disabled = false; // allows to create again
+ this.$el.removeClass('o_disabled');
+ this.$('input.o_temporarily_disabled')
+ .removeClass('o_temporarily_disabled')
+ .attr('disabled', false);
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onAdd: function (ev) {
+ ev.stopPropagation();
+ this._add();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onAddClicked: function (ev) {
+ ev.stopPropagation();
+ this._add();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onCancel: function (ev) {
+ ev.stopPropagation();
+ this._cancel();
+ },
+ /**
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onCancelClicked: function (ev) {
+ ev.stopPropagation();
+ this._cancel();
+ },
+ /**
+ * Validates the quick creation and directly opens the record in a form
+ * view in edit mode.
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onEditClicked: function (ev) {
+ ev.stopPropagation();
+ this._add({openRecord: true});
+ },
+ /**
+ * When a click happens outside the quick create, we want to close the quick
+ * create.
+ *
+ * This is quite tricky, because in some cases a click is performed outside
+ * the quick create, but is still related to it (e.g. click in a dialog
+ * opened from the quick create).
+ *
+ * @param {MouseEvent} ev
+ */
+ _onWindowClicked: function (ev) {
+ var mouseDownInside = this.mouseDownInside;
+
+ this.mouseDownInside = false;
+ // ignore clicks if the quick create is not in the dom
+ if (!document.contains(this.el)) {
+ return;
+ }
+
+ // ignore clicks on elements that open the quick create widget, to
+ // prevent from closing quick create widget that has just been opened
+ if ($(ev.target).closest('.o-kanban-button-new, .o_kanban_quick_add').length) {
+ return;
+ }
+
+ // ignore clicks in autocomplete dropdowns
+ if ($(ev.target).parents('.ui-autocomplete').length) {
+ return;
+ }
+
+ // ignore clicks in modals
+ if ($(ev.target).closest('.modal').length) {
+ return;
+ }
+
+ // ignore clicks while a modal is just about to open
+ if ($(document.body).hasClass('modal-open')) {
+ return;
+ }
+
+ // ignore clicks if target is no longer in dom (e.g., a click on the
+ // 'delete' trash icon of a m2m tag)
+ if (!document.contains(ev.target)) {
+ return;
+ }
+
+ // ignore clicks if target is inside the quick create
+ if (this.el.contains(ev.target) || this.el === ev.target || mouseDownInside) {
+ return;
+ }
+
+ this.cancel();
+ },
+ /**
+ * Detects if the click is originally from the quick create
+ *
+ * @private
+ * @param {MouseEvent} ev
+ */
+ _onMouseDown: function(ev){
+ this.mouseDownInside = true;
+ }
+});
+
+return RecordQuickCreate;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_renderer.js b/addons/web/static/src/js/views/kanban/kanban_renderer.js
new file mode 100644
index 00000000..dfaba0a1
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_renderer.js
@@ -0,0 +1,684 @@
+odoo.define('web.KanbanRenderer', function (require) {
+"use strict";
+
+var BasicRenderer = require('web.BasicRenderer');
+var ColumnQuickCreate = require('web.kanban_column_quick_create');
+var config = require('web.config');
+var core = require('web.core');
+var KanbanColumn = require('web.KanbanColumn');
+var KanbanRecord = require('web.KanbanRecord');
+var QWeb = require('web.QWeb');
+var session = require('web.session');
+var utils = require('web.utils');
+var viewUtils = require('web.viewUtils');
+
+var qweb = core.qweb;
+var _t = core._t;
+
+function findInNode(node, predicate) {
+ if (predicate(node)) {
+ return node;
+ }
+ if (!node.children) {
+ return undefined;
+ }
+ for (var i = 0; i < node.children.length; i++) {
+ if (findInNode(node.children[i], predicate)) {
+ return node.children[i];
+ }
+ }
+}
+
+function qwebAddIf(node, condition) {
+ if (node.attrs[qweb.prefix + '-if']) {
+ condition = _.str.sprintf("(%s) and (%s)", node.attrs[qweb.prefix + '-if'], condition);
+ }
+ node.attrs[qweb.prefix + '-if'] = condition;
+}
+
+function transformQwebTemplate(node, fields) {
+ // Process modifiers
+ if (node.tag && node.attrs.modifiers) {
+ var modifiers = node.attrs.modifiers || {};
+ if (modifiers.invisible) {
+ qwebAddIf(node, _.str.sprintf("!kanban_compute_domain(%s)", JSON.stringify(modifiers.invisible)));
+ }
+ }
+ switch (node.tag) {
+ case 'button':
+ case 'a':
+ var type = node.attrs.type || '';
+ if (_.indexOf('action,object,edit,open,delete,url,set_cover'.split(','), type) !== -1) {
+ _.each(node.attrs, function (v, k) {
+ if (_.indexOf('icon,type,name,args,string,context,states,kanban_states'.split(','), k) !== -1) {
+ node.attrs['data-' + k] = v;
+ delete(node.attrs[k]);
+ }
+ });
+ if (node.attrs['data-string']) {
+ node.attrs.title = node.attrs['data-string'];
+ }
+ if (node.tag === 'a' && node.attrs['data-type'] !== "url") {
+ node.attrs.href = '#';
+ } else {
+ node.attrs.type = 'button';
+ }
+
+ var action_classes = " oe_kanban_action oe_kanban_action_" + node.tag;
+ if (node.attrs['t-attf-class']) {
+ node.attrs['t-attf-class'] += action_classes;
+ } else if (node.attrs['t-att-class']) {
+ node.attrs['t-att-class'] += " + '" + action_classes + "'";
+ } else {
+ node.attrs['class'] = (node.attrs['class'] || '') + action_classes;
+ }
+ }
+ break;
+ }
+ if (node.children) {
+ for (var i = 0, ii = node.children.length; i < ii; i++) {
+ transformQwebTemplate(node.children[i], fields);
+ }
+ }
+}
+
+var KanbanRenderer = BasicRenderer.extend({
+ className: 'o_kanban_view',
+ config: { // the KanbanRecord and KanbanColumn classes to use (may be overridden)
+ KanbanColumn: KanbanColumn,
+ KanbanRecord: KanbanRecord,
+ },
+ custom_events: _.extend({}, BasicRenderer.prototype.custom_events || {}, {
+ close_quick_create: '_onCloseQuickCreate',
+ cancel_quick_create: '_onCancelQuickCreate',
+ set_progress_bar_state: '_onSetProgressBarState',
+ start_quick_create: '_onStartQuickCreate',
+ quick_create_column_updated: '_onQuickCreateColumnUpdated',
+ }),
+ events:_.extend({}, BasicRenderer.prototype.events || {}, {
+ 'keydown .o_kanban_record' : '_onRecordKeyDown'
+ }),
+ sampleDataTargets: [
+ '.o_kanban_counter',
+ '.o_kanban_record',
+ '.o_kanban_toggle_fold',
+ '.o_column_folded',
+ '.o_column_archive_records',
+ '.o_column_unarchive_records',
+ ],
+
+ /**
+ * @override
+ * @param {Object} params
+ * @param {boolean} params.quickCreateEnabled set to false to disable the
+ * quick create feature
+ */
+ init: function (parent, state, params) {
+ this._super.apply(this, arguments);
+
+ this.widgets = [];
+ this.qweb = new QWeb(config.isDebug(), {_s: session.origin}, false);
+ var templates = findInNode(this.arch, function (n) { return n.tag === 'templates';});
+ transformQwebTemplate(templates, state.fields);
+ this.qweb.add_template(utils.json_node_to_xml(templates));
+ this.examples = params.examples;
+ this.recordOptions = _.extend({}, params.record_options, {
+ qweb: this.qweb,
+ viewType: 'kanban',
+ });
+ this.columnOptions = _.extend({KanbanRecord: this.config.KanbanRecord}, params.column_options);
+ if (this.columnOptions.hasProgressBar) {
+ this.columnOptions.progressBarStates = {};
+ }
+ this.quickCreateEnabled = params.quickCreateEnabled;
+ if (!params.readOnlyMode) {
+ var handleField = _.findWhere(this.state.fieldsInfo.kanban, {widget: 'handle'});
+ this.handleField = handleField && handleField.name;
+ }
+ this._setState(state);
+ },
+ /**
+ * Called each time the renderer is attached into the DOM.
+ */
+ on_attach_callback: function () {
+ this._super(...arguments);
+ if (this.quickCreate) {
+ this.quickCreate.on_attach_callback();
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Displays the quick create record in the requested column (first one by
+ * default)
+ *
+ * @params {string} [groupId] local id of the group in which the quick create
+ * must be inserted
+ * @returns {Promise}
+ */
+ addQuickCreate: function (groupId) {
+ let kanbanColumn;
+ if (groupId) {
+ kanbanColumn = this.widgets.find(column => column.db_id === groupId);
+ }
+ kanbanColumn = kanbanColumn || this.widgets[0];
+ return kanbanColumn.addQuickCreate();
+ },
+ /**
+ * Focuses the first kanban record
+ */
+ giveFocus: function () {
+ this.$('.o_kanban_record:first').focus();
+ },
+ /**
+ * Toggle fold/unfold the Column quick create widget
+ */
+ quickCreateToggleFold: function () {
+ this.quickCreate.toggleFold();
+ this._toggleNoContentHelper();
+ },
+ /**
+ * Updates a given column with its new state.
+ *
+ * @param {string} localID the column id
+ * @param {Object} columnState
+ * @param {Object} [options]
+ * @param {Object} [options.state] if set, this represents the new state
+ * @param {boolean} [options.openQuickCreate] if true, directly opens the
+ * QuickCreate widget in the updated column
+ *
+ * @returns {Promise}
+ */
+ updateColumn: function (localID, columnState, options) {
+ var self = this;
+ var KanbanColumn = this.config.KanbanColumn;
+ var newColumn = new KanbanColumn(this, columnState, this.columnOptions, this.recordOptions);
+ var index = _.findIndex(this.widgets, {db_id: localID});
+ var column = this.widgets[index];
+ this.widgets[index] = newColumn;
+ if (options && options.state) {
+ this._setState(options.state);
+ }
+ return newColumn.appendTo(document.createDocumentFragment()).then(function () {
+ var def;
+ if (options && options.openQuickCreate) {
+ def = newColumn.addQuickCreate();
+ }
+ return Promise.resolve(def).then(function () {
+ newColumn.$el.insertAfter(column.$el);
+ self._toggleNoContentHelper();
+ // When a record has been quick created, the new column directly
+ // renders the quick create widget (to allow quick creating several
+ // records in a row). However, as we render this column in a
+ // fragment, the quick create widget can't be correctly focused. So
+ // we manually call on_attach_callback to focus it once in the DOM.
+ newColumn.on_attach_callback();
+ column.destroy();
+ });
+ });
+ },
+ /**
+ * Updates a given record with its new state.
+ *
+ * @param {Object} recordState
+ * @returns {Promise}
+ */
+ updateRecord: function (recordState) {
+ var isGrouped = !!this.state.groupedBy.length;
+ var record;
+
+ if (isGrouped) {
+ // if grouped, this.widgets are kanban columns so we need to find
+ // the kanban record inside
+ _.each(this.widgets, function (widget) {
+ record = record || _.findWhere(widget.records, {
+ db_id: recordState.id,
+ });
+ });
+ } else {
+ record = _.findWhere(this.widgets, {db_id: recordState.id});
+ }
+
+ if (record) {
+ return record.update(recordState);
+ }
+ return Promise.resolve();
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {DOMElement} currentColumn
+ */
+ _focusOnNextCard: function (currentCardElement) {
+ var nextCard = currentCardElement.nextElementSibling;
+ if (nextCard) {
+ nextCard.focus();
+ }
+ },
+ /**
+ * Tries to give focus to the previous card, and returns true if successful
+ *
+ * @private
+ * @param {DOMElement} currentColumn
+ * @returns {boolean}
+ */
+ _focusOnPreviousCard: function (currentCardElement) {
+ var previousCard = currentCardElement.previousElementSibling;
+ if (previousCard && previousCard.classList.contains("o_kanban_record")) { //previous element might be column title
+ previousCard.focus();
+ return true;
+ }
+ },
+ /**
+ * Returns the default columns for the kanban view example background.
+ * You can override this method to easily customize the column names.
+ *
+ * @private
+ */
+ _getGhostColumns: function () {
+ if (this.examples && this.examples.ghostColumns) {
+ return this.examples.ghostColumns;
+ }
+ return _.map(_.range(1, 5), function (num) {
+ return _.str.sprintf(_t("Column %s"), num);
+ });
+ },
+ /**
+ * Render the Example Ghost Kanban card on the background
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderExampleBackground: function (fragment) {
+ var $background = $(qweb.render('KanbanView.ExamplesBackground', {ghostColumns: this._getGhostColumns()}));
+ $background.appendTo(fragment);
+ },
+ /**
+ * Renders empty invisible divs in a document fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ * @param {integer} nbDivs the number of divs to append
+ * @param {Object} [options]
+ * @param {string} [options.inlineStyle]
+ */
+ _renderGhostDivs: function (fragment, nbDivs, options) {
+ var ghostDefs = [];
+ for (var $ghost, i = 0; i < nbDivs; i++) {
+ $ghost = $('<div>').addClass('o_kanban_record o_kanban_ghost');
+ if (options && options.inlineStyle) {
+ $ghost.attr('style', options.inlineStyle);
+ }
+ var def = $ghost.appendTo(fragment);
+ ghostDefs.push(def);
+ }
+ return Promise.all(ghostDefs);
+ },
+ /**
+ * Renders an grouped kanban view in a fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderGrouped: function (fragment) {
+ var self = this;
+
+ // Render columns
+ var KanbanColumn = this.config.KanbanColumn;
+ _.each(this.state.data, function (group) {
+ var column = new KanbanColumn(self, group, self.columnOptions, self.recordOptions);
+ var def;
+ if (!group.value) {
+ def = column.prependTo(fragment); // display the 'Undefined' group first
+ self.widgets.unshift(column);
+ } else {
+ def = column.appendTo(fragment);
+ self.widgets.push(column);
+ }
+ self.defs.push(def);
+ });
+
+ // remove previous sorting
+ if(this.$el.sortable('instance') !== undefined) {
+ this.$el.sortable('destroy');
+ }
+ if (this.groupedByM2O) {
+ // Enable column sorting
+ this.$el.sortable({
+ axis: 'x',
+ items: '> .o_kanban_group',
+ handle: '.o_column_title',
+ cursor: 'move',
+ revert: 150,
+ delay: 100,
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ stop: function () {
+ var ids = [];
+ self.$('.o_kanban_group').each(function (index, u) {
+ // Ignore 'Undefined' column
+ if (_.isNumber($(u).data('id'))) {
+ ids.push($(u).data('id'));
+ }
+ });
+ self.trigger_up('resequence_columns', {ids: ids});
+ },
+ });
+
+ if (this.createColumnEnabled) {
+ this.quickCreate = new ColumnQuickCreate(this, {
+ applyExamplesText: this.examples && this.examples.applyExamplesText,
+ examples: this.examples && this.examples.examples,
+ });
+ this.defs.push(this.quickCreate.appendTo(fragment).then(function () {
+ // Open it directly if there is no column yet
+ if (!self.state.data.length) {
+ self.quickCreate.toggleFold();
+ self._renderExampleBackground(fragment);
+ }
+ }));
+ }
+ }
+ },
+ /**
+ * Renders an ungrouped kanban view in a fragment.
+ *
+ * @private
+ * @param {DocumentFragment} fragment
+ */
+ _renderUngrouped: function (fragment) {
+ var self = this;
+ var KanbanRecord = this.config.KanbanRecord;
+ var kanbanRecord;
+ _.each(this.state.data, function (record) {
+ kanbanRecord = new KanbanRecord(self, record, self.recordOptions);
+ self.widgets.push(kanbanRecord);
+ var def = kanbanRecord.appendTo(fragment);
+ self.defs.push(def);
+ });
+
+ // enable record resequencing if there is a field with widget='handle'
+ // and if there is no orderBy (in this case we assume that the widget
+ // has been put on the first default order field of the model), or if
+ // the first orderBy field is the one with widget='handle'
+ var orderedBy = this.state.orderedBy;
+ var hasHandle = this.handleField &&
+ (orderedBy.length === 0 || orderedBy[0].name === this.handleField);
+ if (hasHandle) {
+ this.$el.sortable({
+ items: '.o_kanban_record:not(.o_kanban_ghost)',
+ cursor: 'move',
+ revert: 0,
+ delay: 0,
+ tolerance: 'pointer',
+ forcePlaceholderSize: true,
+ stop: function (event, ui) {
+ self._moveRecord(ui.item.data('record').db_id, ui.item.index());
+ },
+ });
+ }
+
+ // append ghost divs to ensure that all kanban records are left aligned
+ var prom = Promise.all(self.defs).then(function () {
+ var options = {};
+ if (kanbanRecord) {
+ options.inlineStyle = kanbanRecord.$el.attr('style');
+ }
+ return self._renderGhostDivs(fragment, 6, options);
+ });
+ this.defs.push(prom);
+ },
+ /**
+ * @override
+ * @private
+ */
+ _renderView: function () {
+ var self = this;
+
+ // render the kanban view
+ var isGrouped = !!this.state.groupedBy.length;
+ var fragment = document.createDocumentFragment();
+ var defs = [];
+ this.defs = defs;
+ if (isGrouped) {
+ this._renderGrouped(fragment);
+ } else {
+ this._renderUngrouped(fragment);
+ }
+ delete this.defs;
+
+ return this._super.apply(this, arguments).then(function () {
+ return Promise.all(defs).then(function () {
+ self.$el.empty();
+ self.$el.toggleClass('o_kanban_grouped', isGrouped);
+ self.$el.toggleClass('o_kanban_ungrouped', !isGrouped);
+ self.$el.append(fragment);
+ self._toggleNoContentHelper();
+ });
+ });
+ },
+ /**
+ * @param {boolean} [remove] if true, the nocontent helper is always removed
+ * @private
+ */
+ _toggleNoContentHelper: function (remove) {
+ var displayNoContentHelper =
+ !remove &&
+ !this._hasContent() &&
+ !!this.noContentHelp &&
+ !(this.quickCreate && !this.quickCreate.folded) &&
+ !this.state.isGroupedByM2ONoColumn;
+
+ var $noContentHelper = this.$('.o_view_nocontent');
+
+ if (displayNoContentHelper && !$noContentHelper.length) {
+ this._renderNoContentHelper();
+ }
+ if (!displayNoContentHelper && $noContentHelper.length) {
+ $noContentHelper.remove();
+ }
+ },
+ /**
+ * Sets the current state and updates some internal attributes accordingly.
+ *
+ * @override
+ */
+ _setState: function () {
+ this._super(...arguments);
+
+ var groupByField = this.state.groupedBy[0];
+ var cleanGroupByField = this._cleanGroupByField(groupByField);
+ var groupByFieldAttrs = this.state.fields[cleanGroupByField];
+ var groupByFieldInfo = this.state.fieldsInfo.kanban[cleanGroupByField];
+ // Deactivate the drag'n'drop if the groupedBy field:
+ // - is a date or datetime since we group by month or
+ // - is readonly (on the field attrs or in the view)
+ var draggable = true;
+ var grouped_by_date = false;
+ if (groupByFieldAttrs) {
+ if (groupByFieldAttrs.type === "date" || groupByFieldAttrs.type === "datetime") {
+ draggable = false;
+ grouped_by_date = true;
+ } else if (groupByFieldAttrs.readonly !== undefined) {
+ draggable = !(groupByFieldAttrs.readonly);
+ }
+ }
+ if (groupByFieldInfo) {
+ if (draggable && groupByFieldInfo.readonly !== undefined) {
+ draggable = !(groupByFieldInfo.readonly);
+ }
+ }
+ this.groupedByM2O = groupByFieldAttrs && (groupByFieldAttrs.type === 'many2one');
+ var relation = this.groupedByM2O && groupByFieldAttrs.relation;
+ var groupByTooltip = groupByFieldInfo && groupByFieldInfo.options.group_by_tooltip;
+ this.columnOptions = _.extend(this.columnOptions, {
+ draggable: draggable,
+ group_by_tooltip: groupByTooltip,
+ groupedBy: groupByField,
+ grouped_by_m2o: this.groupedByM2O,
+ grouped_by_date: grouped_by_date,
+ relation: relation,
+ quick_create: this.quickCreateEnabled && viewUtils.isQuickCreateEnabled(this.state),
+ });
+ this.createColumnEnabled = this.groupedByM2O && this.columnOptions.group_creatable;
+ },
+ /**
+ * Remove date/datetime magic grouping info to get proper field attrs/info from state
+ * ex: sent_date:month will become sent_date
+ *
+ * @private
+ * @param {string} groupByField
+ * @returns {string}
+ */
+ _cleanGroupByField: function (groupByField) {
+ var cleanGroupByField = groupByField;
+ if (cleanGroupByField && cleanGroupByField.indexOf(':') > -1) {
+ cleanGroupByField = cleanGroupByField.substring(0, cleanGroupByField.indexOf(':'));
+ }
+
+ return cleanGroupByField;
+ },
+ /**
+ * Moves the focus on the first card of the next column in a given direction
+ * This ignores the folded columns and skips over the empty columns.
+ * In ungrouped kanban, moves the focus to the next/previous card
+ *
+ * @param {DOMElement} eventTarget the target of the keydown event
+ * @param {string} direction contains either 'LEFT' or 'RIGHT'
+ */
+ _focusOnCardInColumn: function(eventTarget, direction) {
+ var currentColumn = eventTarget.parentElement;
+ var hasSelectedACard = false;
+ var cannotSelectAColumn = false;
+ while (!hasSelectedACard && !cannotSelectAColumn) {
+ var candidateColumn = direction === 'LEFT' ?
+ currentColumn.previousElementSibling :
+ currentColumn.nextElementSibling ;
+ currentColumn = candidateColumn;
+ if (candidateColumn) {
+ var allCardsOfCandidateColumn =
+ candidateColumn.getElementsByClassName('o_kanban_record');
+ if (allCardsOfCandidateColumn.length) {
+ allCardsOfCandidateColumn[0].focus();
+ hasSelectedACard = true;
+ }
+ }
+ else { // either there are no more columns in the direction or
+ // this is not a grouped kanban
+ direction === 'LEFT' ?
+ this._focusOnPreviousCard(eventTarget) :
+ this._focusOnNextCard(eventTarget);
+ cannotSelectAColumn = true;
+ }
+ }
+ },
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ */
+ _onCancelQuickCreate: function () {
+ this._toggleNoContentHelper();
+ },
+ /**
+ * Closes the opened quick create widgets in columns
+ *
+ * @private
+ */
+ _onCloseQuickCreate: function () {
+ if (this.state.groupedBy.length) {
+ _.invoke(this.widgets, 'cancelQuickCreate');
+ }
+ this._toggleNoContentHelper();
+ },
+ /**
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onQuickCreateColumnUpdated: function (ev) {
+ ev.stopPropagation();
+ this._toggleNoContentHelper();
+ this._updateExampleBackground();
+ },
+ /**
+ * @private
+ * @param {KeyboardEvent} e
+ */
+ _onRecordKeyDown: function(e) {
+ switch(e.which) {
+ case $.ui.keyCode.DOWN:
+ this._focusOnNextCard(e.currentTarget);
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.UP:
+ const previousFocused = this._focusOnPreviousCard(e.currentTarget);
+ if (!previousFocused) {
+ this.trigger_up('navigation_move', { direction: 'up' });
+ }
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.RIGHT:
+ this._focusOnCardInColumn(e.currentTarget, 'RIGHT');
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ case $.ui.keyCode.LEFT:
+ this._focusOnCardInColumn(e.currentTarget, 'LEFT');
+ e.stopPropagation();
+ e.preventDefault();
+ break;
+ }
+ },
+ /**
+ * Updates progressbar internal states (necessary for animations) with
+ * received data.
+ *
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onSetProgressBarState: function (ev) {
+ if (!this.columnOptions.progressBarStates[ev.data.columnID]) {
+ this.columnOptions.progressBarStates[ev.data.columnID] = {};
+ }
+ _.extend(this.columnOptions.progressBarStates[ev.data.columnID], ev.data.values);
+ },
+ /**
+ * Closes the opened quick create widgets in columns
+ *
+ * @private
+ */
+ _onStartQuickCreate: function () {
+ this._toggleNoContentHelper(true);
+ },
+ /**
+ * Hide or display the background example:
+ * - displayed when quick create column is display and there is no column else
+ * - hidden otherwise
+ *
+ * @private
+ **/
+ _updateExampleBackground: function () {
+ var $elem = this.$('.o_kanban_example_background_container');
+ if (!this.state.data.length && !$elem.length) {
+ this._renderExampleBackground(this.$el);
+ } else {
+ $elem.remove();
+ }
+ },
+});
+
+return KanbanRenderer;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/kanban_view.js b/addons/web/static/src/js/views/kanban/kanban_view.js
new file mode 100644
index 00000000..1add9169
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/kanban_view.js
@@ -0,0 +1,119 @@
+odoo.define('web.KanbanView', function (require) {
+"use strict";
+
+var BasicView = require('web.BasicView');
+var core = require('web.core');
+var KanbanController = require('web.KanbanController');
+var kanbanExamplesRegistry = require('web.kanban_examples_registry');
+var KanbanModel = require('web.KanbanModel');
+var KanbanRenderer = require('web.KanbanRenderer');
+var utils = require('web.utils');
+
+var _lt = core._lt;
+
+var KanbanView = BasicView.extend({
+ accesskey: "k",
+ display_name: _lt("Kanban"),
+ icon: 'fa-th-large',
+ mobile_friendly: true,
+ config: _.extend({}, BasicView.prototype.config, {
+ Model: KanbanModel,
+ Controller: KanbanController,
+ Renderer: KanbanRenderer,
+ }),
+ jsLibs: [],
+ viewType: 'kanban',
+
+ /**
+ * @constructor
+ */
+ init: function (viewInfo, params) {
+ this._super.apply(this, arguments);
+
+ this.loadParams.limit = this.loadParams.limit || 40;
+ this.loadParams.openGroupByDefault = true;
+ this.loadParams.type = 'list';
+ this.noDefaultGroupby = params.noDefaultGroupby;
+ var progressBar;
+ utils.traverse(this.arch, function (n) {
+ var isProgressBar = (n.tag === 'progressbar');
+ if (isProgressBar) {
+ progressBar = _.clone(n.attrs);
+ progressBar.colors = JSON.parse(progressBar.colors);
+ progressBar.sum_field = progressBar.sum_field || false;
+ }
+ return !isProgressBar;
+ });
+ if (progressBar) {
+ this.loadParams.progressBar = progressBar;
+ }
+
+ var activeActions = this.controllerParams.activeActions;
+ var archAttrs = this.arch.attrs;
+ activeActions = _.extend(activeActions, {
+ group_create: this.arch.attrs.group_create ? !!JSON.parse(archAttrs.group_create) : true,
+ group_edit: archAttrs.group_edit ? !!JSON.parse(archAttrs.group_edit) : true,
+ group_delete: archAttrs.group_delete ? !!JSON.parse(archAttrs.group_delete) : true,
+ });
+
+ this.rendererParams.column_options = {
+ editable: activeActions.group_edit,
+ deletable: activeActions.group_delete,
+ archivable: archAttrs.archivable ? !!JSON.parse(archAttrs.archivable) : true,
+ group_creatable: activeActions.group_create,
+ quickCreateView: archAttrs.quick_create_view || null,
+ recordsDraggable: archAttrs.records_draggable ? !!JSON.parse(archAttrs.records_draggable) : true,
+ hasProgressBar: !!progressBar,
+ };
+ this.rendererParams.record_options = {
+ editable: activeActions.edit,
+ deletable: activeActions.delete,
+ read_only_mode: params.readOnlyMode,
+ selectionMode: params.selectionMode,
+ };
+ this.rendererParams.quickCreateEnabled = this._isQuickCreateEnabled();
+ this.rendererParams.readOnlyMode = params.readOnlyMode;
+ var examples = archAttrs.examples;
+ if (examples) {
+ this.rendererParams.examples = kanbanExamplesRegistry.get(examples);
+ }
+
+ this.controllerParams.on_create = archAttrs.on_create;
+ this.controllerParams.hasButtons = !params.selectionMode ? true : false;
+ this.controllerParams.quickCreateEnabled = this.rendererParams.quickCreateEnabled;
+ },
+
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @private
+ * @param {Object} viewInfo
+ * @returns {boolean} true iff the quick create feature is not explicitely
+ * disabled (with create="False" or quick_create="False" in the arch)
+ */
+ _isQuickCreateEnabled: function () {
+ if (!this.controllerParams.activeActions.create) {
+ return false;
+ }
+ if (this.arch.attrs.quick_create !== undefined) {
+ return !!JSON.parse(this.arch.attrs.quick_create);
+ }
+ return true;
+ },
+ /**
+ * @override
+ * @private
+ */
+ _updateMVCParams: function () {
+ this._super.apply(this, arguments);
+ if (this.searchMenuTypes.includes('groupBy') && !this.noDefaultGroupby && this.arch.attrs.default_group_by) {
+ this.loadParams.groupBy = [this.arch.attrs.default_group_by];
+ }
+ },
+});
+
+return KanbanView;
+
+});
diff --git a/addons/web/static/src/js/views/kanban/quick_create_form_view.js b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
new file mode 100644
index 00000000..9286ed82
--- /dev/null
+++ b/addons/web/static/src/js/views/kanban/quick_create_form_view.js
@@ -0,0 +1,123 @@
+odoo.define('web.QuickCreateFormView', function (require) {
+"use strict";
+
+/**
+ * This file defines the QuickCreateFormView, an extension of the FormView that
+ * is used by the RecordQuickCreate in Kanban views.
+ */
+
+var BasicModel = require('web.BasicModel');
+var FormController = require('web.FormController');
+var FormRenderer = require('web.FormRenderer');
+var FormView = require('web.FormView');
+const { qweb } = require("web.core");
+
+var QuickCreateFormRenderer = FormRenderer.extend({
+ /**
+ * @override
+ */
+ start: async function () {
+ await this._super.apply(this, arguments);
+ this.$el.addClass('o_xxs_form_view');
+ this.$el.removeClass('o_xxl_form_view');
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //--------------------------------------------------------------------------
+
+ /**
+ * Override to do nothing so that the renderer won't resize on window resize
+ *
+ * @override
+ */
+ _applyFormSizeClass() {},
+
+ //--------------------------------------------------------------------------
+ // Handlers
+ //--------------------------------------------------------------------------
+
+ /**
+ * @override
+ * @private
+ * @param {OdooEvent} ev
+ */
+ _onNavigationMove: function (ev) {
+ var direction = ev.data.direction;
+ if (direction === 'cancel' || direction === 'next_line') {
+ ev.stopPropagation();
+ this.trigger_up(direction === 'cancel' ? 'cancel' : 'add');
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+});
+
+var QuickCreateFormModel = BasicModel.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {Object} the changes of the given resource (server commands for
+ * x2manys)
+ */
+ getChanges: function (localID) {
+ var record = this.localData[localID];
+ return this._generateChanges(record, {changesOnly: false});
+ },
+});
+
+var QuickCreateFormController = FormController.extend({
+ //--------------------------------------------------------------------------
+ // Public
+ //--------------------------------------------------------------------------
+
+ /**
+ * Asks all field widgets to notify the environment with their current value
+ * (useful for instance for input fields that still have the focus and that
+ * could have not notified the environment of their changes yet).
+ * Synchronizes with the controller's mutex in case there would already be
+ * pending changes being applied.
+ *
+ * @return {Promise}
+ */
+ commitChanges: function () {
+ var mutexDef = this.mutex.getUnlockedDef();
+ return Promise.all([mutexDef, this.renderer.commitChanges(this.handle)]);
+ },
+ /**
+ * @returns {Object} the changes done on the current record
+ */
+ getChanges: function () {
+ return this.model.getChanges(this.handle);
+ },
+
+ /**
+ * @override
+ */
+ renderButtons($node) {
+ this.$buttons = $(qweb.render('KanbanView.RecordQuickCreate.buttons'));
+ if ($node) {
+ this.$buttons.appendTo($node);
+ }
+ },
+
+ /**
+ * @override
+ */
+ updateButtons() {/* No need to update the buttons */},
+});
+
+var QuickCreateFormView = FormView.extend({
+ withControlPanel: false,
+ config: _.extend({}, FormView.prototype.config, {
+ Model: QuickCreateFormModel,
+ Renderer: QuickCreateFormRenderer,
+ Controller: QuickCreateFormController,
+ }),
+});
+
+return QuickCreateFormView;
+
+});