From 27005ebdbbab7e0b173ffe0b0b9707963579a0cc Mon Sep 17 00:00:00 2001 From: "Tiffany Chang (tic)" Date: Thu, 30 Jan 2020 09:46:31 +0000 Subject: [PATCH] [IMP] website_slides: Add course tags creation to frontend This commit makes it possible for course tags to be added via the frontend. To support this, course tags were also made viewable in a specific course's website since previously these tags were only visible in the courses frontpage (i.e. within the course cards). "Add Tag" is placed after course page's tags. Note that due to the ability to make new tags within this "Add Tag" modal restricts addition to only 1 tag at a time since new tags require a "Tap Group" to be assigned and handling a mix of new/existing tags would get confusing and complicated. Supports Task: 2128166 PR #44365 --- addons/website_slides/controllers/main.py | 63 ++++ .../static/src/js/slides_course_tag_add.js | 330 ++++++++++++++++++ .../tests/tours/slides_course_publisher.js | 2 + .../src/tests/tours/slides_tour_tools.js | 51 ++- .../src/xml/website_slides_channel_tag.xml | 20 ++ addons/website_slides/views/assets.xml | 1 + .../views/website_slides_templates_course.xml | 67 +++- 7 files changed, 518 insertions(+), 16 deletions(-) create mode 100644 addons/website_slides/static/src/js/slides_course_tag_add.js create mode 100644 addons/website_slides/static/src/xml/website_slides_channel_tag.xml 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 @@