Merge pull request #4264 from tgxworld/poll_ui_builder

Poll UI Builder.
This commit is contained in:
Guo Xiang Tan 2016-06-15 11:15:52 +08:00 committed by GitHub
commit ba87181506
17 changed files with 586 additions and 14 deletions

View File

@ -45,6 +45,7 @@ before_install:
- eslint app/assets/javascripts
- eslint --ext .es6 app/assets/javascripts
- eslint --ext .es6 test/javascripts
- eslint --ext .es6 plugins/**/assets/javascripts
- eslint test/javascripts
before_script:

View File

@ -362,7 +362,7 @@ export default Ember.Component.extend({
this._resetUpload(true);
},
showOptions() {
showOptions(toolbarEvent) {
// long term we want some smart positioning algorithm in popup-menu
// the problem is that positioning in a fixed panel is a nightmare
// cause offsetParent can end up returning a fixed element and then
@ -388,9 +388,8 @@ export default Ember.Component.extend({
left = replyWidth - popupWidth - 40;
}
this.sendAction('showOptions', { position: "absolute",
left: left,
top: top });
this.sendAction('showOptions', toolbarEvent,
{ position: "absolute", left, top });
},
showUploadModal(toolbarEvent) {
@ -420,7 +419,7 @@ export default Ember.Component.extend({
sendAction: 'showUploadModal'
});
if (this.get('canWhisper')) {
if (this.get("popupMenuOptions").some(option => option.condition)) {
toolbar.addButton({
id: 'options',
group: 'extras',

View File

@ -42,6 +42,12 @@ function loadDraft(store, opts) {
}
}
const _popupMenuOptionsCallbacks = [];
export function addPopupMenuOptionsCallback(callback) {
_popupMenuOptionsCallbacks.push(callback);
}
export default Ember.Controller.extend({
needs: ['modal', 'topic', 'application'],
replyAsNewTopicDraft: Em.computed.equal('model.draftKey', Composer.REPLY_AS_NEW_TOPIC_KEY),
@ -56,6 +62,19 @@ export default Ember.Controller.extend({
topic: null,
linkLookup: null,
init() {
this._super();
addPopupMenuOptionsCallback(function() {
return {
action: 'toggleWhisper',
icon: 'eye-slash',
label: 'composer.toggle_whisper',
condition: "canWhisper"
};
});
},
showToolbar: Em.computed({
get(){
const keyValueStore = this.container.lookup('key-value-store:main');
@ -92,6 +111,23 @@ export default Ember.Controller.extend({
return currentUser && currentUser.get('staff') && this.siteSettings.enable_whispers && action === Composer.REPLY;
},
@computed("model.composeState")
popupMenuOptions(composeState) {
if (composeState === 'open') {
return _popupMenuOptionsCallbacks.map(callback => {
let option = callback();
if (option.condition) {
option.condition = this.get(option.condition);
} else {
option.condition = true;
}
return option;
});
}
},
showWarning: function() {
if (!Discourse.User.currentProp('staff')) { return false; }
@ -154,7 +190,8 @@ export default Ember.Controller.extend({
this.toggleProperty('showToolbar');
},
showOptions(loc) {
showOptions(toolbarEvent, loc) {
this.set('toolbarEvent', toolbarEvent);
this.appEvents.trigger('popup-menu:open', loc);
this.set('optionsVisible', true);
},

View File

@ -10,6 +10,7 @@ import { onPageChange } from 'discourse/lib/page-tracker';
import { preventCloak } from 'discourse/widgets/post-stream';
import { h } from 'virtual-dom';
import { addFlagProperty } from 'discourse/components/site-header';
import { addPopupMenuOptionsCallback } from 'discourse/controllers/composer';
class PluginApi {
constructor(version, container) {
@ -224,6 +225,26 @@ class PluginApi {
addToolbarCallback(callback);
}
/**
* Add a new button in the options popup menu.
*
* Example:
*
* ```
* api.addToolbarPopupMenuOptionsCallback(function(controller) {
* return {
* action: 'toggleWhisper',
* icon: 'eye-slash',
* label: 'composer.toggle_whisper',
* condition: "canWhisper"
* };
* });
* ```
**/
addToolbarPopupMenuOptionsCallback(callback) {
addPopupMenuOptionsCallback(callback);
}
/**
* A hook that is called when the post stream is removed from the DOM.
* This advanced hook should be used if you end up wiring up any

View File

@ -3,9 +3,13 @@
{{#if currentUser.staff}}
{{#popup-menu visible=optionsVisible hide="hideOptions" title="composer.options"}}
<li>
{{d-button action="toggleWhisper" icon="eye-slash" label="composer.toggle_whisper"}}
</li>
{{#each popupMenuOptions as |option|}}
{{#if option.condition}}
<li>
{{d-button action=option.action icon=option.icon label=option.label}}
</li>
{{/if}}
{{/each}}
{{/popup-menu}}
{{/if}}
@ -86,6 +90,7 @@
composer=model
lastValidatedAt=lastValidatedAt
canWhisper=canWhisper
popupMenuOptions=popupMenuOptions
draftStatus=model.draftStatus
isUploading=isUploading
groupsMentioned="groupsMentioned"

View File

@ -1,5 +1,4 @@
import computed from 'ember-addons/ember-computed-decorators';
import User from 'discourse/models/user';
import PollVoters from 'discourse/plugins/poll/components/poll-voters';
export default PollVoters.extend({

View File

@ -1,5 +1,4 @@
import computed from 'ember-addons/ember-computed-decorators';
import User from 'discourse/models/user';
import PollVoters from 'discourse/plugins/poll/components/poll-voters';
export default PollVoters.extend({

View File

@ -0,0 +1,152 @@
import { default as computed, observes } from 'ember-addons/ember-computed-decorators';
export default Ember.Controller.extend({
needs: ['modal'],
init() {
this._super();
this._setupPoll();
},
@computed
pollTypes() {
return [I18n.t("poll.ui_builder.poll_type.number"), I18n.t("poll.ui_builder.poll_type.multiple")].map(type => {
return { name: type, value: type };
});
},
@computed("pollType", "pollOptionsCount")
isMultiple(pollType, count) {
return (pollType === I18n.t("poll.ui_builder.poll_type.multiple")) && count > 0;
},
@computed("pollType")
isNumber(pollType) {
return pollType === I18n.t("poll.ui_builder.poll_type.number");
},
@computed("isNumber", "isMultiple")
showMinMax(isNumber, isMultiple) {
return isNumber || isMultiple;
},
@computed("pollOptions")
pollOptionsCount(pollOptions) {
if (pollOptions.length === 0) return 0;
let length = 0;
pollOptions.split("\n").forEach(option => {
if (option.length !== 0) length += 1;
});
return length;
},
@observes("isMultiple", "isNumber", "pollOptionsCount")
_setPollMax() {
const isMultiple = this.get("isMultiple");
const isNumber = this.get("isNumber");
if (!isMultiple && !isNumber) return;
if (isMultiple) {
this.set("pollMax", this.get("pollOptionsCount"));
} else if (isNumber) {
this.set("pollMax", this.siteSettings.poll_maximum_options);
}
},
@computed("isMultiple", "isNumber", "pollOptionsCount")
pollMinOptions(isMultiple, isNumber, count) {
if (!isMultiple && !isNumber) return;
if (isMultiple) {
return this._comboboxOptions(1, count + 1);
} else if (isNumber) {
return this._comboboxOptions(1, this.siteSettings.poll_maximum_options + 1);
}
},
@computed("isMultiple", "isNumber", "pollOptionsCount", "pollMin", "pollStep")
pollMaxOptions(isMultiple, isNumber, count, pollMin, pollStep) {
if (!isMultiple && !isNumber) return;
const pollMinInt = parseInt(pollMin);
if (isMultiple) {
return this._comboboxOptions(pollMinInt + 1, count + 1);
} else if (isNumber) {
const pollStepInt = parseInt(pollStep);
return this._comboboxOptions(pollMinInt + 1, pollMinInt + (this.siteSettings.poll_maximum_options * pollStepInt));
}
},
@computed("isNumber", "pollMax")
pollStepOptions(isNumber, pollMax) {
if (!isNumber) return;
return this._comboboxOptions(1, parseInt(pollMax) + 1);
},
@computed("isNumber", "showMinMax", "pollName", "pollType", "publicPoll", "pollOptions", "pollMin", "pollMax", "pollStep")
pollOutput(isNumber, showMinMax, pollName, pollType, publicPoll, pollOptions, pollMin, pollMax, pollStep) {
let pollHeader = '[poll';
let output = '';
if (pollName) pollHeader += ` name=${pollName.replace(' ', '-')}`;
if (pollType) pollHeader += ` type=${pollType}`;
if (pollMin && showMinMax) pollHeader += ` min=${pollMin}`;
if (pollMax) pollHeader += ` max=${pollMax}`;
if (isNumber) pollHeader += ` step=${pollStep}`;
if (publicPoll) pollHeader += ' public=true';
pollHeader += ']';
output += `${pollHeader}\n`;
if (pollOptions.length > 0 && !isNumber) {
output += `${pollOptions.split("\n").map(option => `* ${option}`).join("\n")}\n`;
}
output += '[/poll]';
return output;
},
@computed("pollOptionsCount", "isNumber")
disableInsert(count, isNumber) {
return isNumber ? false : (count < 2);
},
@computed("disableInsert")
minNumOfOptionsValidation(disableInsert) {
let options = { ok: true };
if (disableInsert) {
options = { failed: true, reason: I18n.t("poll.ui_builder.help.options_count") };
}
return Discourse.InputValidation.create(options);
},
_comboboxOptions(start_index, end_index) {
return _.range(start_index, end_index).map(number => {
return { value: number, name: number };
});
},
_setupPoll() {
this.setProperties({
pollName: '',
pollNamePlaceholder: I18n.t("poll.ui_builder.poll_name.placeholder"),
pollType: null,
publicPoll: false,
pollOptions: '',
pollMin: 1,
pollMax: null,
pollStep: 1
});
},
actions: {
insertPoll() {
this.get("toolbarEvent").addText(this.get("pollOutput"));
this.send("closeModal");
}
}
});

View File

@ -147,7 +147,6 @@ export default Ember.Controller.extend({
}).then(results => {
const poll = results.poll;
const votes = results.vote;
const currentUser = this.currentUser;
this.setProperties({ vote: votes, showResults: true });
this.set("model", Em.Object.create(poll));

View File

@ -0,0 +1,57 @@
<div class="modal-body poll-ui-builder">
<form class="poll-ui-builder-form form-horizontal">
<div class="input-group">
<label>{{i18n 'poll.ui_builder.poll_name.label'}}</label>
{{input name="poll-name" value=pollName placeholder=pollNamePlaceholder}}
</div>
<div class="input-group">
<label>{{i18n 'poll.ui_builder.poll_type.label'}}</label>
{{combo-box content=pollTypes
value=pollType
valueAttribute="value"
none="poll.ui_builder.poll_type.regular"}}
{{#if showMinMax}}
<label>{{i18n 'poll.ui_builder.poll_config.min'}}</label>
{{combo-box content=pollMinOptions
value=pollMin
valueAttribute="value"
class="poll-options-min"}}
<label>{{i18n 'poll.ui_builder.poll_config.max'}}</label>
{{combo-box content=pollMaxOptions
value=pollMax
valueAttribute="value"
class="poll-options-max"}}
{{#if isNumber}}
<label>{{i18n 'poll.ui_builder.poll_config.step'}}</label>
{{combo-box content=pollStepOptions
value=pollStep
valueAttribute="value"
class="poll-options-step"}}
{{/if}}
{{/if}}
</div>
<div class="input-group">
<label for="poll-public">
{{input type='checkbox' checked=publicPoll}}
{{i18n "poll.ui_builder.poll_public.label"}}
</label>
</div>
{{#unless isNumber}}
<div class="input-group">
<label>{{i18n 'poll.ui_builder.poll_options.label'}}</label>
{{input-tip validation=minNumOfOptionsValidation}}
{{d-editor value=pollOptions}}
</div>
{{/unless}}
</form>
</div>
<div class="modal-footer">
{{d-button action="insertPoll" class='btn-primary' label='poll.ui_builder.insert' disabled=disableInsert}}
</div>

View File

@ -0,0 +1,30 @@
import { withPluginApi } from 'discourse/lib/plugin-api';
import showModal from 'discourse/lib/show-modal';
function initializePollUIBuilder(api) {
const ComposerController = api.container.lookup("controller:composer");
ComposerController.reopen({
actions: {
showPollBuilder() {
showModal("poll-ui-builder").set("toolbarEvent", this.get("toolbarEvent"));
}
}
});
api.addToolbarPopupMenuOptionsCallback(function() {
return {
action: 'showPollBuilder',
icon: 'bar-chart-o',
label: 'poll.ui_builder.title'
};
});
}
export default {
name: "add-poll-ui-builder",
initialize() {
withPluginApi('0.1', initializePollUIBuilder);
}
};

View File

@ -0,0 +1,8 @@
import ModalBodyView from "discourse/views/modal-body";
export default ModalBodyView.extend({
needs: ['modal'],
templateName: 'modals/poll-ui-builder',
title: I18n.t("poll.ui_builder.title")
});

View File

@ -1,5 +1,3 @@
import { on } from "ember-addons/ember-computed-decorators";
export default Em.View.extend({
templateName: "poll",
classNames: ["poll"],

View File

@ -0,0 +1,18 @@
.poll-ui-builder-form {
.input-group {
padding: 10px;
}
label {
font-weight: bold;
display: inline;
}
.combobox {
margin-right: 5px;
}
.poll-options-min, .poll-options-max, .poll-options-step {
width: 70px !important;
}
}

View File

@ -68,3 +68,26 @@ en:
error_while_toggling_status: "There was an error while toggling the status of this poll."
error_while_casting_votes: "There was an error while casting your votes."
error_while_fetching_voters: "There was an error while displaying the voters."
ui_builder:
title: Poll Builder
insert: Insert Poll
reset: Reset Poll
help:
options_count: You must provide a minimum of 2 options.
poll_name:
label: Poll Name
placeholder: Enter Poll Name
poll_type:
label: Poll Type
regular: regular
multiple: multiple
number: number
poll_config:
max: Max
min: Min
step: Step
poll_public:
label: Make Public Poll
poll_options:
label: "Poll Choices: (one option per line)"

View File

@ -7,6 +7,7 @@
enabled_site_setting :poll_enabled
register_asset "stylesheets/common/poll.scss"
register_asset "stylesheets/common/poll-ui-builder.scss"
register_asset "stylesheets/desktop/poll.scss", :desktop
register_asset "stylesheets/mobile/poll.scss", :mobile

View File

@ -0,0 +1,225 @@
moduleFor("controller:poll-ui-builder", "controller:poll-ui-builder", {
needs: ['controller:modal']
});
test("isMultiple", function() {
const controller = this.subject();
controller.setProperties({
pollType: I18n.t("poll.ui_builder.poll_type.multiple"),
pollOptionsCount: 1
});
equal(controller.get("isMultiple"), true, "it should be true");
controller.set("pollOptionsCount", 0);
equal(controller.get("isMultiple"), false, "it should be false");
controller.setProperties({ pollType: "random", pollOptionsCount: 1 });
equal(controller.get("isMultiple"), false, "it should be false");
});
test("isNumber", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.set("pollType", "random");
equal(controller.get("isNumber"), false, "it should be false");
controller.set("pollType", I18n.t("poll.ui_builder.poll_type.number"));
equal(controller.get("isNumber"), true, "it should be true");
});
test("showMinMax", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.setProperties({
isNumber: true,
isMultiple: false
});
equal(controller.get("showMinMax"), true, "it should be true");
controller.setProperties({
isNumber: false,
isMultiple: true
});
equal(controller.get("showMinMax"), true, "it should be true");
});
test("pollOptionsCount", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.set("pollOptions", "1\n2\n")
equal(controller.get("pollOptionsCount"), 2, "it should equal 2");
controller.set("pollOptions", "")
equal(controller.get("pollOptionsCount"), 0, "it should equal 0");
});
test("pollMinOptions", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.setProperties({
isMultiple: true,
pollOptionsCount: 1
});
deepEqual(controller.get("pollMinOptions"), [{ name: 1, value: 1 }], "it should return the right options");
controller.set("pollOptionsCount", 2);
deepEqual(controller.get("pollMinOptions"), [
{ name: 1, value: 1 }, { name: 2, value: 2 }
], "it should return the right options");
controller.set("isNumber", true);
controller.siteSettings.poll_maximum_options = 2;
deepEqual(controller.get("pollMinOptions"), [
{ name: 1, value: 1 }, { name: 2, value: 2 }
], "it should return the right options");
});
test("pollMaxOptions", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.setProperties({ isMultiple: true, pollOptionsCount: 1, pollMin: 1 });
deepEqual(controller.get("pollMaxOptions"), [], "it should return the right options");
controller.set("pollOptionsCount", 2);
deepEqual(controller.get("pollMaxOptions"), [
{ name: 2, value: 2 }
], "it should return the right options");
controller.siteSettings.poll_maximum_options = 3;
controller.setProperties({ isMultiple: false, isNumber: true, pollStep: 2, pollMin: 1 });
deepEqual(controller.get("pollMaxOptions"), [
{ name: 2, value: 2 },
{ name: 3, value: 3 },
{ name: 4, value: 4 },
{ name: 5, value: 5 },
{ name: 6, value: 6 }
], "it should return the right options");
});
test("pollStepOptions", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.siteSettings.poll_maximum_options = 3;
controller.set("isNumber", false);
equal(controller.get("pollStepOptions"), null, "is should return null");
controller.setProperties({ isNumber: true });
deepEqual(controller.get("pollStepOptions"), [
{ name: 1, value: 1 },
{ name: 2, value: 2 },
{ name: 3, value: 3 }
], "it should return the right options");
});
test("disableInsert", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.setProperties({ isNumber: true });
equal(controller.get("disableInsert"), false, "it should be false");
controller.setProperties({ isNumber: false, pollOptionsCount: 3 });
equal(controller.get("disableInsert"), false, "it should be false");
controller.setProperties({ isNumber: false, pollOptionsCount: 1 });
equal(controller.get("disableInsert"), true, "it should be true");
});
test("number pollOutput", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.siteSettings.poll_maximum_options = 20;
controller.setProperties({
isNumber: true,
pollType: I18n.t("poll.ui_builder.poll_type.number"),
pollMin: 1
});
equal(controller.get("pollOutput"), "[poll type=number min=1 max=20 step=1]\n[/poll]", "it should return the right output");
controller.set("pollName", 'test');
equal(controller.get("pollOutput"), "[poll name=test type=number min=1 max=20 step=1]\n[/poll]", "it should return the right output");
controller.set("pollName", 'test poll');
equal(controller.get("pollOutput"), "[poll name=test-poll type=number min=1 max=20 step=1]\n[/poll]", "it should return the right output");
controller.set("pollStep", 2);
equal(controller.get("pollOutput"), "[poll name=test-poll type=number min=1 max=20 step=2]\n[/poll]", "it should return the right output");
controller.set("publicPoll", true);
equal(controller.get("pollOutput"), "[poll name=test-poll type=number min=1 max=20 step=2 public=true]\n[/poll]", "it should return the right output");
});
test("regular pollOutput", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.siteSettings.poll_maximum_options = 20;
controller.set("pollOptions", "1\n2");
equal(controller.get("pollOutput"), "[poll]\n* 1\n* 2\n[/poll]", "it should return the right output");
controller.set("pollName", "test");
equal(controller.get("pollOutput"), "[poll name=test]\n* 1\n* 2\n[/poll]", "it should return the right output");
controller.set("publicPoll", "true");
equal(controller.get("pollOutput"), "[poll name=test public=true]\n* 1\n* 2\n[/poll]", "it should return the right output");
});
test("multiple pollOutput", function() {
const controller = this.subject();
controller.siteSettings = Discourse.SiteSettings;
controller.siteSettings.poll_maximum_options = 20;
controller.setProperties({
isMultiple: true,
pollType: I18n.t("poll.ui_builder.poll_type.multiple"),
pollMin: 1,
pollOptions: "1\n2"
});
equal(controller.get("pollOutput"), "[poll type=multiple min=1 max=2]\n* 1\n* 2\n[/poll]", "it should return the right output");
controller.set("pollName", "test");
equal(controller.get("pollOutput"), "[poll name=test type=multiple min=1 max=2]\n* 1\n* 2\n[/poll]", "it should return the right output");
controller.set("publicPoll", "true");
equal(controller.get("pollOutput"), "[poll name=test type=multiple min=1 max=2 public=true]\n* 1\n* 2\n[/poll]", "it should return the right output");
});