diff --git a/assets/javascripts/wizard-custom-lib.js b/assets/javascripts/wizard-custom-lib.js new file mode 100644 index 00000000..ec39b956 --- /dev/null +++ b/assets/javascripts/wizard-custom-lib.js @@ -0,0 +1,4 @@ +//= require discourse/lib/autocomplete +//= require discourse/lib/utilities +//= require discourse/lib/offset-calculator +//= require discourse/lib/lock-on diff --git a/assets/javascripts/wizard-custom.js b/assets/javascripts/wizard-custom.js index 651d01e4..c5056c8f 100644 --- a/assets/javascripts/wizard-custom.js +++ b/assets/javascripts/wizard-custom.js @@ -1,12 +1,20 @@ //= require ./wizard/custom-wizard +//= require_tree ./wizard/components //= require_tree ./wizard/controllers //= require_tree ./wizard/helpers //= require_tree ./wizard/initializers +//= require_tree ./wizard/lib //= require_tree ./wizard/models //= require_tree ./wizard/routes //= require_tree ./wizard/templates +//= require discourse/components/user-selector +//= require discourse/components/text-field +//= require discourse/helpers/user-avatar + //= require lodash.js +window.Discourse = {} window.Wizard = {}; Wizard.SiteSettings = {}; +Discourse.__widget_helpers = {}; diff --git a/assets/javascripts/wizard/components/custom-user-selector.js.es6 b/assets/javascripts/wizard/components/custom-user-selector.js.es6 new file mode 100644 index 00000000..c553bd68 --- /dev/null +++ b/assets/javascripts/wizard/components/custom-user-selector.js.es6 @@ -0,0 +1,138 @@ +import { default as computed, observes } from 'ember-addons/ember-computed-decorators'; +import { renderAvatar } from 'discourse/helpers/user-avatar'; +import userSearch from '../lib/user-search'; + +const template = function(params) { + const options = params.options; + let html = "
"; + + if (options.users) { + html += ""; + }; + + html += "
"; + + return new Handlebars.SafeString(html).string; +}; + +export default Ember.TextField.extend({ + attributeBindings: ['autofocus', 'maxLength'], + autocorrect: false, + autocapitalize: false, + name: 'user-selector', + id: "custom-member-selector", + + @computed("placeholderKey") + placeholder(placeholderKey) { + return placeholderKey ? I18n.t(placeholderKey) : ""; + }, + + @observes('usernames') + _update() { + if (this.get('canReceiveUpdates') === 'true') + this.didInsertElement({updateData: true}); + }, + + didInsertElement(opts) { + this._super(); + var self = this, + selected = [], + groups = [], + currentUser = this.currentUser, + includeMentionableGroups = this.get('includeMentionableGroups') === 'true', + includeMessageableGroups = this.get('includeMessageableGroups') === 'true', + includeGroups = this.get('includeGroups') === 'true', + allowedUsers = this.get('allowedUsers') === 'true'; + + function excludedUsernames() { + // hack works around some issues with allowAny eventing + const usernames = self.get('single') ? [] : selected; + + if (currentUser && self.get('excludeCurrentUser')) { + return usernames.concat([currentUser.get('username')]); + } + return usernames; + } + + this.$().val(this.get('usernames')).autocomplete({ + template, + disabled: this.get('disabled'), + single: this.get('single'), + allowAny: this.get('allowAny'), + updateData: (opts && opts.updateData) ? opts.updateData : false, + + dataSource(term) { + const termRegex = /[^a-zA-Z0-9_\-\.@\+]/; + + var results = userSearch({ + term: term.replace(termRegex, ''), + topicId: self.get('topicId'), + exclude: excludedUsernames(), + includeGroups, + allowedUsers, + includeMentionableGroups, + includeMessageableGroups, + group: self.get("group") + }); + + return results; + }, + + transformComplete(v) { + if (v.username || v.name) { + if (!v.username) { groups.push(v.name); } + return v.username || v.name; + } else { + var excludes = excludedUsernames(); + return v.usernames.filter(function(item){ + return excludes.indexOf(item) === -1; + }); + } + }, + + onChangeItems(items) { + var hasGroups = false; + items = items.map(function(i) { + if (groups.indexOf(i) > -1) { hasGroups = true; } + return i.username ? i.username : i; + }); + self.set('usernames', items.join(",")); + self.set('hasGroups', hasGroups); + + selected = items; + if (self.get('onChangeCallback')) self.sendAction('onChangeCallback'); + }, + + reverseTransform(i) { + return { username: i }; + } + + }); + }, + + willDestroyElement() { + this._super(); + this.$().autocomplete('destroy'); + }, + + // THIS IS A HUGE HACK TO SUPPORT CLEARING THE INPUT + @observes('usernames') + _clearInput: function() { + if (arguments.length > 1) { + if (Em.isEmpty(this.get("usernames"))) { + this.$().parent().find("a").click(); + } + } + } + +}); diff --git a/assets/javascripts/wizard/initializers/custom.js.es6 b/assets/javascripts/wizard/initializers/custom.js.es6 index 5c381ca4..0eb864e8 100644 --- a/assets/javascripts/wizard/initializers/custom.js.es6 +++ b/assets/javascripts/wizard/initializers/custom.js.es6 @@ -11,6 +11,12 @@ export default { const WizardStep = requirejs('wizard/components/wizard-step').default; const getUrl = requirejs('discourse-common/lib/get-url').default; const FieldModel = requirejs('wizard/models/wizard-field').default; + const autocomplete = requirejs('discourse/lib/autocomplete').default; + + $.fn.autocomplete = autocomplete; + + // this is for discourse/lib/utilities.avatarImg; + Discourse.getURLWithCDN = getUrl; Router.reopen({ rootURL: getUrl('/w/') diff --git a/assets/javascripts/wizard/lib/user-search.js.es6 b/assets/javascripts/wizard/lib/user-search.js.es6 new file mode 100644 index 00000000..6b242fd4 --- /dev/null +++ b/assets/javascripts/wizard/lib/user-search.js.es6 @@ -0,0 +1,134 @@ +import { CANCELLED_STATUS } from 'discourse/lib/autocomplete'; +import getUrl from 'discourse-common/lib/get-url'; + +var cache = {}, + cacheTopicId, + cacheTime, + currentTerm, + oldSearch; + +function performSearch(term, topicId, includeGroups, includeMentionableGroups, includeMessageableGroups, allowedUsers, group, resultsFn) { + var cached = cache[term]; + if (cached) { + resultsFn(cached); + return; + } + + // need to be able to cancel this + oldSearch = $.ajax(getUrl('/u/search/users'), { + data: { term: term, + topic_id: topicId, + include_groups: includeGroups, + include_mentionable_groups: includeMentionableGroups, + include_messageable_groups: includeMessageableGroups, + group: group, + topic_allowed_users: allowedUsers } + }); + + var returnVal = CANCELLED_STATUS; + + oldSearch.then(function (r) { + cache[term] = r; + cacheTime = new Date(); + // If there is a newer search term, return null + if (term === currentTerm) { returnVal = r; } + + }).always(function(){ + oldSearch = null; + resultsFn(returnVal); + }); +} + +var debouncedSearch = _.debounce(performSearch, 300); + +function organizeResults(r, options) { + if (r === CANCELLED_STATUS) { return r; } + + var exclude = options.exclude || [], + limit = options.limit || 5, + users = [], + emails = [], + groups = [], + results = []; + + if (r.users) { + r.users.every(function(u) { + if (exclude.indexOf(u.username) === -1) { + users.push(u); + results.push(u); + } + return results.length <= limit; + }); + } + + if (options.term.match(/@/)) { + let e = { username: options.term }; + emails = [ e ]; + results.push(e); + } + + if (r.groups) { + r.groups.every(function(g) { + if (results.length > limit && options.term.toLowerCase() !== g.name.toLowerCase()) return false; + if (exclude.indexOf(g.name) === -1) { + groups.push(g); + results.push(g); + } + return true; + }); + } + + results.users = users; + results.emails = emails; + results.groups = groups; + return results; +} + + +export default function userSearch(options) { + var term = options.term || "", + includeGroups = options.includeGroups, + includeMentionableGroups = options.includeMentionableGroups, + includeMessageableGroups = options.includeMessageableGroups, + allowedUsers = options.allowedUsers, + topicId = options.topicId, + group = options.group; + + + if (oldSearch) { + oldSearch.abort(); + oldSearch = null; + } + + currentTerm = term; + + return new Ember.RSVP.Promise(function(resolve) { + // TODO site setting for allowed regex in username + if (term.match(/[^\w_\-\.@\+]/)) { + resolve([]); + return; + } + if (((new Date() - cacheTime) > 30000) || (cacheTopicId !== topicId)) { + cache = {}; + } + + cacheTopicId = topicId; + + var clearPromise = setTimeout(function(){ + resolve(CANCELLED_STATUS); + }, 5000); + + debouncedSearch(term, + topicId, + includeGroups, + includeMentionableGroups, + includeMessageableGroups, + allowedUsers, + group, + function(r) { + clearTimeout(clearPromise); + resolve(organizeResults(r, options)); + }); + + }); +} diff --git a/assets/javascripts/wizard/templates/components/wizard-field-checkbox.hbs b/assets/javascripts/wizard/templates/components/wizard-field-checkbox.hbs new file mode 100644 index 00000000..dce57902 --- /dev/null +++ b/assets/javascripts/wizard/templates/components/wizard-field-checkbox.hbs @@ -0,0 +1 @@ +{{input type='checkbox' checked=field.value}} diff --git a/assets/javascripts/wizard/templates/components/wizard-field-user-selector.hbs b/assets/javascripts/wizard/templates/components/wizard-field-user-selector.hbs new file mode 100644 index 00000000..0881bede --- /dev/null +++ b/assets/javascripts/wizard/templates/components/wizard-field-user-selector.hbs @@ -0,0 +1 @@ +{{custom-user-selector usernames=field.value placeholderKey=field.placeholder}} diff --git a/assets/stylesheets/wizard/wizard_custom.scss b/assets/stylesheets/wizard/wizard_custom.scss index 074a7ada..e53bb496 100644 --- a/assets/stylesheets/wizard/wizard_custom.scss +++ b/assets/stylesheets/wizard/wizard_custom.scss @@ -221,6 +221,146 @@ } } +.user-selector-field.wizard-field { + div.ac-wrap div.item a.remove, .remove-link { + margin-left: 4px; + font-size: 11px; + line-height: 10px; + padding: 1.5px 1.5px 1.5px 2.5px; + border-radius: 12px; + width: 10px; + display: inline-block; + border: 1px solid #e9e9e9; + + &:hover { + background-color: #f2ab9a; + border: 1px solid #ec8972; + text-decoration: none; + color: #e45735; + } + } + + div.ac-wrap { + width: 98.5% !important; + overflow: auto; + max-height: 150px; + background-color: white; + border: 1px solid #e9e9e9; + padding: 5px 4px 1px 4px; + + div.item { + float: left; + margin-bottom: 4px; + margin-right: 10px; + + span { + height: 24px; + display: inline-block; + line-height: 20px; + } + } + + .ac-collapsed-button { + float: left; + border-radius: 20px; + position: relative; + top: -2px; + margin-right: 10px; + } + + input[type="text"] { + float: left; + margin-bottom: 4px; + height: 24px; + display: block; + border: 0; + padding: 0; + box-shadow: none; + } + } +} + +img.avatar { + border-radius: 50%; + vertical-align: middle; +} + +.autocomplete { + z-index: 999999; + position: absolute; + width: 240px; + background-color: white; + border: 1px solid #e9e9e9; + + ul { + list-style: none; + padding: 0; + margin: 0; + + li { + .d-users { + color: #333; + padding: 0 2px; + } + + border-bottom: 1px solid #e9e9e9; + + a { + padding: 5px; + display: block; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + font-size: 14px; + text-decoration: none; + + img { + margin-right: 5px; + } + + span.username { + color: #000; + vertical-align: middle; + } + + span.name { + font-size: 11px; + vertical-align: middle; + } + + &.selected { + background-color: #d1f0ff; + } + + &:hover { + background-color: #ffffa6; + text-decoration: none; + } + } + } + } +} + +.checkbox-field { + display: inline-block; + width: 100%; + + &> label { + float: left; + } + + &> .input-area { + float: right; + margin: 0 20px !important; + padding: 10px 0; + + input { + cursor: pointer; + transform: scale(1.3); + } + } +} + @keyframes rotate-forever { 0% { transform: rotate(0deg); diff --git a/lib/field.rb b/lib/field.rb index 606e88b2..385e9f76 100644 --- a/lib/field.rb +++ b/lib/field.rb @@ -1,6 +1,6 @@ class CustomWizard::Field def self.types - @types ||= ['text', 'textarea', 'dropdown', 'image', 'radio'] + @types ||= ['text', 'textarea', 'dropdown', 'image', 'checkbox', 'user-selector'] end def self.require_assets diff --git a/views/layouts/wizard.html.erb b/views/layouts/wizard.html.erb index e46d157c..e153196b 100644 --- a/views/layouts/wizard.html.erb +++ b/views/layouts/wizard.html.erb @@ -5,8 +5,10 @@ <%= preload_script "ember_jquery" %> <%= preload_script "wizard-vendor" %> <%= preload_script "wizard-application" %> + <%= preload_script "wizard-custom-lib" %> <%= preload_script "wizard-custom" %> <%= preload_script "wizard-plugin" %> + <%= preload_script "pretty-text-bundle" %> <%= preload_script "locales/#{I18n.locale}" %> <%= render partial: "common/special_font_face" %>