From d4ab4080dba258ffd750cf70ff60eac7552c82a8 Mon Sep 17 00:00:00 2001 From: Jean Date: Fri, 9 Jun 2023 11:02:17 -0400 Subject: [PATCH] FEATURE: add setting to show tags by group (#138) * FEATURE: add setting to show tags by group --- .../discourse/controllers/docs-index.js | 48 ++++++- .../discourse/templates/docs-index.hbs | 62 ++++++++ config/locales/server.en.yml | 2 + config/settings.yml | 7 + lib/docs/query.rb | 52 ++++++- spec/requests/docs_controller_spec.rb | 59 ++++++++ test/javascripts/acceptance/docs-test.js | 77 ++++++++++ .../fixtures/docs-show-tag-groups.js | 135 ++++++++++++++++++ 8 files changed, 435 insertions(+), 7 deletions(-) create mode 100644 test/javascripts/fixtures/docs-show-tag-groups.js diff --git a/assets/javascripts/discourse/controllers/docs-index.js b/assets/javascripts/discourse/controllers/docs-index.js index c4b7420..39fe0cd 100644 --- a/assets/javascripts/discourse/controllers/docs-index.js +++ b/assets/javascripts/discourse/controllers/docs-index.js @@ -48,6 +48,7 @@ export default Controller.extend({ categories: readOnly("model.categories"), topics: alias("model.topics.topic_list.topics"), tags: readOnly("model.tags"), + tagGroups: readOnly("model.tag_groups"), topicCount: alias("model.topic_count"), emptyResults: equal("topicCount", 0), @@ -142,6 +143,39 @@ export default Controller.extend({ return tags; }, + @discourseComputed("tagGroups", "tagSort", "tagFilter") + sortedTagGroups(tagGroups, tagSort, filter) { + let { type, direction } = tagSort; + let sortedTagGroups = [...tagGroups]; + + if (type === "numeric") { + sortedTagGroups.forEach((group) => { + group.totalCount = group.tags.reduce( + (acc, curr) => acc + curr.count, + 0 + ); + }); + + sortedTagGroups.sort((a, b) => b.totalCount - a.totalCount); + } else { + sortedTagGroups.sort((a, b) => + a.name.toLowerCase().localeCompare(b.name.toLowerCase()) + ); + } + + if (direction === "desc") { + sortedTagGroups.reverse(); + } + + if (this.showTagFilter) { + return sortedTagGroups.filter((tag) => + tag.id.toLowerCase().includes(filter.toLowerCase()) + ); + } + + return sortedTagGroups; + }, + @discourseComputed("tagSort") tagSortNumericIcon(tagSort) { if (tagSort.type === "numeric" && tagSort.direction === "asc") { @@ -192,9 +226,17 @@ export default Controller.extend({ return !!filterTags; }, - @discourseComputed() - shouldShowTags() { - return this.siteSettings.tagging_enabled; + @discourseComputed("siteSettings.tagging_enabled", "shouldShowTagsByGroup") + shouldShowTags(tagging_enabled, shouldShowTagsByGroup) { + return tagging_enabled && !shouldShowTagsByGroup; + }, + + @discourseComputed( + "siteSettings.show_tags_by_group", + "siteSettings.docs_tag_groups" + ) + shouldShowTagsByGroup(show_tags_by_group, docs_tag_groups) { + return show_tags_by_group && Boolean(docs_tag_groups); }, @discourseComputed() diff --git a/assets/javascripts/discourse/templates/docs-index.hbs b/assets/javascripts/discourse/templates/docs-index.hbs index 403ae1b..46e4003 100644 --- a/assets/javascripts/discourse/templates/docs-index.hbs +++ b/assets/javascripts/discourse/templates/docs-index.hbs @@ -139,6 +139,68 @@ {{/if}} + {{#if (and tagGroups shouldShowTagsByGroup)}} +
+
+

{{i18n "docs.tags"}}

+
+ + + +
+
+ {{#if showTagFilter}} + + {{/if}} + +
    + {{#each sortedTagGroups as |tagGroup|}} +
  • + {{tagGroup.name}} +
      + {{#each tagGroup.tags as |tag|}} +
    • + +
    • + {{/each}} +
    +
  • + {{/each}} +
+
+ {{/if}} {{/if}} diff --git a/config/locales/server.en.yml b/config/locales/server.en.yml index 5d00c9f..8700caa 100644 --- a/config/locales/server.en.yml +++ b/config/locales/server.en.yml @@ -4,5 +4,7 @@ en: docs_categories: "A list of category slugs to include in docs" docs_tags: "A list of tags to include in docs" docs_add_solved_filter: "Adds a filter for solved topics -- requires Discourse Solved to be installed and enabled" + show_tags_by_group: "Organize tags using Tag Groups. Create groups to categorize related tags." + docs_tag_groups: "The Group Tags used to show tags by group." docs_add_to_top_menu: "Adds a link to the top menu to navigate to the Docs view" docs_add_search_menu_tip: "Adds the tip \"in:docs\" to the search menu random tips" diff --git a/config/settings.yml b/config/settings.yml index 881c0fa..e9224eb 100644 --- a/config/settings.yml +++ b/config/settings.yml @@ -6,6 +6,13 @@ plugins: type: category_list default: "" client: true + show_tags_by_group: + default: false + client: true + docs_tag_groups: + type: tag_group_list + default: "" + client: true docs_tags: type: tag_list default: "" diff --git a/lib/docs/query.rb b/lib/docs/query.rb index 0f366dd..e86b982 100644 --- a/lib/docs/query.rb +++ b/lib/docs/query.rb @@ -21,7 +21,8 @@ module Docs opts = { no_definitions: true, limit: false } tq = TopicQuery.new(@user, opts) results = tq.list_docs_topics - results = results.left_outer_joins(:tags) + results = + results.left_outer_joins(SiteSetting.show_tags_by_group ? { tags: :tag_groups } : :tags) results = results.references(:categories) results = results.where("topics.category_id IN (?)", Query.categories).or( @@ -99,8 +100,25 @@ module Docs INNER JOIN topic_tags ttx ON ttx.topic_id = topics.id INNER JOIN tags t2 ON t2.id = ttx.tag_id SQL - tags = count_query.group("t2.name").reorder("").count - tags = create_tags_object(tags) + + if SiteSetting.show_tags_by_group + enabled_tag_groups = SiteSetting.docs_tag_groups.split("|") + subquery = TagGroup.where(name: enabled_tag_groups).select(:id) + results = results.joins(tags: :tag_groups).where(tag_groups: { id: subquery }) + + tags = + count_query + .joins(tags: :tag_groups) + .where(tag_groups: { id: subquery }) + .group("tag_groups.id", "tag_groups.name", "tags.name") + .reorder("") + .count + + tags = create_group_tags_object(tags) + else + tags = count_query.group("t2.name").reorder("").count + tags = create_tags_object(tags) + end categories = results @@ -145,7 +163,33 @@ module Docs topic_list["load_more_url"] = nil end - { tags: tags, categories: categories, topics: topic_list, topic_count: results_length } + tags_key = SiteSetting.show_tags_by_group ? :tag_groups : :tags + { + tags_key => tags, + :categories => categories, + :topics => topic_list, + :topic_count => results_length, + } + end + + def create_group_tags_object(tags) + tags_hash = ActiveSupport::OrderedHash.new + allowed_tags = DiscourseTagging.filter_allowed_tags(Guardian.new(@user)).map(&:name) + + tags.each do |group_tags_data, count| + group_tag_id, group_tag_name, tag_name = group_tags_data + active = @filters[:tags]&.include?(tag_name) + + tags_hash[group_tag_id] ||= { id: group_tag_id, name: group_tag_name, tags: [] } + tags_hash[group_tag_id][:tags] << { id: tag_name, count: count, active: active } + end + + tags_hash + .transform_values do |group| + group[:tags] = group[:tags].filter { |tag| allowed_tags.include?(tag[:id]) } + group + end + .values end def create_tags_object(tags) diff --git a/spec/requests/docs_controller_spec.rb b/spec/requests/docs_controller_spec.rb index fee15d5..a23c49b 100644 --- a/spec/requests/docs_controller_spec.rb +++ b/spec/requests/docs_controller_spec.rb @@ -8,6 +8,14 @@ describe Docs::DocsController do fab!(:topic2) { Fabricate(:topic, title: "I love pineapple today", category: category) } fab!(:tag) { Fabricate(:tag, topics: [topic], name: "test") } + def get_tag_attributes(tag) + { "id" => tag.name, "count" => 1 } + end + + def get_tags_from_response(response_tags) + response_tags.map { |tag| tag.except("active") } + end + before do SiteSetting.tagging_enabled = true SiteSetting.docs_enabled = true @@ -97,6 +105,57 @@ describe Docs::DocsController do expect(tags.size).to eq(3) expect(topics.size).to eq(1) end + + context "when show_tags_by_group is enabled" do + fab!(:tag4) { Fabricate(:tag, topics: [topic], name: "test4") } + + fab!(:tag_group_1) { Fabricate(:tag_group, name: "test-test2", tag_names: %w[test test2]) } + fab!(:tag_group_2) do + Fabricate(:tag_group, name: "test3-test4", tag_names: %w[test3 test4]) + end + fab!(:non_docs_tag_group) do + Fabricate(:tag_group, name: "non-docs-group", tag_names: %w[test3]) + end + fab!(:empty_tag_group) { Fabricate(:tag_group, name: "empty-group") } + + let(:docs_json_path) { "/#{GlobalSetting.docs_path}.json" } + let(:parsed_body) { response.parsed_body } + let(:tag_groups) { parsed_body["tag_groups"] } + let(:tag_ids) { tag_groups.map { |group| group["id"] } } + + before do + SiteSetting.show_tags_by_group = true + SiteSetting.docs_tag_groups = "test-test2|test3-test4" + get docs_json_path + end + + it "should add groups to the tags attribute" do + get docs_json_path + expect(get_tags_from_response(tag_groups[0]["tags"])).to contain_exactly( + *[tag, tag2].map { |t| get_tag_attributes(t) }, + ) + expect(get_tags_from_response(tag_groups[1]["tags"])).to contain_exactly( + *[tag3, tag4].map { |t| get_tag_attributes(t) }, + ) + end + + it "only displays tag groups that are enabled" do + SiteSetting.docs_tag_groups = "test3-test4" + get docs_json_path + expect(tag_groups.size).to eq(1) + expect(get_tags_from_response(tag_groups[0]["tags"])).to contain_exactly( + *[tag3, tag4].map { |t| get_tag_attributes(t) }, + ) + end + + it "does not return tag groups without tags" do + expect(tag_ids).not_to include(empty_tag_group.id) + end + + it "does not return non-docs tag groups" do + expect(tag_ids).not_to include(non_docs_tag_group.id) + end + end end context "when filtering by category" do diff --git a/test/javascripts/acceptance/docs-test.js b/test/javascripts/acceptance/docs-test.js index e263fdc..68f95d9 100644 --- a/test/javascripts/acceptance/docs-test.js +++ b/test/javascripts/acceptance/docs-test.js @@ -6,6 +6,7 @@ import { } from "discourse/tests/helpers/qunit-helpers"; import { test } from "qunit"; import docsFixtures from "../fixtures/docs"; +import docsShowTagGroupsFixtures from "../fixtures/docs-show-tag-groups"; import { click, visit } from "@ember/test-helpers"; let DOCS_URL_PATH = "docs"; @@ -73,6 +74,82 @@ acceptance("Docs", function (needs) { }); }); +acceptance("Docs - with tag groups enabled", function (needs) { + needs.user(); + needs.site({ docs_path: DOCS_URL_PATH }); + needs.settings({ + docs_enabled: true, + navigation_menu: "legacy", + }); + + function getRootElementText(selector) { + return Array.from(query(selector).childNodes) + .filter((node) => node.nodeType === Node.TEXT_NODE) + .map((node) => node.textContent.trim()) + .join(""); + } + + function assertTagGroup(assert, tagGroup) { + let groupTagSelector = `.docs-filter-tag-group-${tagGroup.id}`; + assert.equal( + getRootElementText(groupTagSelector), + tagGroup.expectedTagGroupName + ); + assert.equal( + query(`${groupTagSelector} .docs-tag .docs-item-id`).innerText, + tagGroup.expectedTagName + ); + assert.equal( + query(`${groupTagSelector} .docs-tag .docs-item-count`).innerText, + tagGroup.expectedCount + ); + } + + needs.pretender((server, helper) => { + server.get("/" + DOCS_URL_PATH + ".json", () => { + return helper.response(docsShowTagGroupsFixtures); + }); + }); + + test("Show tag groups", async function (assert) { + this.siteSettings.tagging_enabled = true; + this.siteSettings.show_tags_by_group = true; + this.siteSettings.docs_tag_groups = + "my-tag-group-1|my-tag-group-2|my-tag-group-3"; + + await visit("/"); + await click("#toggle-hamburger-menu"); + await click(".docs-link"); + + assert.equal(query(".docs-category .docs-item-id").innerText, "bug"); + assert.equal(query(".docs-category .docs-item-count").innerText, "119"); + + const expectedTagGroups = [ + { + id: "1", + expectedTagGroupName: "my-tag-group-1", + expectedTagName: "something 1", + expectedCount: "50", + }, + { + id: "2", + expectedTagGroupName: "my-tag-group-2", + expectedTagName: "something 2", + expectedCount: "10", + }, + { + id: "3", + expectedTagGroupName: "my-tag-group-3", + expectedTagName: "something 3", + expectedCount: "1", + }, + ]; + + for (let tagGroup of expectedTagGroups) { + assertTagGroup(assert, tagGroup); + } + }); +}); acceptance("Docs - empty state", function (needs) { needs.user(); needs.site({ docs_path: DOCS_URL_PATH }); diff --git a/test/javascripts/fixtures/docs-show-tag-groups.js b/test/javascripts/fixtures/docs-show-tag-groups.js new file mode 100644 index 0000000..349c47b --- /dev/null +++ b/test/javascripts/fixtures/docs-show-tag-groups.js @@ -0,0 +1,135 @@ +export default { + tag_groups: [ + { + id: 1, + name: "my-tag-group-1", + tags: [ + { + id: "something 1", + count: 50, + active: true, + }, + { + id: "something 2", + count: 10, + active: true, + }, + ], + }, + { + id: 2, + name: "my-tag-group-2", + tags: [ + { + id: "something 2", + count: 10, + active: true, + }, + ], + }, + { + id: 3, + name: "my-tag-group-3", + tags: [ + { + id: "something 3", + count: 1, + active: false, + }, + ], + }, + { + id: 4, + name: "my-tag-group-4", + tags: [ + { + id: "something 4", + count: 1, + active: false, + }, + ], + }, + ], + categories: [ + { + id: 1, + count: 119, + active: false, + }, + ], + topics: { + users: [ + { + id: 2, + username: "cvx", + name: "Jarek", + avatar_template: "/letter_avatar/cvx/{size}/2.png", + }, + ], + primary_groups: [], + topic_list: { + can_create_topic: true, + draft: null, + draft_key: "new_topic", + draft_sequence: 94, + per_page: 30, + top_tags: ["something"], + topics: [ + { + id: 54881, + title: "Importing from Software X", + fancy_title: "Importing from Software X", + slug: "importing-from-software-x", + posts_count: 112, + reply_count: 72, + highest_post_number: 122, + image_url: null, + created_at: "2016-12-28T14:59:29.396Z", + last_posted_at: "2020-11-14T16:21:35.720Z", + bumped: true, + bumped_at: "2020-11-14T16:21:35.720Z", + archetype: "regular", + unseen: false, + pinned: false, + unpinned: null, + visible: true, + closed: false, + archived: false, + bookmarked: null, + liked: null, + tags: ["something"], + views: 15222, + like_count: 167, + has_summary: true, + last_poster_username: "cvx", + category_id: 1, + pinned_globally: false, + featured_link: null, + has_accepted_answer: false, + posters: [ + { + extras: null, + description: "Original Poster", + user_id: 2, + primary_group_id: null, + }, + { + extras: null, + description: "Frequent Poster", + user_id: 2, + primary_group_id: null, + }, + { + extras: "latest", + description: "Most Recent Poster", + user_id: 2, + primary_group_id: null, + }, + ], + }, + ], + }, + load_more_url: "/docs.json?page=1", + }, + search_count: null, +};