summaryrefslogtreecommitdiff
path: root/addons/web/static/src/js/views/kanban/kanban_renderer.js
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/web/static/src/js/views/kanban/kanban_renderer.js
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/web/static/src/js/views/kanban/kanban_renderer.js')
-rw-r--r--addons/web/static/src/js/views/kanban/kanban_renderer.js684
1 files changed, 684 insertions, 0 deletions
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;
+
+});