diff --git a/addons/website_slides/controllers/main.py b/addons/website_slides/controllers/main.py index ee023857b95..d6bb401c7e2 100644 --- a/addons/website_slides/controllers/main.py +++ b/addons/website_slides/controllers/main.py @@ -549,6 +549,69 @@ class WebsiteSlides(WebsiteProfile): 'can_create': can_create, } + @http.route(['/slides/channel/tag/group/search_read'], type='json', auth='user', methods=['POST'], website=True) + def slide_channel_tag_group_search_read(self, fields, domain): + can_create = request.env['slide.channel.tag.group'].check_access_rights('create', raise_exception=False) + return { + 'read_results': request.env['slide.channel.tag.group'].search_read(domain, fields), + 'can_create': can_create, + } + + @http.route('/slides/channel/tag/add', type='json', auth='user', methods=['POST'], website=True) + def slide_channel_tag_add(self, channel_id, tag_id=None, group_id=None): + """ Adds a slide channel tag to the specified slide channel. + + :param integer channel_id: Channel ID + :param list tag_id: Channel Tag ID as first value of list. If id=0, then this is a new tag to + generate and expects a second list value of the name of the new tag. + :param list group_id: Channel Tag Group ID as first value of list. If id=0, then this is a new + tag group to generate and expects a second list value of the name of the + new tag group. This value is required for when a new tag is being created. + + tag_id and group_id values are provided by a Select2. Default "None" values allow for + graceful failures in exceptional cases when values are not provided. + + :return: channel's course page + """ + + # handle exception during addition of course tag and send error notification to the client + # otherwise client slide create dialog box continue processing even server fail to create a slide + try: + channel = request.env['slide.channel'].browse(int(channel_id)) + can_upload = channel.can_upload + can_publish = channel.can_publish + except (UserError, AccessError) as e: + _logger.error(e) + return {'error': e.name} + else: + if not can_upload or not can_publish: + return {'error': _('You cannot add tags to this course.')} + + if tag_id: + # handle creation of new channel tag + if tag_id[0] == 0: + if group_id: + # handle creation of new channel tag group + if group_id[0] == 0: + tag_group = request.env['slide.channel.tag.group'].create({ + 'name': group_id[1]['name'], + }) + group_id = tag_group.id + # use existing channel tag group + else: + group_id = group_id[0] + else: + return {'error': _('Missing "Tag Group" for creating a new "Tag".')} + + request.env['slide.channel.tag'].create({'name': tag_id[1]['name'], + 'channel_ids': [channel.id], + 'group_id': group_id, + }) + else: + # use existing channel tag + request.env['slide.channel.tag'].browse(tag_id[0]).write({'channel_ids': [(4, channel.id, 0)]}) + return {'url': "/slides/%s" % (slug(channel))} + @http.route(['/slides/channel/subscribe'], type='json', auth='user', website=True) def slide_channel_subscribe(self, channel_id): return request.env['slide.channel'].browse(channel_id).message_subscribe(partner_ids=[request.env.user.partner_id.id]) diff --git a/addons/website_slides/static/src/js/slides_course_tag_add.js b/addons/website_slides/static/src/js/slides_course_tag_add.js new file mode 100644 index 00000000000..dcab7176a93 --- /dev/null +++ b/addons/website_slides/static/src/js/slides_course_tag_add.js @@ -0,0 +1,330 @@ +odoo.define('website_slides.channel_tag.add', function (require) { +'use strict'; + +var core = require('web.core'); +var Dialog = require('web.Dialog'); +var publicWidget = require('web.public.widget'); + +var _t = core._t; + +var TagCourseDialog = Dialog.extend({ + template: 'website.slides.tag.add', + events: _.extend({}, Dialog.prototype.events, { + 'change input#tag_id' : '_onChangeTag', + }), + + /** + * @override + * @param {Object} parent + * @param {Object} options holding channelId + * + */ + init: function (parent, options) { + options = _.defaults(options || {}, { + title: _t("Add a tag"), + size: 'medium', + buttons: [{ + text: _t("Add"), + classes: 'btn-primary', + click: this._onClickFormSubmit.bind(this) + }, { + text: _t("Discard"), + close: true + }] + }); + + this.channelID = parseInt(options.channelId, 10); + this.tagIds = options.channelTagIds || []; + this._super(parent, options); + }, + start: function () { + var self = this; + return this._super.apply(this, arguments).then(function () { + self._bindSelect2Dropdown(); + self._hideTagGroup(); + }); + }, + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + /** + * 'Tag' and 'Tag Group' management for select2 + * + * @private + */ + _bindSelect2Dropdown: function () { + var self = this; + this.$('#tag_id').select2(this._select2Wrapper(_t('Tag'), + function () { + return self._rpc({ + route: '/slides/channel/tag/search_read', + params: { + fields: ['name'], + domain: [['id','not in',self.tagIds]], + } + }); + }) + ); + this.$('#tag_group_id').select2(this._select2Wrapper(_t('Tag Group (required for new tags)'), + function () { + return self._rpc({ + route: '/slides/channel/tag/group/search_read', + params: { + fields: ['name'], + domain: [], + } + }); + }) + ); + }, + + /** + * Wrapper for select2 load data from server at once and store it. + * + * @private + * @param {String} Placeholder for element. + * @param {Function} Function to fetch data from remote location should return a Promise + * resolved data should be array of object with id and name. eg. [{'id': id, 'name': 'text'}, ...] + * @param {String} [nameKey='name'] (optional) the name key of the returned record + * ('name' if not provided) + * @returns {Object} select2 wrapper object + */ + _select2Wrapper: function (tag, fetchFNC, nameKey) { + nameKey = nameKey || 'name'; + + var values = { + width: '100%', + placeholder: tag, + allowClear: true, + formatNoMatches: false, + selection_data: false, + fetch_rpc_fnc: fetchFNC, + formatSelection: function (data) { + if (data.tag) { + data.text = data.tag; + } + return data.text; + }, + createSearchChoice: function (term, data) { + var addedTags = $(this.opts.element).select2('data'); + if (_.filter(_.union(addedTags, data), function (tag) { + return tag.text.toLowerCase().localeCompare(term.toLowerCase()) === 0; + }).length === 0) { + if (this.opts.can_create) { + return { + id: _.uniqueId('tag_'), + create: true, + tag: term, + text: _.str.sprintf(_t("Create new %s '%s'"), tag, term), + }; + } else { + return undefined; + } + } + }, + fill_data: function (query, data) { + var that = this, + tags = {results: []}; + _.each(data, function (obj) { + if (that.matcher(query.term, obj[nameKey])) { + tags.results.push({id: obj.id, text: obj[nameKey]}); + } + }); + query.callback(tags); + }, + query: function (query) { + var that = this; + // fetch data only once and store it + if (!this.selection_data) { + this.fetch_rpc_fnc().then(function (data) { + that.can_create = data.can_create; + that.fill_data(query, data.read_results); + that.selection_data = data.read_results; + }); + } else { + this.fill_data(query, this.selection_data); + } + } + }; + return values; + }, + + /** + * Get value for tag_id and [when appropriate] tag_group_id to send to server + * + * @private + */ + _getSelect2DropdownValues: function () { + var result = {}; + var tag = this.$('#tag_id').select2('data'); + if (tag) { + if (tag.create) { + // new tag + var group = this.$('#tag_group_id').select2('data'); + if(group) { + result['tag_id'] = [0, {'name': tag.text}] + if (group.create) { + // new tag group + result['group_id'] = [0, {'name': group.text}]; + } else { + result['group_id'] = [group.id]; + } + } + } else { + result['tag_id'] = [tag.id]; + } + } + return result; + }, + + /** + * Select2 fields makes the "required" input hidden on the interface. + * Therefore we need to make a method to visually provide this requirement + * feedback to users. "tag group" field should only need this when a new tag + * is created. + * + * @private + */ + _formValidate: function ($form) { + $form.addClass('was-validated'); + var result = $form[0].checkValidity(); + + var $tagInput = this.$('#tag_id'); + if ($tagInput.length !== 0){ + var $tagSelect2Container = $tagInput + .closest('.form-group') + .find('.select2-container'); + $tagSelect2Container.removeClass('is-invalid is-valid'); + if ($tagInput.is(':invalid')) { + $tagSelect2Container.addClass('is-invalid'); + } else if ($tagInput.is(':valid')) { + $tagSelect2Container.addClass('is-valid'); + var $tagGroupInput = this.$('#tag_group_id'); + if ($tagGroupInput.length !== 0){ + var $tagGroupSelect2Container = $tagGroupInput + .closest('.form-group') + .find('.select2-container'); + if ($tagGroupInput.is(':invalid')) { + $tagGroupSelect2Container.addClass('is-invalid'); + } else if ($tagGroupInput.is(':valid')) { + $tagGroupSelect2Container.addClass('is-valid'); + } + } + } + } + return result; + }, + + _alertDisplay: function (message) { + this._alertRemove(); + $('
', { + "class": 'alert alert-warning', + role: 'alert' + }).text(message).insertBefore(this.$('form')); + }, + _alertRemove: function () { + this.$('.alert-warning').remove(); + }, + + /** + * When the user IS NOT creating a new tag, this function hides the group tag field + * and makes it not required. Since the select2 field makes an extra container, this + * needs to be hidden along with the group tag input field and its label. + * + * @private + */ + _hideTagGroup: function () { + var $tag_group_id = this.$('#tag_group_id'); + var $tagGroupSelect2Container = $tag_group_id.closest('.form-group'); + $tagGroupSelect2Container.hide(); + $tag_group_id.removeAttr("required"); + $tag_group_id.select2("val", ""); + }, + + /** + * When the user IS creating a new tag, this function shows the field and + * makes it required. Since the select2 field makes an extra container, this + * needs to be shown along with the group input field and its label. + * + * @private + */ + _showTagGroup: function () { + var $tag_group_id = this.$('#tag_group_id'); + var $tagGroupSelect2Container = $tag_group_id.closest('.form-group'); + $tagGroupSelect2Container.show(); + $tag_group_id.attr("required", "required"); + }, + + //-------------------------------------------------------------------------- + // Handler + //-------------------------------------------------------------------------- + _onClickFormSubmit: function () { + var self = this; + var $form = this.$('#slides_channel_tag_add_form'); + if (this._formValidate($form)) { + var values = this._getSelect2DropdownValues(); + return this._rpc({ + route: '/slides/channel/tag/add', + params: {'channel_id': this.channelID, + 'tag_id': values.tag_id, + 'group_id': values.group_id}, + }).then(function (data) { + if (data.error) { + self._alertDisplay(data.error); + } else { + window.location = data.url; + } + }); + } + }, + + _onChangeTag: function (ev) { + var self = this; + var tag = $(ev.currentTarget).select2('data'); + if (tag && tag.create) { + self._showTagGroup(); + } else { + self._hideTagGroup(); + } + }, +}); + +publicWidget.registry.websiteSlidesTag = publicWidget.Widget.extend({ + selector: '.o_wslides_js_channel_tag_add', + xmlDependencies: ['/website_slides/static/src/xml/website_slides_channel_tag.xml'], + events: { + 'click': '_onAddTagClick', + }, + + + //-------------------------------------------------------------------------- + // Private + //-------------------------------------------------------------------------- + + _openDialog: function ($element) { + var data = $element.data(); + return new TagCourseDialog(this, data).open(); + }, + + //-------------------------------------------------------------------------- + // Handlers + //-------------------------------------------------------------------------- + + /** + * @private + * @param {Event} ev + */ + _onAddTagClick: function (ev) { + ev.preventDefault(); + this._openDialog($(ev.currentTarget)); + }, +}); + +return { + TagCourseDialog: TagCourseDialog, + websiteSlidesTag: publicWidget.registry.websiteSlidesTag +}; + +}); diff --git a/addons/website_slides/static/src/tests/tours/slides_course_publisher.js b/addons/website_slides/static/src/tests/tours/slides_course_publisher.js index 2b9aad17f92..f357a2296c8 100644 --- a/addons/website_slides/static/src/tests/tours/slides_course_publisher.js +++ b/addons/website_slides/static/src/tests/tours/slides_course_publisher.js @@ -75,6 +75,8 @@ tour.register('course_publisher', { run: function () {} // check membership } ].concat( + slidesTourTools.addExistingCourseTag(), + slidesTourTools.addNewCourseTag('The Most Awesome Course'), slidesTourTools.addSection('Introduction'), slidesTourTools.addVideoToSection('Introduction'), [{ diff --git a/addons/website_slides/static/src/tests/tours/slides_tour_tools.js b/addons/website_slides/static/src/tests/tours/slides_tour_tools.js index 2290cd4e982..5b5f525cfeb 100644 --- a/addons/website_slides/static/src/tests/tours/slides_tour_tools.js +++ b/addons/website_slides/static/src/tests/tours/slides_tour_tools.js @@ -73,7 +73,7 @@ var addWebPageToSection = function (sectionName, pageName) { content: 'eLearning: click on tags', trigger: 'ul.select2-choices:first', }, { - content: 'eLearning: select gardener tag', + content: 'eLearning: select Theory tag', trigger: 'div.select2-result-label:contains("Theory")', in_modal: false, }, { @@ -83,10 +83,59 @@ var addWebPageToSection = function (sectionName, pageName) { }]; }; +var addExistingCourseTag = function () { + return [ +{ + content: 'eLearning: click on Add Tag', + trigger: 'a.o_wslides_js_channel_tag_add', +}, { + content: 'eLearning: click on tag dropdown', + trigger: 'a.select2-choice:first', +}, { + content: 'eLearning: select advanced tag', + trigger: 'div.select2-result-label:contains("Advanced")', + in_modal: false, +}, { + content: 'eLearning: add existing course tag', + trigger: 'footer.modal-footer button:contains("Add")' +}]; +}; + +var addNewCourseTag = function (courseTagName) { + return [ +{ + content: 'eLearning: click on Add Tag', + trigger: 'a.o_wslides_js_channel_tag_add', +}, { + content: 'eLearning: click on tag dropdown', + trigger: 'a.select2-choice:first', +}, { + content: 'eLearning: add a new course tag', + trigger: 'a.select2-choice:first', + run: function () { + // directly add new tag since we can assume select2 works correctly + $('#tag_id').select2('data', {id:'123', text: courseTagName, create: true}); + $('#tag_id').trigger('change'); + } +}, { + content: 'eLearning: click on tag group dropdown', + trigger: 'a.select2-choice:last', +}, { + content: 'eLearning: select Tags tag group', + trigger: 'div.select2-result-label:contains("Tags")', + in_modal: false, +}, { + content: 'eLearning: add new course tag', + trigger: 'footer.modal-footer button:contains("Add")' +}]; +}; + return { addSection: addSection, addVideoToSection: addVideoToSection, addWebPageToSection: addWebPageToSection, + addExistingCourseTag: addExistingCourseTag, + addNewCourseTag: addNewCourseTag, }; }); diff --git a/addons/website_slides/static/src/xml/website_slides_channel_tag.xml b/addons/website_slides/static/src/xml/website_slides_channel_tag.xml new file mode 100644 index 00000000000..081032e0a4e --- /dev/null +++ b/addons/website_slides/static/src/xml/website_slides_channel_tag.xml @@ -0,0 +1,20 @@ + + + + + +
+
+
+ + +
+
+ + +
+
+
+
+ +
diff --git a/addons/website_slides/views/assets.xml b/addons/website_slides/views/assets.xml index a078d02a3d3..3d896e8473e 100644 --- a/addons/website_slides/views/assets.xml +++ b/addons/website_slides/views/assets.xml @@ -30,6 +30,7 @@