diff --git a/app/assets/javascripts/discourse/controllers/groups.js.es6 b/app/assets/javascripts/discourse/controllers/groups-index.js.es6 similarity index 93% rename from app/assets/javascripts/discourse/controllers/groups.js.es6 rename to app/assets/javascripts/discourse/controllers/groups-index.js.es6 index 006dd3baabc..e8f4098ae8f 100644 --- a/app/assets/javascripts/discourse/controllers/groups.js.es6 +++ b/app/assets/javascripts/discourse/controllers/groups-index.js.es6 @@ -35,6 +35,10 @@ export default Ember.Controller.extend({ actions: { loadMore() { this.get('model').loadMore(); + }, + + new() { + this.transitionToRoute("groups.new"); } } }); diff --git a/app/assets/javascripts/discourse/controllers/groups-new.js.es6 b/app/assets/javascripts/discourse/controllers/groups-new.js.es6 new file mode 100644 index 00000000000..00714cd8f22 --- /dev/null +++ b/app/assets/javascripts/discourse/controllers/groups-new.js.es6 @@ -0,0 +1,103 @@ +import { popupAjaxError } from 'discourse/lib/ajax-error'; +import computed from 'ember-addons/ember-computed-decorators'; +import User from "discourse/models/user"; +import InputValidation from 'discourse/models/input-validation'; +import debounce from 'discourse/lib/debounce'; + +export default Ember.Controller.extend({ + disableSave: null, + + aliasLevelOptions: [ + { name: I18n.t("groups.alias_levels.nobody"), value: 0 }, + { name: I18n.t("groups.alias_levels.mods_and_admins"), value: 2 }, + { name: I18n.t("groups.alias_levels.members_mods_and_admins"), value: 3 }, + { name: I18n.t("groups.alias_levels.everyone"), value: 99 } + ], + + visibilityLevelOptions: [ + { name: I18n.t("groups.visibility_levels.public"), value: 0 }, + { name: I18n.t("groups.visibility_levels.members"), value: 1 }, + { name: I18n.t("groups.visibility_levels.staff"), value: 2 }, + { name: I18n.t("groups.visibility_levels.owners"), value: 3 } + ], + + @computed('model.visibility_level', 'model.public_admission') + disableMembershipRequestSetting(visibility_level, publicAdmission) { + visibility_level = parseInt(visibility_level); + return (visibility_level !== 0) || publicAdmission; + }, + + @computed('basicNameValidation', 'uniqueNameValidation') + nameValidation(basicNameValidation, uniqueNameValidation) { + return uniqueNameValidation ? uniqueNameValidation : basicNameValidation; + }, + + @computed('model.name') + basicNameValidation(name) { + if (name === undefined) { + return this._failedInputValidation(); + }; + + if (name === "") { + this.set('uniqueNameValidation', null); + return this._failedInputValidation(I18n.t('groups.new.name.blank')); + } + + if (name.length < this.siteSettings.min_username_length) { + return this._failedInputValidation(I18n.t('groups.new.name.too_short')); + } + + if (name.length > this.siteSettings.max_username_length) { + return this._failedInputValidation(I18n.t('groups.new.name.too_long')); + } + + this.checkGroupName(); + + return this._failedInputValidation(I18n.t('groups.new.name.checking')); + }, + + checkGroupName: debounce(function() { + User.checkUsername(this.get('model.name')).then(response => { + const validationName = 'uniqueNameValidation'; + + if (response.available) { + this.set(validationName, InputValidation.create({ + ok: true, + reason: I18n.t('groups.new.name.available') + })); + + this.set('disableSave', false); + } else { + let reason; + + if (response.errors) { + reason = response.errors.join(' '); + } else { + reason = I18n.t('groups.new.name.not_available'); + } + + this.set(validationName, this._failedInputValidation(reason)); + } + }); + }, 500), + + _failedInputValidation(reason) { + this.set('disableSave', true); + + const options = { failed: true }; + if (reason) options.reason = reason; + return InputValidation.create(options); + }, + + actions: { + save() { + this.set('disableSave', true); + const group = this.get('model'); + + group.create().then(() => { + this.transitionToRoute("group.members", group.name); + }).catch(popupAjaxError) + .finally(() => this.set('disableSave', false)); + }, + } +}); diff --git a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 index 524e8510017..eece80d8260 100644 --- a/app/assets/javascripts/discourse/routes/app-route-map.js.es6 +++ b/app/assets/javascripts/discourse/routes/app-route-map.js.es6 @@ -49,7 +49,9 @@ export default function() { this.route('categoryWithID', { path: '/c/:parentSlug/:slug/:id' }); }); - this.route('groups', { resetNamespace: true }); + this.route('groups', { resetNamespace: true }, function() { + this.route("new", { path: "custom/new" }); + }); this.route('group', { path: '/groups/:name', resetNamespace: true }, function() { this.route('members'); diff --git a/app/assets/javascripts/discourse/routes/groups.js.es6 b/app/assets/javascripts/discourse/routes/groups-index.js.es6 similarity index 100% rename from app/assets/javascripts/discourse/routes/groups.js.es6 rename to app/assets/javascripts/discourse/routes/groups-index.js.es6 diff --git a/app/assets/javascripts/discourse/routes/groups-new.js.es6 b/app/assets/javascripts/discourse/routes/groups-new.js.es6 new file mode 100644 index 00000000000..e7489737cbc --- /dev/null +++ b/app/assets/javascripts/discourse/routes/groups-new.js.es6 @@ -0,0 +1,21 @@ +import Group from 'discourse/models/group'; + +export default Discourse.Route.extend({ + titleToken() { + return I18n.t('groups.new.title'); + }, + + model() { + return Group.create({ automatic: false, visibility_level: 0 }); + }, + + setupController(controller, model) { + controller.set("model", model); + }, + + afterModel() { + if (!(this.currentUser && this.currentUser.admin)) { + this.transitionTo("groups"); + } + }, +}); diff --git a/app/assets/javascripts/discourse/templates/groups.hbs b/app/assets/javascripts/discourse/templates/groups/index.hbs similarity index 96% rename from app/assets/javascripts/discourse/templates/groups.hbs rename to app/assets/javascripts/discourse/templates/groups/index.hbs index 2bfc5d1f5ab..c86bc76ab85 100644 --- a/app/assets/javascripts/discourse/templates/groups.hbs +++ b/app/assets/javascripts/discourse/templates/groups/index.hbs @@ -1,6 +1,12 @@ {{#d-section pageClass="groups"}}

{{i18n "groups.index.title"}}

+ {{#if currentUser.admin}} +
+ {{group-admin-dropdown new="new"}} +
+ {{/if}} +
{{combo-box value=type content=types diff --git a/app/assets/javascripts/discourse/templates/groups/new.hbs b/app/assets/javascripts/discourse/templates/groups/new.hbs new file mode 100644 index 00000000000..51bca6eb78f --- /dev/null +++ b/app/assets/javascripts/discourse/templates/groups/new.hbs @@ -0,0 +1,183 @@ +{{#d-section pageClass="groups-new"}} +

{{i18n "groups.new.title"}}

+ +
+
+ + + {{text-field name="name" + class="input-xxlarge" + value=model.name + placeholderKey="groups.name_placeholder"}} + + {{input-tip validation=nameValidation}} +
+ +
+ + + {{text-field name='full_name' + class="input-xxlarge group-manage-full-name" + value=model.full_name}} +
+ +
+ + + {{input value=model.title name="title" class="input-xxlarge"}} +
+ +
+ + {{d-editor value=model.bio_raw}} +
+ +
+ + + {{user-selector usernames=model.ownerUsernames + placeholderKey="groups.selector_placeholder" + id="owner-selector"}} +
+ +
+ + + {{user-selector usernames=model.usernames + placeholderKey="groups.selector_placeholder" + id="member-selector"}} +
+ +
+ + + {{combo-box name="alias" + valueAttribute="value" + value=model.visibility_level + content=visibilityLevelOptions + castInteger=true}} +
+ +
+ + + + + + + + + {{#if model.allow_membership_requests}} +
+ + + {{expanding-text-area name="membership-request-template" + value=model.membership_request_template}} +
+ {{/if}} +
+ +
+ + + {{combo-box name="alias" + valueAttribute="value" + value=model.mentionable_level + content=aliasLevelOptions}} +
+ +
+ + + {{combo-box name="alias" + valueAttribute="value" + value=model.messageable_level + content=aliasLevelOptions}} +
+ +
+ + + {{notifications-button i18nPrefix='groups.notifications' + value=model.default_notification_level}} +
+ +
+ + + {{list-setting name="automatic_membership" settingValue=model.emailDomains}} + + +
+ +
+ + + {{combo-box name="grant_trust_level" + valueAttribute="value" + value=model.grant_trust_level + content=trustLevelOptions}} +
+ + {{#if siteSettings.email_in}} +
+ + + {{text-field name="incoming_email" + class="input-xxlarge" + value=model.incoming_email + placeholderKey="admin.groups.incoming_email_placeholder"}} + + {{plugin-outlet name="group-email-in" args=(hash model=model)}} +
+ {{/if}} + +
+ {{group-flair-inputs model=model}} +
+ + {{plugin-outlet name="group-edit" args=(hash group=model)}} + +
+ {{d-button action="save" + disabled=disableSave + class='btn btn-primary' + label='groups.new.create'}} + + {{#link-to "groups"}} + {{i18n 'cancel'}} + {{/link-to}} +
+
+{{/d-section}} diff --git a/app/assets/javascripts/select-kit/components/group-admin-dropdown.js.es6 b/app/assets/javascripts/select-kit/components/group-admin-dropdown.js.es6 new file mode 100644 index 00000000000..67817ef81ed --- /dev/null +++ b/app/assets/javascripts/select-kit/components/group-admin-dropdown.js.es6 @@ -0,0 +1,29 @@ +import DropdownSelectBoxComponent from "select-kit/components/dropdown-select-box"; + +export default DropdownSelectBoxComponent.extend({ + classNames: "groups-admin-dropdown pull-right", + headerIcon: ["bars", "caret-down"], + showFullTitle: false, + + computeContent() { + const items = [ + { + id: "new", + name: I18n.t("groups.new.title"), + description: I18n.t("groups.new.description"), + icon: "plus" + } + ]; + + return items; + }, + + mutateValue(value) { + switch (value) { + case 'new': { + this.sendAction("new"); + break; + } + } + }, +}); diff --git a/app/assets/stylesheets/common/base/group.scss b/app/assets/stylesheets/common/base/group.scss index fbcfd1d4893..b0e90f9f0de 100644 --- a/app/assets/stylesheets/common/base/group.scss +++ b/app/assets/stylesheets/common/base/group.scss @@ -201,7 +201,8 @@ table.group-members { } } -.group-manage { +.group-manage, +.groups-new-page { .form-horizontal { label { font-weight: bold; diff --git a/app/assets/stylesheets/desktop.scss b/app/assets/stylesheets/desktop.scss index e11252ac35a..b1a6d6b8495 100644 --- a/app/assets/stylesheets/desktop.scss +++ b/app/assets/stylesheets/desktop.scss @@ -20,6 +20,7 @@ @import "desktop/history"; @import "desktop/queued-posts"; @import "desktop/group"; +@import "desktop/groups"; // Import all component-specific files @import "desktop/components/*"; diff --git a/app/assets/stylesheets/desktop/groups.scss b/app/assets/stylesheets/desktop/groups.scss new file mode 100644 index 00000000000..5c12eae2921 --- /dev/null +++ b/app/assets/stylesheets/desktop/groups.scss @@ -0,0 +1,19 @@ +.groups-page { + .list-controls { + float: right; + } +} + +$filter-line-height: 1.5; + +.groups-filter { + .groups-type-filter { + .select-kit-header { + line-height: $filter-line-height; + } + } + + input { + line-height: $filter-line-height; + } +} diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index 1cf32c8ead1..a660d2a628e 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -7,7 +7,8 @@ class GroupsController < ApplicationController :update, :histories, :request_membership, - :search + :search, + :new ] skip_before_action :preload_json, :check_xhr, only: [:posts_feed, :mentions_feed] @@ -113,6 +114,9 @@ class GroupsController < ApplicationController end end + def new + end + def edit end diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 31f073ba17d..b42d7e5bc02 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -403,6 +403,17 @@ en: remove_user_as_group_owner: "Revoke owner" groups: + new: + title: "New Group" + description: "Create a new group" + create: "Create" + name: + too_short: "Group name is too short" + too_long: "Group name is too long" + checking: "Checking group name availability..." + available: "Group name is available" + not_available: "Group name is not available" + blank: "Group name cannot be blank" manage: title: 'Manage' name: 'Name' @@ -452,7 +463,6 @@ en: submit: "Submit Request" title: "Request to join @%{group_name}" reason: "Let the group owners know why you belong in this group" - membership: "Membership" name: "Name" user_count: "Members Count" diff --git a/config/routes.rb b/config/routes.rb index 38cf78b4e51..7f49f013e91 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -462,6 +462,7 @@ Discourse::Application.routes.draw do get 'logs' => 'groups#histories' collection do + get 'custom/new' => 'groups#new', constraints: AdminConstraint.new get "search" => "groups#search" end diff --git a/spec/requests/groups_controller_spec.rb b/spec/requests/groups_controller_spec.rb index 5e47f4d95c5..eefb9048e8c 100644 --- a/spec/requests/groups_controller_spec.rb +++ b/spec/requests/groups_controller_spec.rb @@ -984,4 +984,34 @@ describe GroupsController do end end end + + describe '#new' do + describe 'for an anon user' do + it 'should return 404' do + get '/groups/custom/new' + + expect(response.status).to eq(404) + end + end + + describe 'for a normal user' do + before { sign_in(user) } + + it 'should return 404' do + get '/groups/custom/new' + + expect(response.status).to eq(404) + end + end + + describe 'for an admin user' do + before { sign_in(Fabricate(:admin)) } + + it 'should return 404' do + get '/groups/custom/new' + + expect(response.status).to eq(200) + end + end + end end diff --git a/test/javascripts/acceptance/groups-test.js.es6 b/test/javascripts/acceptance/group-test.js.es6 similarity index 65% rename from test/javascripts/acceptance/groups-test.js.es6 rename to test/javascripts/acceptance/group-test.js.es6 index 78f55268412..0e0181085ca 100644 --- a/test/javascripts/acceptance/groups-test.js.es6 +++ b/test/javascripts/acceptance/group-test.js.es6 @@ -1,5 +1,7 @@ import { acceptance, logIn } from "helpers/qunit-helpers"; +acceptance("Group"); + const response = object => { return [ 200, @@ -8,63 +10,6 @@ const response = object => { ]; }; -acceptance("Groups", { - beforeEach() { - server.get('/groups/snorlax.json', () => { // eslint-disable-line no-undef - return response({"basic_group":{"id":41,"automatic":false,"name":"snorlax","user_count":1,"alias_level":0,"visible":true,"automatic_membership_email_domains":"","automatic_membership_retroactive":false,"primary_group":true,"title":"Team Snorlax","grant_trust_level":null,"incoming_email":null,"has_messages":false,"flair_url":"","flair_bg_color":"","flair_color":"","bio_raw":"","bio_cooked":null,"public":true,"is_group_user":true,"is_group_owner":true}}); - }); - - // Workaround while awaiting https://github.com/tildeio/route-recognizer/issues/53 - server.get('/groups/snorlax/logs.json', request => { // eslint-disable-line no-undef - if (request.queryParams["filters[action]"]) { - return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":null}],"all_loaded":true}); - } else { - return response({"logs":[{"action":"change_group_setting","subject":"title","prev_value":null,"new_value":"Team Snorlax","created_at":"2016-12-12T08:27:46.408Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":null},{"action":"add_user_to_group","subject":null,"prev_value":null,"new_value":null,"created_at":"2016-12-12T08:27:27.725Z","acting_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"},"target_user":{"id":1,"username":"tgx","avatar_template":"/images/avatar.png"}}],"all_loaded":true}); - } - }); - } -}); - -QUnit.test("Browsing Groups", assert => { - visit("/groups"); - - andThen(() => { - assert.equal(count('.groups-table-row'), 2, 'it displays visible groups'); - assert.equal(find('.group-index-join').length, 1, 'it shows button to join group'); - assert.equal(find('.group-index-request').length, 1, 'it shows button to request for group membership'); - }); - - click('.group-index-join'); - - andThen(() => { - assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); - }); - - click('.login-modal .close'); - - andThen(() => { - assert.ok(invisible('.modal.login-modal'), 'it closes the login modal'); - }); - - click('.group-index-request'); - - andThen(() => { - assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); - }); - - click("a[href='/groups/discourse/members']"); - - andThen(() => { - assert.equal(find('.group-info-name').text().trim(), 'Awesome Team', "it displays the group page"); - }); - - click('.group-index-join'); - - andThen(() => { - assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); - }); -}); - QUnit.test("Anonymous Viewing Group", assert => { visit("/groups/discourse"); diff --git a/test/javascripts/acceptance/groups-index-test.js.es6 b/test/javascripts/acceptance/groups-index-test.js.es6 new file mode 100644 index 00000000000..c6b1eaaef03 --- /dev/null +++ b/test/javascripts/acceptance/groups-index-test.js.es6 @@ -0,0 +1,43 @@ +import { acceptance, logIn } from "helpers/qunit-helpers"; + +acceptance("Groups"); + +QUnit.test("Browsing Groups", assert => { + visit("/groups"); + + andThen(() => { + assert.equal(count('.groups-table-row'), 2, 'it displays visible groups'); + assert.equal(find('.group-index-join').length, 1, 'it shows button to join group'); + assert.equal(find('.group-index-request').length, 1, 'it shows button to request for group membership'); + }); + + click('.group-index-join'); + + andThen(() => { + assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); + }); + + click('.login-modal .close'); + + andThen(() => { + assert.ok(invisible('.modal.login-modal'), 'it closes the login modal'); + }); + + click('.group-index-request'); + + andThen(() => { + assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); + }); + + click("a[href='/groups/discourse/members']"); + + andThen(() => { + assert.equal(find('.group-info-name').text().trim(), 'Awesome Team', "it displays the group page"); + }); + + click('.group-index-join'); + + andThen(() => { + assert.ok(exists('.modal.login-modal'), 'it shows the login modal'); + }); +}); diff --git a/test/javascripts/acceptance/groups-new-test.js.es6 b/test/javascripts/acceptance/groups-new-test.js.es6 new file mode 100644 index 00000000000..9a57899bb78 --- /dev/null +++ b/test/javascripts/acceptance/groups-new-test.js.es6 @@ -0,0 +1,72 @@ +import { acceptance, logIn } from "helpers/qunit-helpers"; + +acceptance("New Group"); + +QUnit.test("As an anon user", assert => { + visit("/groups"); + + andThen(() => { + assert.equal( + find('.groups-admin-dropdown').length, 0, + 'it should not display the admin dropdown' + ); + }); +}); + +QUnit.test("Creating a new group", assert => { + logIn(); + Discourse.reset(); + + visit("/groups"); + + selectKit('.groups-admin-dropdown').expand().selectRowByValue("new"); + fillIn("input[name='name']", '1'); + + andThen(() => { + assert.equal( + find('.tip.bad').text().trim(), I18n.t("groups.new.name.too_short"), + 'it should show the right validation tooltip' + ); + + assert.ok( + find("button[title='Create']:disabled").length === 1, + 'it should disable the save button' + ); + }); + + fillIn("input[name='name']", 'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa'); + + andThen(() => { + assert.equal( + find('.tip.bad').text().trim(), I18n.t("groups.new.name.too_long"), + 'it should show the right validation tooltip' + ); + }); + + fillIn("input[name='name']", ''); + + andThen(() => { + assert.equal( + find('.tip.bad').text().trim(), I18n.t("groups.new.name.blank"), + 'it should show the right validation tooltip' + ); + }); + + fillIn("input[name='name']", 'goodusername'); + + andThen(() => { + assert.equal( + find('.tip.good').text().trim(), I18n.t("groups.new.name.available"), + 'it should show the right validation tooltip' + ); + }); + + click(".groups-new-public-admission"); + + andThen(() => { + assert.equal( + find('groups-new-allow-membership-requests').length, 0, + 'it should disable the membership requests checkbox' + ); + }); +});