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 = $('
').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; });