From 19672faba6a2b7a6865c4095df1d1a3923bdbfda Mon Sep 17 00:00:00 2001 From: Osama Sayegh Date: Wed, 30 Oct 2024 05:31:14 +0300 Subject: [PATCH] FEATURE: Add invite link to the sidebar (#29448) This commit adds a new "Invite" link to the sidebar for all users who can invite to the site. Clicking the link opens the invite modal without changing the current route the user is on. Admins can customize the new link or remove it entirely if they wish by editing the sidebar section. Internal topic: t/129752. --- .../common/community-section/section.js | 2 + .../community-section/invite-section-link.js | 31 +++++++++++ .../discourse/app/routes/app-route-map.js | 1 + .../discourse/app/routes/new-invite.js | 44 +++++++++++++++ .../sidebar-user-community-section-test.js | 24 ++++++++ .../tests/fixtures/session-fixtures.js | 8 +++ .../discourse/tests/fixtures/site-fixtures.js | 8 +++ app/controllers/new_invite_controller.rb | 6 ++ app/models/sidebar_url.rb | 6 ++ config/locales/client.en.yml | 4 ++ config/routes.rb | 1 + ...41025045928_add_invites_link_to_sidebar.rb | 55 +++++++++++++++++++ spec/models/category_spec.rb | 10 +++- spec/models/sidebar_section_spec.rb | 13 ++++- spec/models/tag_spec.rb | 7 ++- .../editing_sidebar_community_section_spec.rb | 10 ++-- 16 files changed, 219 insertions(+), 11 deletions(-) create mode 100644 app/assets/javascripts/discourse/app/lib/sidebar/user/community-section/invite-section-link.js create mode 100644 app/assets/javascripts/discourse/app/routes/new-invite.js create mode 100644 app/controllers/new_invite_controller.rb create mode 100644 db/migrate/20241025045928_add_invites_link_to_sidebar.rb 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