diff options
| author | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
|---|---|---|
| committer | stephanchrst <stephanchrst@gmail.com> | 2022-05-10 21:51:50 +0700 |
| commit | 3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch) | |
| tree | a44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/kanban | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/kanban')
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; + +}); |
