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"}}
{{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"}}
+
+
+{{/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'
+ );
+ });
+});