diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js index e5e8f541793..5eb7c2e8f93 100644 --- a/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js +++ b/app/assets/javascripts/discourse/app/lib/sidebar/common/community-section/section.js @@ -13,6 +13,7 @@ import { } from "discourse/lib/sidebar/custom-community-section-links"; import SectionLink from "discourse/lib/sidebar/section-link"; import AdminSectionLink from "discourse/lib/sidebar/user/community-section/admin-section-link"; +import InviteSectionLink from "discourse/lib/sidebar/user/community-section/invite-section-link"; import MyPostsSectionLink from "discourse/lib/sidebar/user/community-section/my-posts-section-link"; import ReviewSectionLink from "discourse/lib/sidebar/user/community-section/review-section-link"; @@ -26,6 +27,7 @@ const SPECIAL_LINKS_MAP = { "/badges": BadgesSectionLink, "/admin": AdminSectionLink, "/g": GroupsSectionLink, + "/new-invite": InviteSectionLink, }; export default class CommunitySection { diff --git a/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/invite-section-link.js b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/invite-section-link.js new file mode 100644 index 00000000000..f6e5469d087 --- /dev/null +++ b/app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/invite-section-link.js @@ -0,0 +1,31 @@ +import BaseSectionLink from "discourse/lib/sidebar/base-community-section-link"; +import I18n from "discourse-i18n"; + +export default class InviteSectionLink extends BaseSectionLink { + get name() { + return "invite"; + } + + get route() { + return "new-invite"; + } + + get title() { + return I18n.t("sidebar.sections.community.links.invite.content"); + } + + get text() { + return I18n.t( + `sidebar.sections.community.links.${this.overridenName.toLowerCase()}.content`, + { defaultValue: this.overridenName } + ); + } + + get shouldDisplay() { + return !!this.currentUser?.can_invite_to_forum; + } + + get defaultPrefixValue() { + return "paper-plane"; + } +} diff --git a/app/assets/javascripts/discourse/app/routes/app-route-map.js b/app/assets/javascripts/discourse/app/routes/app-route-map.js index eaecbc2b8a0..5d27b43dba4 100644 --- a/app/assets/javascripts/discourse/app/routes/app-route-map.js +++ b/app/assets/javascripts/discourse/app/routes/app-route-map.js @@ -225,6 +225,7 @@ export default function () { this.route("new-topic"); this.route("new-message"); + this.route("new-invite"); this.route("badges", { resetNamespace: true }, function () { this.route("show", { path: "/:id/:slug" }); diff --git a/app/assets/javascripts/discourse/app/routes/new-invite.js b/app/assets/javascripts/discourse/app/routes/new-invite.js new file mode 100644 index 00000000000..b2122b65e4a --- /dev/null +++ b/app/assets/javascripts/discourse/app/routes/new-invite.js @@ -0,0 +1,44 @@ +import { next } from "@ember/runloop"; +import { service } from "@ember/service"; +import CreateInvite from "discourse/components/modal/create-invite"; +import cookie from "discourse/lib/cookie"; +import DiscourseRoute from "discourse/routes/discourse"; +import I18n from "discourse-i18n"; + +export default class extends DiscourseRoute { + @service router; + @service modal; + @service dialog; + @service currentUser; + + async beforeModel(transition) { + if (this.currentUser) { + if (transition.from) { + // when navigating from another ember route + transition.abort(); + this.#openInviteModalIfAllowed(); + } else { + // when landing on this route from a full page load + this.router + .replaceWith("discovery.latest") + .followRedirects() + .then(() => { + this.#openInviteModalIfAllowed(); + }); + } + } else { + cookie("destination_url", window.location.href); + this.router.replaceWith("login"); + } + } + + #openInviteModalIfAllowed() { + next(() => { + if (this.currentUser.can_invite_to_forum) { + this.modal.show(CreateInvite, { model: { invites: [] } }); + } else { + this.dialog.alert(I18n.t("user.invited.cannot_invite_to_forum")); + } + }); + } +} diff --git a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js index 1be2f20721c..0e297efa520 100644 --- a/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js +++ b/app/assets/javascripts/discourse/tests/acceptance/sidebar-user-community-section-test.js @@ -655,6 +655,30 @@ acceptance("Sidebar - Logged on user - Community Section", function (needs) { ); }); + test("the invite section link is not visible to people who cannot invite to the forum", async function (assert) { + updateCurrentUser({ can_invite_to_forum: false }); + + await visit("/"); + + assert + .dom( + ".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='invite']" + ) + .doesNotExist("invite section link is not visible"); + }); + + test("clicking the invite section link opens the invite modal and doesn't change the route", async function (assert) { + updateCurrentUser({ can_invite_to_forum: true }); + + await visit("/"); + await click( + ".sidebar-section[data-section-name='community'] .sidebar-section-link[data-link-name='invite']" + ); + + assert.dom(".create-invite-modal").exists("invite modal is open"); + assert.strictEqual(currentURL(), "/", "route doesn't change"); + }); + test("visiting top route", async function (assert) { await visit("/top"); diff --git a/app/assets/javascripts/discourse/tests/fixtures/session-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/session-fixtures.js index 90a528ba04c..35f295b10a4 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/session-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/session-fixtures.js @@ -129,6 +129,14 @@ export default { external: false, segment: "secondary", }, + { + id: 338, + name: "Invite", + value: "/new-invite", + icon: "paper-plane", + external: false, + segment: "primary", + }, ], }, ], diff --git a/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js b/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js index c7cf310ac55..7cb703a8062 100644 --- a/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js +++ b/app/assets/javascripts/discourse/tests/fixtures/site-fixtures.js @@ -803,6 +803,14 @@ export default { external: false, segment: "secondary", }, + { + id: 338, + name: "Invite", + value: "/new-invite", + icon: "paper-plane", + external: false, + segment: "primary", + }, ], slug: "community", public: true, diff --git a/app/controllers/new_invite_controller.rb b/app/controllers/new_invite_controller.rb new file mode 100644 index 00000000000..a31e71d75c3 --- /dev/null +++ b/app/controllers/new_invite_controller.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +class NewInviteController < ApplicationController + def index + end +end diff --git a/app/models/sidebar_url.rb b/app/models/sidebar_url.rb index 0c0d1d16cac..e3cfb5b8ff2 100644 --- a/app/models/sidebar_url.rb +++ b/app/models/sidebar_url.rb @@ -21,6 +21,12 @@ class SidebarUrl < ActiveRecord::Base }, { name: "Review", path: "/review", icon: "flag", segment: SidebarUrl.segments["primary"] }, { name: "Admin", path: "/admin", icon: "wrench", segment: SidebarUrl.segments["primary"] }, + { + name: "Invite", + path: "/new-invite", + icon: "paper-plane", + segment: SidebarUrl.segments["primary"], + }, { name: "Users", path: "/u", icon: "users", segment: SidebarUrl.segments["secondary"] }, { name: "About", diff --git a/config/locales/client.en.yml b/config/locales/client.en.yml index 267675079f2..b71351d19ea 100644 --- a/config/locales/client.en.yml +++ b/config/locales/client.en.yml @@ -1964,6 +1964,7 @@ en: title: "Invite Link" success: "Invite link generated successfully!" error: "There was an error generating Invite link" + cannot_invite_to_forum: "Sorry, you don't have permission to create invites. Please contact an admin to grant you invite permission." invite: new_title: "Invite members" @@ -4945,6 +4946,9 @@ en: pending_count: one: "%{count} pending" other: "%{count} pending" + invite: + content: "Invite" + title: "Invite new members" global_section: "Global section, visible to everyone" panels: forum: diff --git a/config/routes.rb b/config/routes.rb index cfaec12f630..6c99110abb9 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -1351,6 +1351,7 @@ Discourse::Application.routes.draw do get "new-topic" => "new_topic#index" get "new-message" => "new_topic#index" + get "new-invite" => "new_invite#index" # Topic routes get "t/id_for/:slug" => "topics#id_for_slug" diff --git a/db/migrate/20241025045928_add_invites_link_to_sidebar.rb b/db/migrate/20241025045928_add_invites_link_to_sidebar.rb new file mode 100644 index 00000000000..1adfdc042f3 --- /dev/null +++ b/db/migrate/20241025045928_add_invites_link_to_sidebar.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +class AddInvitesLinkToSidebar < ActiveRecord::Migration[7.1] + def up + community_section_id = DB.query_single(<<~SQL).first + SELECT id + FROM sidebar_sections + WHERE section_type = 0 + SQL + + return if !community_section_id + + max_position = DB.query_single(<<~SQL, section_id: community_section_id).first + SELECT MAX(ssl.position) + FROM sidebar_urls su + JOIN sidebar_section_links ssl ON su.id = ssl.linkable_id + WHERE ssl.linkable_type = 'SidebarUrl' + AND ssl.sidebar_section_id = :section_id + AND su.segment = 0 + SQL + + updated_rows = DB.query_hash(<<~SQL, position: max_position, section_id: community_section_id) + DELETE FROM sidebar_section_links + WHERE position > :position + AND sidebar_section_id = :section_id + AND linkable_type = 'SidebarUrl' + RETURNING user_id, linkable_id, linkable_type, sidebar_section_id, position + 1 AS position, created_at, updated_at + SQL + updated_rows.each { |row| DB.exec(<<~SQL, **row.symbolize_keys) } + INSERT INTO sidebar_section_links + (user_id, linkable_id, linkable_type, sidebar_section_id, position, created_at, updated_at) + VALUES + (:user_id, :linkable_id, :linkable_type, :sidebar_section_id, :position, :created_at, :updated_at) + SQL + + link_id = DB.query_single(<<~SQL).first + INSERT INTO sidebar_urls + (name, value, icon, external, segment, created_at, updated_at) + VALUES + ('Invite', '/new-invite', 'paper-plane', false, 0, now(), now()) + RETURNING sidebar_urls.id + SQL + + DB.exec(<<~SQL, link_id:, section_id: community_section_id, position: max_position + 1) + INSERT INTO sidebar_section_links + (user_id, linkable_id, linkable_type, sidebar_section_id, position, created_at, updated_at) + VALUES + (-1, :link_id, 'SidebarUrl', :section_id, :position, now(), now()) + SQL + end + + def down + raise ActiveRecord::IrreversibleMigration + end +end diff --git a/spec/models/category_spec.rb b/spec/models/category_spec.rb index e1efb376782..d1d5d9f6d7e 100644 --- a/spec/models/category_spec.rb +++ b/spec/models/category_spec.rb @@ -47,13 +47,17 @@ RSpec.describe Category do it "should delete associated sidebar_section_links when category is destroyed" do category_sidebar_section_link = Fabricate(:category_sidebar_section_link) - Fabricate(:category_sidebar_section_link, linkable: category_sidebar_section_link.linkable) - tag_sidebar_section_link = Fabricate(:tag_sidebar_section_link) + category_sidebar_section_link_2 = + Fabricate(:category_sidebar_section_link, linkable: category_sidebar_section_link.linkable) expect { category_sidebar_section_link.linkable.destroy! }.to change { SidebarSectionLink.count }.from(12).to(10) - expect(SidebarSectionLink.last).to eq(tag_sidebar_section_link) + expect( + SidebarSectionLink.where( + id: [category_sidebar_section_link.id, category_sidebar_section_link_2.id], + ).count, + ).to eq(0) end end diff --git a/spec/models/sidebar_section_spec.rb b/spec/models/sidebar_section_spec.rb index b2e9f310c96..947f0f1acfa 100644 --- a/spec/models/sidebar_section_spec.rb +++ b/spec/models/sidebar_section_spec.rb @@ -22,7 +22,18 @@ RSpec.describe SidebarSection do expect(community_section.reload.title).to eq("Community") expect(community_section.sidebar_section_links.all.map { |link| link.linkable.name }).to eq( - ["Topics", "My Posts", "Review", "Admin", "Users", "About", "FAQ", "Groups", "Badges"], + [ + "Topics", + "My Posts", + "Review", + "Admin", + "Invite", + "Users", + "About", + "FAQ", + "Groups", + "Badges", + ], ) end end diff --git a/spec/models/tag_spec.rb b/spec/models/tag_spec.rb index d7b97e3475d..7b7aa78ac55 100644 --- a/spec/models/tag_spec.rb +++ b/spec/models/tag_spec.rb @@ -25,12 +25,15 @@ RSpec.describe Tag do tag_sidebar_section_link = Fabricate(:tag_sidebar_section_link) tag_sidebar_section_link_2 = Fabricate(:tag_sidebar_section_link, linkable: tag_sidebar_section_link.linkable) - category_sidebar_section_link = Fabricate(:category_sidebar_section_link) expect { tag_sidebar_section_link.linkable.destroy! }.to change { SidebarSectionLink.count }.from(12).to(10) - expect(SidebarSectionLink.last).to eq(category_sidebar_section_link) + expect( + SidebarSectionLink.where( + id: [tag_sidebar_section_link.id, tag_sidebar_section_link_2.id], + ).count, + ).to eq(0) end end diff --git a/spec/system/editing_sidebar_community_section_spec.rb b/spec/system/editing_sidebar_community_section_spec.rb index 45502f5d28f..d8e6d7c43df 100644 --- a/spec/system/editing_sidebar_community_section_spec.rb +++ b/spec/system/editing_sidebar_community_section_spec.rb @@ -23,7 +23,7 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do visit("/latest") expect(sidebar.primary_section_icons("community")).to eq( - %w[layer-group user flag wrench ellipsis-vertical], + %w[layer-group user flag wrench paper-plane ellipsis-vertical], ) modal = sidebar.click_community_section_more_button.click_customize_community_section_button @@ -33,11 +33,11 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do modal.confirm_update expect(sidebar.primary_section_links("community")).to eq( - ["My Posts", "Topics", "Review", "Admin", "More"], + ["My Posts", "Topics", "Review", "Admin", "Invite", "More"], ) expect(sidebar.primary_section_icons("community")).to eq( - %w[user paper-plane flag wrench ellipsis-vertical], + %w[user paper-plane flag wrench paper-plane ellipsis-vertical], ) modal = sidebar.click_community_section_more_button.click_customize_community_section_button @@ -46,11 +46,11 @@ RSpec.describe "Editing Sidebar Community Section", type: :system do expect(sidebar).to have_section("Community") expect(sidebar.primary_section_links("community")).to eq( - ["Topics", "My Posts", "Review", "Admin", "More"], + ["Topics", "My Posts", "Review", "Admin", "Invite", "More"], ) expect(sidebar.primary_section_icons("community")).to eq( - %w[layer-group user flag wrench ellipsis-vertical], + %w[layer-group user flag wrench paper-plane ellipsis-vertical], ) end