FEATURE: Allow changing slug on create channel (#19928)
This commit allows us to set the channel slug when creating new chat channels. As well as this, it introduces a new `SlugsController` which can generate a slug using `Slug.for` and a name string for input. We call this after the user finishes typing the channel name (debounced) and fill in the autogenerated slug in the background, and update the slug input placeholder. This autogenerated slug is used by default, but if the user writes anything else in the input it will be used instead.
This commit is contained in:
parent
ae20ce8654
commit
641e94fc3c
|
@ -0,0 +1,22 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
class SlugsController < ApplicationController
|
||||
requires_login
|
||||
|
||||
MAX_SLUG_GENERATIONS_PER_MINUTE = 20
|
||||
|
||||
def generate
|
||||
params.require(:name)
|
||||
|
||||
raise Discourse::InvalidAccess if !current_user.has_trust_level_or_staff?(TrustLevel[4])
|
||||
|
||||
RateLimiter.new(
|
||||
current_user,
|
||||
"max-slug-generations-per-minute",
|
||||
MAX_SLUG_GENERATIONS_PER_MINUTE,
|
||||
1.minute,
|
||||
).performed!
|
||||
|
||||
render json: success_json.merge(slug: Slug.for(params[:name], ""))
|
||||
end
|
||||
end
|
|
@ -1064,6 +1064,8 @@ Discourse::Application.routes.draw do
|
|||
|
||||
resources :associated_groups, only: %i[index], constraints: AdminConstraint.new
|
||||
|
||||
get "slugs/generate", to: "slugs#generate"
|
||||
|
||||
# aliases so old API code works
|
||||
delete "admin/groups/:id/members" => "groups#remove_member", :constraints => AdminConstraint.new
|
||||
put "admin/groups/:id/members" => "groups#add_members", :constraints => AdminConstraint.new
|
||||
|
|
|
@ -57,7 +57,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
|
|||
|
||||
def create
|
||||
channel_params =
|
||||
params.require(:channel).permit(:chatable_id, :name, :description, :auto_join_users)
|
||||
params.require(:channel).permit(:chatable_id, :name, :slug, :description, :auto_join_users)
|
||||
|
||||
guardian.ensure_can_create_chat_channel!
|
||||
if channel_params[:name].length > SiteSetting.max_topic_title_length
|
||||
|
@ -81,6 +81,7 @@ class Chat::Api::ChatChannelsController < Chat::Api
|
|||
channel =
|
||||
chatable.create_chat_channel!(
|
||||
name: channel_params[:name],
|
||||
slug: channel_params[:slug],
|
||||
description: channel_params[:description],
|
||||
user_count: 1,
|
||||
auto_join_users: auto_join_users,
|
||||
|
|
|
@ -1,4 +1,7 @@
|
|||
import { escapeExpression } from "discourse/lib/utilities";
|
||||
import { ajax } from "discourse/lib/ajax";
|
||||
import { cancel } from "@ember/runloop";
|
||||
import discourseDebounce from "discourse-common/lib/debounce";
|
||||
import Controller from "@ember/controller";
|
||||
import I18n from "I18n";
|
||||
import ModalFunctionality from "discourse/mixins/modal-functionality";
|
||||
|
@ -26,6 +29,8 @@ export default class CreateChannelController extends Controller.extend(
|
|||
category = null;
|
||||
categoryId = null;
|
||||
name = "";
|
||||
slug = "";
|
||||
autoGeneratedSlug = "";
|
||||
description = "";
|
||||
categoryPermissionsHint = null;
|
||||
autoJoinUsers = null;
|
||||
|
@ -51,11 +56,14 @@ export default class CreateChannelController extends Controller.extend(
|
|||
}
|
||||
|
||||
onClose() {
|
||||
cancel(this.generateSlugHandler);
|
||||
this.setProperties({
|
||||
categoryId: null,
|
||||
category: null,
|
||||
name: "",
|
||||
description: "",
|
||||
slug: "",
|
||||
autoGeneratedSlug: "",
|
||||
categoryPermissionsHint: DEFAULT_HINT,
|
||||
autoJoinWarning: "",
|
||||
});
|
||||
|
@ -65,6 +73,7 @@ export default class CreateChannelController extends Controller.extend(
|
|||
const data = {
|
||||
chatable_id: this.categoryId,
|
||||
name: this.name,
|
||||
slug: this.slug || this.autoGeneratedSlug,
|
||||
description: this.description,
|
||||
auto_join_users: this.autoJoinUsers,
|
||||
};
|
||||
|
@ -147,17 +156,53 @@ export default class CreateChannelController extends Controller.extend(
|
|||
}
|
||||
}
|
||||
|
||||
// intentionally not showing AJAX error for this, we will autogenerate
|
||||
// the slug server-side if they leave it blank
|
||||
_generateSlug(name) {
|
||||
ajax("/slugs/generate.json", { type: "GET", data: { name } }).then(
|
||||
(response) => {
|
||||
this.set("autoGeneratedSlug", response.slug);
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
_debouncedGenerateSlug(name) {
|
||||
cancel(this.generateSlugHandler);
|
||||
this._clearAutoGeneratedSlug();
|
||||
if (!name) {
|
||||
return;
|
||||
}
|
||||
this.generateSlugHandler = discourseDebounce(
|
||||
this,
|
||||
this._generateSlug,
|
||||
name,
|
||||
300
|
||||
);
|
||||
}
|
||||
|
||||
_clearAutoGeneratedSlug() {
|
||||
this.set("autoGeneratedSlug", "");
|
||||
}
|
||||
|
||||
@action
|
||||
onCategoryChange(categoryId) {
|
||||
let category = categoryId
|
||||
? this.site.categories.findBy("id", categoryId)
|
||||
: null;
|
||||
this._updatePermissionsHint(category);
|
||||
|
||||
const name = this.name || category?.name || "";
|
||||
this.setProperties({
|
||||
categoryId,
|
||||
category,
|
||||
name: this.name || category?.name || "",
|
||||
name,
|
||||
});
|
||||
this._debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
onNameChange(name) {
|
||||
this._debouncedGenerateSlug(name);
|
||||
}
|
||||
|
||||
@action
|
||||
|
|
|
@ -8,6 +8,20 @@
|
|||
class="create-channel-name-input"
|
||||
@type="text"
|
||||
@value={{this.name}}
|
||||
{{on "input" (action "onNameChange" value="target.value")}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="create-channel-control">
|
||||
<label for="channel-slug" class="create-channel-label">
|
||||
{{i18n "chat.create_channel.slug"}}
|
||||
</label>
|
||||
<Input
|
||||
name="channel-slug"
|
||||
class="create-channel-slug-input"
|
||||
@type="text"
|
||||
@value={{this.slug}}
|
||||
placeholder={{this.autoGeneratedSlug}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
@ -10,6 +10,7 @@
|
|||
|
||||
.select-kit.combo-box,
|
||||
.create-channel-name-input,
|
||||
.create-channel-slug-input,
|
||||
.create-channel-description-input,
|
||||
#choose-topic-title {
|
||||
width: 100%;
|
||||
|
|
|
@ -289,6 +289,7 @@ en:
|
|||
create: "Create channel"
|
||||
description: "Description (optional)"
|
||||
name: "Channel name"
|
||||
slug: "Channel slug (optional)"
|
||||
title: "New channel"
|
||||
type: "Type"
|
||||
types:
|
||||
|
|
|
@ -263,11 +263,22 @@ RSpec.describe Chat::Api::ChatChannelsController do
|
|||
new_channel = ChatChannel.last
|
||||
|
||||
expect(new_channel.name).to eq(params[:channel][:name])
|
||||
expect(new_channel.slug).to eq("channel-name")
|
||||
expect(new_channel.description).to eq(params[:channel][:description])
|
||||
expect(new_channel.chatable_type).to eq(category.class.name)
|
||||
expect(new_channel.chatable_id).to eq(category.id)
|
||||
end
|
||||
|
||||
it "creates a channel using the user-provided slug" do
|
||||
new_params = params.dup
|
||||
new_params[:channel][:slug] = "wow-so-cool"
|
||||
post "/chat/api/channels", params: new_params
|
||||
|
||||
new_channel = ChatChannel.last
|
||||
|
||||
expect(new_channel.slug).to eq("wow-so-cool")
|
||||
end
|
||||
|
||||
it "creates a channel sets auto_join_users to false by default" do
|
||||
post "/chat/api/channels", params: params
|
||||
|
||||
|
|
|
@ -1,35 +1,43 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe "Create channel", type: :system, js: true do
|
||||
fab!(:current_admin_user) { Fabricate(:admin) }
|
||||
fab!(:category_1) { Fabricate(:category) }
|
||||
|
||||
let(:chat_page) { PageObjects::Pages::Chat.new }
|
||||
let(:channel_modal) { PageObjects::Modals::ChatChannelCreate.new }
|
||||
|
||||
before do
|
||||
chat_system_bootstrap
|
||||
sign_in(current_admin_user)
|
||||
before { chat_system_bootstrap }
|
||||
|
||||
context "when user cannot create channel" do
|
||||
fab!(:current_user) { Fabricate(:user) }
|
||||
before { sign_in(current_user) }
|
||||
|
||||
it "does not show the create channel button" do
|
||||
chat_page.visit_browse
|
||||
expect(chat_page).not_to have_new_channel_button
|
||||
end
|
||||
end
|
||||
|
||||
context "when can create channel" do
|
||||
fab!(:current_admin_user) { Fabricate(:admin) }
|
||||
before { sign_in(current_admin_user) }
|
||||
|
||||
context "when selecting a category" do
|
||||
it "shows access hint" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{category_1.id}\"]").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(category_1)
|
||||
|
||||
expect(find(".create-channel-hint")).to have_content(Group[:everyone].name)
|
||||
expect(channel_modal).to have_create_hint(Group[:everyone].name)
|
||||
end
|
||||
|
||||
it "does not override channel name if that was already specified" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
fill_in("channel-name", with: "My Cool Channel")
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{category_1.id}\"]").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.fill_name("My Cool Channel")
|
||||
channel_modal.select_category(category_1)
|
||||
|
||||
expect(page).to have_field("channel-name", with: "My Cool Channel")
|
||||
expect(channel_modal).to have_name_prefilled("My Cool Channel")
|
||||
end
|
||||
|
||||
context "when category is private" do
|
||||
|
@ -37,12 +45,11 @@ RSpec.describe "Create channel", type: :system, js: true do
|
|||
fab!(:private_category_1) { Fabricate(:private_category, group: group_1) }
|
||||
|
||||
it "shows access hint when selecting the category" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{private_category_1.id}\"]").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(private_category_1)
|
||||
|
||||
expect(find(".create-channel-hint")).to have_content(group_1.name)
|
||||
expect(channel_modal).to have_create_hint(group_1.name)
|
||||
end
|
||||
|
||||
context "when category is a child" do
|
||||
|
@ -52,12 +59,11 @@ RSpec.describe "Create channel", type: :system, js: true do
|
|||
end
|
||||
|
||||
it "shows access hint when selecting the category" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{child_category.id}\"]").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(child_category)
|
||||
|
||||
expect(find(".create-channel-hint")).to have_content(group_2.name)
|
||||
expect(channel_modal).to have_create_hint(group_2.name)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
@ -72,45 +78,91 @@ RSpec.describe "Create channel", type: :system, js: true do
|
|||
fab!(:private_category_1) { Fabricate(:private_category, group: group_1) }
|
||||
|
||||
it "escapes the group name" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{private_category_1.id}\"]").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(private_category_1)
|
||||
|
||||
expect(find(".create-channel-hint")["innerHTML"].strip).to include(
|
||||
expect(channel_modal.create_channel_hint["innerHTML"].strip).to include(
|
||||
"<script>e</script>",
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
it "autogenerates slug from name and changes slug placeholder" do
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
name = "Cats & Dogs"
|
||||
channel_modal.select_category(category_1)
|
||||
channel_modal.fill_name(name)
|
||||
channel_modal.fill_description("All kind of cute cats")
|
||||
|
||||
wait_for_attribute(channel_modal.slug_input, :placeholder, "cats-dogs")
|
||||
|
||||
channel_modal.click_primary_button
|
||||
|
||||
expect(page).to have_content(name)
|
||||
created_channel = ChatChannel.find_by(chatable_id: category_1.id)
|
||||
expect(created_channel.slug).to eq("cats-dogs")
|
||||
expect(page).to have_current_path(chat.channel_path(created_channel.id, created_channel.slug))
|
||||
end
|
||||
|
||||
it "allows the user to set a slug independently of name" do
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
name = "Cats & Dogs"
|
||||
channel_modal.select_category(category_1)
|
||||
channel_modal.fill_name(name)
|
||||
channel_modal.fill_description("All kind of cute cats")
|
||||
channel_modal.fill_slug("pets-everywhere")
|
||||
channel_modal.click_primary_button
|
||||
|
||||
expect(page).to have_content(name)
|
||||
created_channel = ChatChannel.find_by(chatable_id: category_1.id)
|
||||
expect(created_channel.slug).to eq("pets-everywhere")
|
||||
expect(page).to have_current_path(chat.channel_path(created_channel.id, created_channel.slug))
|
||||
end
|
||||
|
||||
context "when saving" do
|
||||
context "when error" do
|
||||
it "displays the error" do
|
||||
existing_channel = Fabricate(:chat_channel)
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{existing_channel.chatable_id}\"]").click
|
||||
fill_in("channel-name", with: existing_channel.name)
|
||||
find(".create-channel-modal .create").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(existing_channel.chatable)
|
||||
channel_modal.fill_name(existing_channel.name)
|
||||
channel_modal.click_primary_button
|
||||
|
||||
expect(page).to have_content(I18n.t("chat.errors.channel_exists_for_category"))
|
||||
end
|
||||
end
|
||||
|
||||
context "when slug is already being used" do
|
||||
it "displays the error" do
|
||||
Fabricate(:chat_channel, slug: "pets-everywhere")
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(category_1)
|
||||
channel_modal.fill_name("Testing")
|
||||
channel_modal.fill_slug("pets-everywhere")
|
||||
channel_modal.click_primary_button
|
||||
|
||||
expect(page).to have_content(
|
||||
"Slug " + I18n.t("chat.category_channel.errors.is_already_in_use"),
|
||||
)
|
||||
end
|
||||
end
|
||||
|
||||
context "when successful" do
|
||||
it "redirects to created channel" do
|
||||
visit("/chat")
|
||||
find(".new-channel-btn").click
|
||||
name = "Cats"
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{category_1.id}\"]").click
|
||||
expect(page).to have_field("channel-name", with: category_1.name)
|
||||
fill_in("channel-name", with: name)
|
||||
fill_in("channel-description", with: "All kind of cute cats")
|
||||
find(".create-channel-modal .create").click
|
||||
chat_page.visit_browse
|
||||
chat_page.new_channel_button.click
|
||||
channel_modal.select_category(category_1)
|
||||
expect(channel_modal).to have_name_prefilled(category_1.name)
|
||||
|
||||
expect(page).to have_content(name)
|
||||
channel_modal.fill_description("All kind of cute cats")
|
||||
channel_modal.click_primary_button
|
||||
|
||||
expect(page).to have_content(category_1.name)
|
||||
created_channel = ChatChannel.find_by(chatable_id: category_1.id)
|
||||
expect(page).to have_current_path(
|
||||
chat.channel_path(created_channel.id, created_channel.slug),
|
||||
|
|
|
@ -51,6 +51,14 @@ module PageObjects
|
|||
container.has_content?(message.message)
|
||||
container.has_content?(message.user.username)
|
||||
end
|
||||
|
||||
def new_channel_button
|
||||
find(".new-channel-btn")
|
||||
end
|
||||
|
||||
def has_new_channel_button?
|
||||
has_css?(".new-channel-btn")
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
|
@ -0,0 +1,40 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
module PageObjects
|
||||
module Modals
|
||||
class ChatChannelCreate < PageObjects::Modals::Base
|
||||
def select_category(category)
|
||||
find(".category-chooser").click
|
||||
find(".category-row[data-value=\"#{category.id}\"]").click
|
||||
end
|
||||
|
||||
def create_channel_hint
|
||||
find(".create-channel-hint")
|
||||
end
|
||||
|
||||
def slug_input
|
||||
find(".create-channel-slug-input")
|
||||
end
|
||||
|
||||
def has_create_hint?(content)
|
||||
create_channel_hint.has_content?(content)
|
||||
end
|
||||
|
||||
def fill_name(name)
|
||||
fill_in("channel-name", with: name)
|
||||
end
|
||||
|
||||
def fill_slug(slug)
|
||||
fill_in("channel-slug", with: slug)
|
||||
end
|
||||
|
||||
def fill_description(description)
|
||||
fill_in("channel-description", with: description)
|
||||
end
|
||||
|
||||
def has_name_prefilled?(name)
|
||||
has_field?("channel-name", with: name)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -0,0 +1,61 @@
|
|||
# frozen_string_literal: true
|
||||
|
||||
RSpec.describe SlugsController do
|
||||
fab!(:current_user) { Fabricate(:user, trust_level: TrustLevel[4]) }
|
||||
|
||||
describe "#generate" do
|
||||
let(:name) { "Arts & Media" }
|
||||
|
||||
context "when user not logged in" do
|
||||
it "returns a 403 error" do
|
||||
get "/slugs/generate.json?name=#{name}"
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is logged in" do
|
||||
before { sign_in(current_user) }
|
||||
|
||||
it "generates a slug from the name" do
|
||||
get "/slugs/generate.json", params: { name: name }
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["slug"]).to eq(Slug.for(name, ""))
|
||||
end
|
||||
|
||||
it "rate limits" do
|
||||
RateLimiter.enable
|
||||
|
||||
stub_const(SlugsController, "MAX_SLUG_GENERATIONS_PER_MINUTE", 1) do
|
||||
get "/slugs/generate.json?name=#{name}"
|
||||
get "/slugs/generate.json?name=#{name}"
|
||||
end
|
||||
|
||||
expect(response.status).to eq(429)
|
||||
end
|
||||
|
||||
it "requires name" do
|
||||
get "/slugs/generate.json"
|
||||
expect(response.status).to eq(400)
|
||||
end
|
||||
|
||||
context "when user is not TL4 or higher" do
|
||||
before { current_user.change_trust_level!(1) }
|
||||
|
||||
it "returns a 403 error" do
|
||||
get "/slugs/generate.json?name=#{name}"
|
||||
expect(response.status).to eq(403)
|
||||
end
|
||||
end
|
||||
|
||||
context "when user is admin" do
|
||||
fab!(:current_user) { Fabricate(:admin) }
|
||||
|
||||
it "generates a slug from the name" do
|
||||
get "/slugs/generate.json", params: { name: name }
|
||||
expect(response.status).to eq(200)
|
||||
expect(response.parsed_body["slug"]).to eq(Slug.for(name, ""))
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
|
@ -39,6 +39,18 @@ module SystemHelpers
|
|||
retry
|
||||
end
|
||||
|
||||
def wait_for_attribute(
|
||||
element,
|
||||
attribute,
|
||||
value,
|
||||
timeout: Capybara.default_max_wait_time,
|
||||
frequency: 0.01
|
||||
)
|
||||
try_until_success(timeout: timeout, frequency: frequency) do
|
||||
expect(element[attribute.to_sym]).to eq(value)
|
||||
end
|
||||
end
|
||||
|
||||
def resize_window(width: nil, height: nil)
|
||||
original_size = page.driver.browser.manage.window.size
|
||||
page.driver.browser.manage.window.resize_to(
|
||||
|
|
|
@ -17,6 +17,10 @@ module PageObjects
|
|||
def click_outside
|
||||
find(".modal-outer-container").click(x: 0, y: 0)
|
||||
end
|
||||
|
||||
def click_primary_button
|
||||
find(".modal-footer .btn-primary").click
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
|
Loading…
Reference in New Issue