From af9ed9ec600d89fc960f072bb3c78da0fb2feafe Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Jonas=20W=C3=A4lter?= <jonas.waelter@noser.com>
Date: Wed, 13 Oct 2021 07:06:32 +0000
Subject: [PATCH] Project topics: Add management in admin area

---
 .../pages/admin/topics/edit/index.js          |   8 ++
 .../pages/admin/topics/new/index.js           |   8 ++
 .../admin/topics/avatars_controller.rb        |  14 ++
 app/controllers/admin/topics_controller.rb    |  57 ++++++++
 app/controllers/uploads_controller.rb         |   5 +-
 app/finders/projects/topics_finder.rb         |  29 ++++
 app/helpers/avatars_helper.rb                 |   4 +
 app/models/concerns/avatarable.rb             |   1 +
 app/models/projects/project_topic.rb          |   2 +-
 app/models/projects/topic.rb                  |  17 +++
 app/views/admin/topics/_form.html.haml        |  40 ++++++
 app/views/admin/topics/_topic.html.haml       |  17 +++
 app/views/admin/topics/edit.html.haml         |   4 +
 app/views/admin/topics/index.html.haml        |  19 +++
 app/views/admin/topics/new.html.haml          |   4 +
 .../layouts/nav/sidebar/_admin.html.haml      |   6 +-
 .../shared/empty_states/_topics.html.haml     |   7 +
 app/views/shared/notes/_hints.html.haml       |  56 ++++----
 .../shared/topics/_search_form.html.haml      |   7 +
 config/routes/admin.rb                        |   7 +
 config/routes/uploads.rb                      |   4 +-
 ...0211004075629_add_topics_name_gin_index.rb |  15 ++
 ...4_add_topics_total_projects_count_cache.rb |  11 ++
 ...0_add_topics_total_projects_count_index.rb |  15 ++
 ...ulate_topics_total_projects_count_cache.rb |  23 +++
 db/schema_migrations/20211004075629           |   1 +
 db/schema_migrations/20211006060254           |   1 +
 db/schema_migrations/20211006060436           |   1 +
 db/schema_migrations/20211006122010           |   1 +
 db/structure.sql                              |   5 +
 doc/development/file_storage.md               |   3 +
 doc/user/admin_area/index.md                  |  22 ++-
 ...ulate_topics_total_projects_count_cache.rb |  29 ++++
 locale/gitlab.pot                             |  39 ++++++
 .../admin/topics/avatars_controller_spec.rb   |  20 +++
 .../admin/topics_controller_spec.rb           | 131 ++++++++++++++++++
 spec/controllers/uploads_controller_spec.rb   |  40 ++++++
 spec/finders/projects/topics_finder_spec.rb   |  45 ++++++
 spec/helpers/avatars_helper_spec.rb           |  14 +-
 ..._topics_total_projects_count_cache_spec.rb |  35 +++++
 ..._topics_total_projects_count_cache_spec.rb |  29 ++++
 spec/models/projects/topic_spec.rb            |  78 ++++++++++-
 .../nav/sidebar/_admin.html.haml_spec.rb      |   9 ++
 43 files changed, 848 insertions(+), 35 deletions(-)
 create mode 100644 app/assets/javascripts/pages/admin/topics/edit/index.js
 create mode 100644 app/assets/javascripts/pages/admin/topics/new/index.js
 create mode 100644 app/controllers/admin/topics/avatars_controller.rb
 create mode 100644 app/controllers/admin/topics_controller.rb
 create mode 100644 app/finders/projects/topics_finder.rb
 create mode 100644 app/views/admin/topics/_form.html.haml
 create mode 100644 app/views/admin/topics/_topic.html.haml
 create mode 100644 app/views/admin/topics/edit.html.haml
 create mode 100644 app/views/admin/topics/index.html.haml
 create mode 100644 app/views/admin/topics/new.html.haml
 create mode 100644 app/views/shared/empty_states/_topics.html.haml
 create mode 100644 app/views/shared/topics/_search_form.html.haml
 create mode 100644 db/migrate/20211004075629_add_topics_name_gin_index.rb
 create mode 100644 db/migrate/20211006060254_add_topics_total_projects_count_cache.rb
 create mode 100644 db/migrate/20211006122010_add_topics_total_projects_count_index.rb
 create mode 100644 db/post_migrate/20211006060436_schedule_populate_topics_total_projects_count_cache.rb
 create mode 100644 db/schema_migrations/20211004075629
 create mode 100644 db/schema_migrations/20211006060254
 create mode 100644 db/schema_migrations/20211006060436
 create mode 100644 db/schema_migrations/20211006122010
 create mode 100644 lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb
 create mode 100644 spec/controllers/admin/topics/avatars_controller_spec.rb
 create mode 100644 spec/controllers/admin/topics_controller_spec.rb
 create mode 100644 spec/finders/projects/topics_finder_spec.rb
 create mode 100644 spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb
 create mode 100644 spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb

diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
new file mode 100644
index 0000000000000..c4e05bbd092fd
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/assets/javascripts/pages/admin/topics/new/index.js b/app/assets/javascripts/pages/admin/topics/new/index.js
new file mode 100644
index 0000000000000..c4e05bbd092fd
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/new/index.js
@@ -0,0 +1,8 @@
+import $ from 'jquery';
+import GLForm from '~/gl_form';
+import initFilePickers from '~/file_pickers';
+import ZenMode from '~/zen_mode';
+
+new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
+initFilePickers();
+new ZenMode(); // eslint-disable-line no-new
diff --git a/app/controllers/admin/topics/avatars_controller.rb b/app/controllers/admin/topics/avatars_controller.rb
new file mode 100644
index 0000000000000..7acdec424b401
--- /dev/null
+++ b/app/controllers/admin/topics/avatars_controller.rb
@@ -0,0 +1,14 @@
+# frozen_string_literal: true
+
+class Admin::Topics::AvatarsController < Admin::ApplicationController
+  feature_category :projects
+
+  def destroy
+    @topic = Projects::Topic.find(params[:topic_id])
+
+    @topic.remove_avatar!
+    @topic.save
+
+    redirect_to edit_admin_topic_path(@topic), status: :found
+  end
+end
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
new file mode 100644
index 0000000000000..ccc38ba7cd5bc
--- /dev/null
+++ b/app/controllers/admin/topics_controller.rb
@@ -0,0 +1,57 @@
+# frozen_string_literal: true
+
+class Admin::TopicsController < Admin::ApplicationController
+  include SendFileUpload
+  include PreviewMarkdown
+
+  before_action :topic, only: [:edit, :update]
+
+  feature_category :projects
+
+  def index
+    @topics = Projects::TopicsFinder.new(params: params.permit(:search)).execute.page(params[:page]).without_count
+  end
+
+  def new
+    @topic = Projects::Topic.new
+  end
+
+  def edit
+  end
+
+  def create
+    @topic = Projects::Topic.new(topic_params)
+
+    if @topic.save
+      redirect_to edit_admin_topic_path(@topic), notice: _('Topic %{topic_name} was successfully created.') % { topic_name: @topic.name }
+    else
+      render "new"
+    end
+  end
+
+  def update
+    if @topic.update(topic_params)
+      redirect_to edit_admin_topic_path(@topic), notice: _('Topic was successfully updated.')
+    else
+      render "edit"
+    end
+  end
+
+  private
+
+  def topic
+    @topic ||= Projects::Topic.find(params[:id])
+  end
+
+  def topic_params
+    params.require(:projects_topic).permit(allowed_topic_params)
+  end
+
+  def allowed_topic_params
+    [
+      :avatar,
+      :description,
+      :name
+    ]
+  end
+end
diff --git a/app/controllers/uploads_controller.rb b/app/controllers/uploads_controller.rb
index d040ac7f76ca4..d7eb3ccd27472 100644
--- a/app/controllers/uploads_controller.rb
+++ b/app/controllers/uploads_controller.rb
@@ -13,6 +13,7 @@ class UploadsController < ApplicationController
     "group"            => Group,
     "appearance"       => Appearance,
     "personal_snippet" => PersonalSnippet,
+    "projects/topic"   => Projects::Topic,
     nil                => PersonalSnippet
   }.freeze
 
@@ -54,6 +55,8 @@ def authorize_access!
         !secret? || can?(current_user, :update_user, model)
       when Appearance
         true
+      when Projects::Topic
+        true
       else
         permission = "read_#{model.class.underscore}".to_sym
 
@@ -85,7 +88,7 @@ def render_unauthorized
 
   def cache_settings
     case model
-    when User, Appearance
+    when User, Appearance, Projects::Topic
       [5.minutes, { public: true, must_revalidate: false }]
     when Project, Group
       [5.minutes, { private: true, must_revalidate: true }]
diff --git a/app/finders/projects/topics_finder.rb b/app/finders/projects/topics_finder.rb
new file mode 100644
index 0000000000000..7c3abc27cf731
--- /dev/null
+++ b/app/finders/projects/topics_finder.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+# Used to filter project topics by a set of params
+#
+# Arguments:
+#   params:
+#     search: string
+module Projects
+  class TopicsFinder
+    def initialize(params: {})
+      @params = params
+    end
+
+    def execute
+      topics = Projects::Topic.order_by_total_projects_count
+      by_search(topics)
+    end
+
+    private
+
+    attr_reader :current_user, :params
+
+    def by_search(topics)
+      return topics unless params[:search].present?
+
+      topics.search(params[:search]).reorder_by_similarity(params[:search])
+    end
+  end
+end
diff --git a/app/helpers/avatars_helper.rb b/app/helpers/avatars_helper.rb
index 4cfa1528d9b20..dd852a68682df 100644
--- a/app/helpers/avatars_helper.rb
+++ b/app/helpers/avatars_helper.rb
@@ -9,6 +9,10 @@ def group_icon(group, options = {})
     source_icon(group, options)
   end
 
+  def topic_icon(topic, options = {})
+    source_icon(topic, options)
+  end
+
   # Takes both user and email and returns the avatar_icon by
   # user (preferred) or email.
   def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
diff --git a/app/models/concerns/avatarable.rb b/app/models/concerns/avatarable.rb
index 53cc6aabcce73..b32502c3ee295 100644
--- a/app/models/concerns/avatarable.rb
+++ b/app/models/concerns/avatarable.rb
@@ -18,6 +18,7 @@ module Avatarable
     prepend ShadowMethods
     include ObjectStorage::BackgroundMove
     include Gitlab::Utils::StrongMemoize
+    include ApplicationHelper
 
     validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
     validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed?
diff --git a/app/models/projects/project_topic.rb b/app/models/projects/project_topic.rb
index d4b456ef48285..7021a48646a32 100644
--- a/app/models/projects/project_topic.rb
+++ b/app/models/projects/project_topic.rb
@@ -3,6 +3,6 @@
 module Projects
   class ProjectTopic < ApplicationRecord
     belongs_to :project
-    belongs_to :topic
+    belongs_to :topic, counter_cache: :total_projects_count
   end
 end
diff --git a/app/models/projects/topic.rb b/app/models/projects/topic.rb
index 468cce785e0b4..f3352ecc5ee30 100644
--- a/app/models/projects/topic.rb
+++ b/app/models/projects/topic.rb
@@ -1,13 +1,30 @@
 # frozen_string_literal: true
 
+require 'carrierwave/orm/activerecord'
+
 module Projects
   class Topic < ApplicationRecord
     include Avatarable
+    include Gitlab::SQL::Pattern
 
     validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
     validates :description, length: { maximum: 1024 }
 
     has_many :project_topics, class_name: 'Projects::ProjectTopic'
     has_many :projects, through: :project_topics
+
+    scope :order_by_total_projects_count, -> { order(total_projects_count: :desc).order(id: :asc) }
+    scope :reorder_by_similarity, -> (search) do
+      order_expression = Gitlab::Database::SimilarityScore.build_expression(search: search, rules: [
+        { column: arel_table['name'] }
+      ])
+      reorder(order_expression.desc, arel_table['total_projects_count'].desc, arel_table['id'])
+    end
+
+    class << self
+      def search(query)
+        fuzzy_search(query, [:name])
+      end
+    end
   end
 end
diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml
new file mode 100644
index 0000000000000..21a1d74a8c682
--- /dev/null
+++ b/app/views/admin/topics/_form.html.haml
@@ -0,0 +1,40 @@
+= gitlab_ui_form_for @topic, url: url, html: { multipart: true, class: 'js-project-topic-form gl-show-field-errors common-note-form js-quick-submit js-requires-input' }, authenticity_token: true do |f|
+  = form_errors(@topic)
+
+  .form-group
+    = f.label :name do
+      = _("Topic name")
+    = f.text_field :name, placeholder: _('My topic'), class: 'form-control input-lg', data: { qa_selector: 'topic_name_field' },
+      required: true,
+      title: _('Please fill in a name for your topic.'),
+      autofocus: true
+
+  .form-group
+    = f.label :description, _("Description")
+    = render layout: 'shared/md_preview', locals: { url: preview_markdown_admin_topics_path, referenced_users: false } do
+      = render 'shared/zen', f: f, attr: :description,
+                               classes: 'note-textarea',
+                               placeholder: _('Write a description…'),
+                               supports_quick_actions: false,
+                               supports_autocomplete: false,
+                               qa_selector: 'topic_form_description'
+      = render 'shared/notes/hints', supports_file_upload: false
+
+  .form-group.gl-mt-3.gl-mb-3
+    = f.label :avatar, _('Topic avatar'), class: 'gl-display-block'
+    - if @topic.avatar?
+      .avatar-container.rect-avatar.s90
+        = topic_icon(@topic, alt: _('Topic avatar'), class: 'avatar topic-avatar s90')
+    = render 'shared/choose_avatar_button', f: f
+    - if @topic.avatar?
+      = link_to _('Remove avatar'), admin_topic_avatar_path(@topic), data: { confirm: _('Avatar will be removed. Are you sure?')}, method: :delete, class: 'gl-button btn btn-danger-secondary gl-mt-2'
+
+  - if @topic.new_record?
+    .form-actions
+      = f.submit _('Create topic'), class: "gl-button btn btn-confirm"
+      = link_to  _('Cancel'), admin_topics_path, class: "gl-button btn btn-default btn-cancel"
+
+  - else
+    .form-actions
+      = f.submit _('Save changes'), class: "gl-button btn btn-confirm", data: { qa_selector: 'save_changes_button' }
+      = link_to  _('Cancel'), admin_topics_path, class: "gl-button btn btn-cancel"
diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml
new file mode 100644
index 0000000000000..abf3cffa422a3
--- /dev/null
+++ b/app/views/admin/topics/_topic.html.haml
@@ -0,0 +1,17 @@
+- topic = local_assigns.fetch(:topic)
+
+%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'topic_row_content' } }
+  .avatar-container.rect-avatar.s40.gl-flex-shrink-0
+    = topic_icon(topic, class: "avatar s40")
+
+  .gl-min-w-0.gl-flex-grow-1
+    .title
+      = topic.name
+
+  .stats.gl-text-gray-500.gl-flex-shrink-0.gl-display-none.gl-sm-display-flex
+    %span.gl-ml-5.has-tooltip{ title: n_('%d project', '%d projects', topic.total_projects_count) % topic.total_projects_count }
+      = sprite_icon('bookmark', css_class: 'gl-vertical-align-text-bottom')
+      = number_with_delimiter(topic.total_projects_count)
+
+  .controls.gl-flex-shrink-0.gl-ml-5
+    = link_to _('Edit'), edit_admin_topic_path(topic), id: "edit_#{dom_id(topic)}", class: 'btn gl-button btn-default'
diff --git a/app/views/admin/topics/edit.html.haml b/app/views/admin/topics/edit.html.haml
new file mode 100644
index 0000000000000..4416bb0fe1836
--- /dev/null
+++ b/app/views/admin/topics/edit.html.haml
@@ -0,0 +1,4 @@
+- page_title _("Edit"), @topic.name, _("Topics")
+%h3.page-title= _('Edit topic: %{topic_name}') % { topic_name: @topic.name }
+%hr
+= render 'form', url: admin_topic_path(@topic)
diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml
new file mode 100644
index 0000000000000..6485b8aa4116e
--- /dev/null
+++ b/app/views/admin/topics/index.html.haml
@@ -0,0 +1,19 @@
+- page_title _("Topics")
+
+= form_tag admin_topics_path, method: :get do |f|
+  .gl-py-3.gl-display-flex.gl-flex-direction-column-reverse.gl-md-flex-direction-row.gl-border-b-solid.gl-border-gray-100.gl-border-b-1
+    .gl-flex-grow-1.gl-mt-3.gl-md-mt-0
+      .inline.gl-w-full.gl-md-w-auto
+        - search = params.fetch(:search, nil)
+        .search-field-holder
+          = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' }
+          = sprite_icon('search', css_class: 'search-icon')
+    .nav-controls
+      = link_to new_admin_topic_path, class: "gl-button btn btn-confirm gl-w-full gl-md-w-auto" do
+        = _('New topic')
+%ul.content-list
+  = render partial: 'topic', collection: @topics
+
+= paginate_collection @topics
+- if @topics.empty?
+  = render 'shared/empty_states/topics'
diff --git a/app/views/admin/topics/new.html.haml b/app/views/admin/topics/new.html.haml
new file mode 100644
index 0000000000000..8b4a8ac269e83
--- /dev/null
+++ b/app/views/admin/topics/new.html.haml
@@ -0,0 +1,4 @@
+- page_title _("New topic")
+%h3.page-title= _('New topic')
+%hr
+= render 'form', url: admin_topics_path(@topic)
diff --git a/app/views/layouts/nav/sidebar/_admin.html.haml b/app/views/layouts/nav/sidebar/_admin.html.haml
index 0a91194db5173..842fb23d24a6b 100644
--- a/app/views/layouts/nav/sidebar/_admin.html.haml
+++ b/app/views/layouts/nav/sidebar/_admin.html.haml
@@ -7,7 +7,7 @@
         %span.sidebar-context-title
           = _('Admin Area')
     %ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } }
-      = nav_link(controller: %w(dashboard admin admin/projects users groups jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
+      = nav_link(controller: %w(dashboard admin admin/projects users groups admin/topics jobs runners gitaly_servers cohorts), html_options: {class: 'home'}) do
         = link_to admin_root_path, class: 'has-sub-items' do
           .nav-icon-container
             = sprite_icon('overview')
@@ -35,6 +35,10 @@
             = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do
               %span
                 = _('Groups')
+          = nav_link(controller: [:admin, 'admin/topics']) do
+            = link_to admin_topics_path, title: _('Topics'), data: { qa_selector: 'topics_overview_link' } do
+              %span
+                = _('Topics')
           = nav_link path: 'jobs#index' do
             = link_to admin_jobs_path, title: _('Jobs') do
               %span
diff --git a/app/views/shared/empty_states/_topics.html.haml b/app/views/shared/empty_states/_topics.html.haml
new file mode 100644
index 0000000000000..fd82a853037d1
--- /dev/null
+++ b/app/views/shared/empty_states/_topics.html.haml
@@ -0,0 +1,7 @@
+.row.empty-state
+  .col-12
+    .svg-content
+      = image_tag 'illustrations/labels.svg', data: { qa_selector: 'svg_content' }
+    .text-content.gl-text-center.gl-pt-0!
+      %h4= _('There are no topics to show.')
+      %p= _('Add topics to projects to help users find them.')
diff --git a/app/views/shared/notes/_hints.html.haml b/app/views/shared/notes/_hints.html.haml
index a03e8446f5d0b..6231f81770444 100644
--- a/app/views/shared/notes/_hints.html.haml
+++ b/app/views/shared/notes/_hints.html.haml
@@ -1,4 +1,5 @@
 - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
+- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
 .comment-toolbar.clearfix
   .toolbar-text
     = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank'
@@ -10,33 +11,34 @@
       is
     supported
 
-  %span.uploading-container
-    %span.uploading-progress-container.hide
-      = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
-      %span.attaching-file-message
-        -# Populated by app/assets/javascripts/dropzone_input.js
-      %span.uploading-progress 0%
-      = loading_icon(css_class: 'align-text-bottom gl-mr-2')
-
-    %span.uploading-error-container.hide
-      %span.uploading-error-icon
+  - if supports_file_upload
+    %span.uploading-container
+      %span.uploading-progress-container.hide
         = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
-      %span.uploading-error-message
-        -# Populated by app/assets/javascripts/dropzone_input.js
-      %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
-        %span.gl-button-text
-          = _("Try again")
-      = _("or")
-      %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
-        %span.gl-button-text
-          = _("attach a new file")
-      = _(".")
+        %span.attaching-file-message
+          -# Populated by app/assets/javascripts/dropzone_input.js
+        %span.uploading-progress 0%
+        = loading_icon(css_class: 'align-text-bottom gl-mr-2')
 
-    %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
-      = sprite_icon('media')
-      %span.gl-button-text
-        = _("Attach a file")
+      %span.uploading-error-container.hide
+        %span.uploading-error-icon
+          = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
+        %span.uploading-error-message
+          -# Populated by app/assets/javascripts/dropzone_input.js
+        %button.btn.gl-button.btn-link.gl-vertical-align-baseline.retry-uploading-link
+          %span.gl-button-text
+            = _("Try again")
+        = _("or")
+        %button.btn.gl-button.btn-link.attach-new-file.markdown-selector.gl-vertical-align-baseline
+          %span.gl-button-text
+            = _("attach a new file")
+        = _(".")
 
-    %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
-      %span.gl-button-text
-        = _("Cancel")
+      %button.btn.gl-button.btn-link.button-attach-file.markdown-selector.button-attach-file.gl-vertical-align-text-bottom
+        = sprite_icon('media')
+        %span.gl-button-text
+          = _("Attach a file")
+
+      %button.btn.gl-button.btn-link.button-cancel-uploading-files.gl-vertical-align-baseline.hide
+        %span.gl-button-text
+          = _("Cancel")
diff --git a/app/views/shared/topics/_search_form.html.haml b/app/views/shared/topics/_search_form.html.haml
new file mode 100644
index 0000000000000..97343983b3c42
--- /dev/null
+++ b/app/views/shared/topics/_search_form.html.haml
@@ -0,0 +1,7 @@
+= form_tag page_filter_path, method: :get, class: "topic-filter-form js-topic-filter-form", id: 'topic-filter-form' do |f|
+  = search_field_tag :search, params[:search],
+    placeholder: s_('Filter by name'),
+    class: 'topic-filter-form-field form-control input-short',
+    spellcheck: false,
+    id: 'topic-filter-form-field',
+    autofocus: local_assigns[:autofocus]
diff --git a/config/routes/admin.rb b/config/routes/admin.rb
index a17059c026513..dac1937b76a7f 100644
--- a/config/routes/admin.rb
+++ b/config/routes/admin.rb
@@ -61,6 +61,13 @@
     end
   end
 
+  resources :topics, only: [:index, :new, :create, :edit, :update] do
+    resource :avatar, controller: 'topics/avatars', only: [:destroy]
+    collection do
+      post :preview_markdown
+    end
+  end
+
   resources :deploy_keys, only: [:index, :new, :create, :edit, :update, :destroy]
 
   resources :hooks, only: [:index, :create, :edit, :update, :destroy] do
diff --git a/config/routes/uploads.rb b/config/routes/uploads.rb
index 71a868175a9f5..e2cdf8ba60625 100644
--- a/config/routes/uploads.rb
+++ b/config/routes/uploads.rb
@@ -1,10 +1,10 @@
 # frozen_string_literal: true
 
 scope path: :uploads do
-  # Note attachments and User/Group/Project avatars
+  # Note attachments and User/Group/Project/Topic avatars
   get "-/system/:model/:mounted_as/:id/:filename",
       to:           "uploads#show",
-      constraints:  { model: /note|user|group|project/, mounted_as: /avatar|attachment/, filename: %r{[^/]+} }
+      constraints:  { model: %r{note|user|group|project|projects\/topic}, mounted_as: /avatar|attachment/, filename: %r{[^/]+} }
 
   # show uploads for models, snippets (notes) available for now
   get '-/system/:model/:id/:secret/:filename',
diff --git a/db/migrate/20211004075629_add_topics_name_gin_index.rb b/db/migrate/20211004075629_add_topics_name_gin_index.rb
new file mode 100644
index 0000000000000..94634a4cb2f97
--- /dev/null
+++ b/db/migrate/20211004075629_add_topics_name_gin_index.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddTopicsNameGinIndex < Gitlab::Database::Migration[1.0]
+  INDEX_NAME = 'index_topics_on_name_trigram'
+
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_index :topics, :name, name: INDEX_NAME, using: :gin, opclass: { name: :gin_trgm_ops }
+  end
+
+  def down
+    remove_concurrent_index_by_name :topics, INDEX_NAME
+  end
+end
diff --git a/db/migrate/20211006060254_add_topics_total_projects_count_cache.rb b/db/migrate/20211006060254_add_topics_total_projects_count_cache.rb
new file mode 100644
index 0000000000000..ebca4c7087941
--- /dev/null
+++ b/db/migrate/20211006060254_add_topics_total_projects_count_cache.rb
@@ -0,0 +1,11 @@
+# frozen_string_literal: true
+
+class AddTopicsTotalProjectsCountCache < Gitlab::Database::Migration[1.0]
+  def up
+    add_column :topics, :total_projects_count, :bigint, null: false, default: 0
+  end
+
+  def down
+    remove_column :topics, :total_projects_count
+  end
+end
diff --git a/db/migrate/20211006122010_add_topics_total_projects_count_index.rb b/db/migrate/20211006122010_add_topics_total_projects_count_index.rb
new file mode 100644
index 0000000000000..bd969a9ff0a1f
--- /dev/null
+++ b/db/migrate/20211006122010_add_topics_total_projects_count_index.rb
@@ -0,0 +1,15 @@
+# frozen_string_literal: true
+
+class AddTopicsTotalProjectsCountIndex < Gitlab::Database::Migration[1.0]
+  INDEX_NAME = 'index_topics_total_projects_count'
+
+  disable_ddl_transaction!
+
+  def up
+    add_concurrent_index :topics, [:total_projects_count, :id], order: { total_projects_count: :desc }, name: INDEX_NAME
+  end
+
+  def down
+    remove_concurrent_index_by_name :topics, INDEX_NAME
+  end
+end
diff --git a/db/post_migrate/20211006060436_schedule_populate_topics_total_projects_count_cache.rb b/db/post_migrate/20211006060436_schedule_populate_topics_total_projects_count_cache.rb
new file mode 100644
index 0000000000000..b14a9ab88b937
--- /dev/null
+++ b/db/post_migrate/20211006060436_schedule_populate_topics_total_projects_count_cache.rb
@@ -0,0 +1,23 @@
+# frozen_string_literal: true
+
+class SchedulePopulateTopicsTotalProjectsCountCache < Gitlab::Database::Migration[1.0]
+  MIGRATION = 'PopulateTopicsTotalProjectsCountCache'
+  BATCH_SIZE = 10_000
+  DELAY_INTERVAL = 2.minutes
+
+  disable_ddl_transaction!
+
+  def up
+    queue_background_migration_jobs_by_range_at_intervals(
+      define_batchable_model('topics'),
+      MIGRATION,
+      DELAY_INTERVAL,
+      batch_size: BATCH_SIZE,
+      track_jobs: true
+    )
+  end
+
+  def down
+    # no-op
+  end
+end
diff --git a/db/schema_migrations/20211004075629 b/db/schema_migrations/20211004075629
new file mode 100644
index 0000000000000..d55f73707989a
--- /dev/null
+++ b/db/schema_migrations/20211004075629
@@ -0,0 +1 @@
+e035616201329b7610e8c3a647bc01c52ce722790ea7bb88d4a38bc0feb4737e
\ No newline at end of file
diff --git a/db/schema_migrations/20211006060254 b/db/schema_migrations/20211006060254
new file mode 100644
index 0000000000000..2891170a092ac
--- /dev/null
+++ b/db/schema_migrations/20211006060254
@@ -0,0 +1 @@
+0d6ec7c1d96f32c645ddc051d8e3b3bd0ad759c52c8938888287b1c6b57d27a3
\ No newline at end of file
diff --git a/db/schema_migrations/20211006060436 b/db/schema_migrations/20211006060436
new file mode 100644
index 0000000000000..e2374c092c793
--- /dev/null
+++ b/db/schema_migrations/20211006060436
@@ -0,0 +1 @@
+918852db691546e4e93a933789968115ac98b5757d480ed1e09118508e6024d5
\ No newline at end of file
diff --git a/db/schema_migrations/20211006122010 b/db/schema_migrations/20211006122010
new file mode 100644
index 0000000000000..6758ab4978a31
--- /dev/null
+++ b/db/schema_migrations/20211006122010
@@ -0,0 +1 @@
+19efbbf7aab5837e33ff72d87e101a76da7eeb1d60c05ffc0ceddad1d0cbc69c
\ No newline at end of file
diff --git a/db/structure.sql b/db/structure.sql
index 9ecb7857d501d..dee1d5f06c2fb 100644
--- a/db/structure.sql
+++ b/db/structure.sql
@@ -19667,6 +19667,7 @@ CREATE TABLE topics (
     updated_at timestamp with time zone NOT NULL,
     avatar text,
     description text,
+    total_projects_count bigint DEFAULT 0 NOT NULL,
     CONSTRAINT check_26753fb43a CHECK ((char_length(avatar) <= 255)),
     CONSTRAINT check_5d1a07c8c8 CHECK ((char_length(description) <= 1024)),
     CONSTRAINT check_7a90d4c757 CHECK ((char_length(name) <= 255))
@@ -26742,6 +26743,10 @@ CREATE UNIQUE INDEX index_token_with_ivs_on_hashed_token ON token_with_ivs USING
 
 CREATE UNIQUE INDEX index_topics_on_name ON topics USING btree (name);
 
+CREATE INDEX index_topics_on_name_trigram ON topics USING gin (name gin_trgm_ops);
+
+CREATE INDEX index_topics_total_projects_count ON topics USING btree (total_projects_count DESC, id);
+
 CREATE UNIQUE INDEX index_trending_projects_on_project_id ON trending_projects USING btree (project_id);
 
 CREATE INDEX index_u2f_registrations_on_key_handle ON u2f_registrations USING btree (key_handle);
diff --git a/doc/development/file_storage.md b/doc/development/file_storage.md
index 71fc81a6ea31c..d161206f44dbf 100644
--- a/doc/development/file_storage.md
+++ b/doc/development/file_storage.md
@@ -28,6 +28,8 @@ There are many places where file uploading is used, according to contexts:
   - LFS Objects
   - Merge request diffs
   - Design Management design thumbnails
+- Topic
+  - Topic avatars
 
 ## Disk storage
 
@@ -42,6 +44,7 @@ they are still not 100% standardized. You can see them below:
 | User avatars                          | yes    | `uploads/-/system/user/avatar/:id/:filename`                  | `AvatarUploader`       | User       |
 | User snippet attachments              | yes    | `uploads/-/system/personal_snippet/:id/:random_hex/:filename` | `PersonalFileUploader` | Snippet    |
 | Project avatars                       | yes    | `uploads/-/system/project/avatar/:id/:filename`               | `AvatarUploader`       | Project    |
+| Topic avatars                         | yes    | `uploads/-/system/projects/topic/avatar/:id/:filename`        | `AvatarUploader`       | Topic      |
 | Issues/MR/Notes Markdown attachments  | yes    | `uploads/:project_path_with_namespace/:random_hex/:filename`  | `FileUploader`         | Project    |
 | Issues/MR/Notes Legacy Markdown attachments | no | `uploads/-/system/note/attachment/:id/:filename`            | `AttachmentUploader`   | Note       |
 | Design Management design thumbnails   | yes | `uploads/-/system/design_management/action/image_v432x230/:id/:filename` | `DesignManagement::DesignV432x230Uploader` | DesignManagement::Action |
diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md
index 380e3f3dcc5ad..27d2bd8f7646c 100644
--- a/doc/user/admin_area/index.md
+++ b/doc/user/admin_area/index.md
@@ -24,7 +24,7 @@ The Admin Area is made up of the following sections:
 
 | Section                                        | Description |
 |:-----------------------------------------------|:------------|
-| **{overview}** [Overview](#overview-section)   | View your GitLab [Dashboard](#admin-area-dashboard), and administer [projects](#administering-projects), [users](#administering-users), [groups](#administering-groups), [jobs](#administering-jobs), [runners](#administering-runners), and [Gitaly servers](#administering-gitaly-servers). |
+| **{overview}** [Overview](#overview-section)   | View your GitLab [Dashboard](#admin-area-dashboard), and administer [projects](#administering-projects), [users](#administering-users), [groups](#administering-groups), [topics](#administering-topics), [jobs](#administering-jobs), [runners](#administering-runners), and [Gitaly servers](#administering-gitaly-servers). |
 | **{monitor}** Monitoring                       | View GitLab [system information](#system-information), and information on [background jobs](#background-jobs), [logs](#logs), [health checks](monitoring/health_check.md), [requests profiles](#requests-profiles), and [audit events](#audit-events). |
 | **{messages}** Messages                        | Send and manage [broadcast messages](broadcast_messages.md) for your users. |
 | **{hook}** System Hooks                        | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. |
@@ -237,6 +237,26 @@ insensitive, and applies partial matching.
 
 To [Create a new group](../group/index.md#create-a-group) click **New group**.
 
+### Administering Topics
+
+> [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/340920) in GitLab 14.4.
+
+You can administer all topics in the GitLab instance from the Admin Area's Topics page.
+
+To access the Topics page:
+
+1. On the top bar, select **Menu > Admin**.
+1. On the left sidebar, select **Overview > Topics**.
+
+For each topic, the page displays their name and number of projects labeled with the topic.
+
+To create a new topic, select **New topic**.
+
+To edit a topic, select **Edit** in that topic's row.
+
+To search for topics by name, enter your criteria in the search box. The topic search is case
+insensitive, and applies partial matching.
+
 ### Administering Jobs
 
 You can administer all jobs in the GitLab instance from the Admin Area's Jobs page.
diff --git a/lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb b/lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb
new file mode 100644
index 0000000000000..1d96872d44540
--- /dev/null
+++ b/lib/gitlab/background_migration/populate_topics_total_projects_count_cache.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+module Gitlab
+  module BackgroundMigration
+    SUB_BATCH_SIZE = 1_000
+
+    # The class to populates the total projects counter cache of topics
+    class PopulateTopicsTotalProjectsCountCache
+      # Temporary AR model for topics
+      class Topic < ActiveRecord::Base
+        include EachBatch
+
+        self.table_name = 'topics'
+      end
+
+      def perform(start_id, stop_id)
+        Topic.where(id: start_id..stop_id).each_batch(of: SUB_BATCH_SIZE) do |batch|
+          ActiveRecord::Base.connection.execute(<<~SQL)
+            WITH batched_relation AS #{Gitlab::Database::AsWithMaterialized.materialized_if_supported} (#{batch.select(:id).limit(SUB_BATCH_SIZE).to_sql})
+            UPDATE topics
+            SET total_projects_count = (SELECT COUNT(*) FROM project_topics WHERE topic_id = batched_relation.id)
+            FROM batched_relation
+            WHERE topics.id = batched_relation.id
+          SQL
+        end
+      end
+    end
+  end
+end
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 87fab20908d74..f62faf64c75d3 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -2108,6 +2108,9 @@ msgstr ""
 msgid "Add to tree"
 msgstr ""
 
+msgid "Add topics to projects to help users find them."
+msgstr ""
+
 msgid "Add trigger"
 msgstr ""
 
@@ -9708,6 +9711,9 @@ msgstr ""
 msgid "Create tag %{tagName}"
 msgstr ""
 
+msgid "Create topic"
+msgstr ""
+
 msgid "Create user"
 msgstr ""
 
@@ -12369,6 +12375,9 @@ msgstr ""
 msgid "Edit title and description"
 msgstr ""
 
+msgid "Edit topic: %{topic_name}"
+msgstr ""
+
 msgid "Edit user: %{user_name}"
 msgstr ""
 
@@ -22390,6 +22399,9 @@ msgstr ""
 msgid "My company or team"
 msgstr ""
 
+msgid "My topic"
+msgstr ""
+
 msgid "My-Reaction"
 msgstr ""
 
@@ -22883,6 +22895,9 @@ msgstr ""
 msgid "New test case"
 msgstr ""
 
+msgid "New topic"
+msgstr ""
+
 msgid "New users set to external"
 msgstr ""
 
@@ -25571,6 +25586,9 @@ msgstr ""
 msgid "Please fill in a descriptive name for your group."
 msgstr ""
 
+msgid "Please fill in a name for your topic."
+msgstr ""
+
 msgid "Please fill out this field."
 msgstr ""
 
@@ -34347,6 +34365,9 @@ msgstr ""
 msgid "There are no projects shared with this group yet"
 msgstr ""
 
+msgid "There are no topics to show."
+msgstr ""
+
 msgid "There are no variables yet."
 msgstr ""
 
@@ -35839,6 +35860,21 @@ msgstr ""
 msgid "TopNav|Go back"
 msgstr ""
 
+msgid "Topic %{topic_name} was successfully created."
+msgstr ""
+
+msgid "Topic avatar"
+msgstr ""
+
+msgid "Topic name"
+msgstr ""
+
+msgid "Topic was successfully updated."
+msgstr ""
+
+msgid "Topics"
+msgstr ""
+
 msgid "Topics (optional)"
 msgstr ""
 
@@ -38705,6 +38741,9 @@ msgstr ""
 msgid "Write a description or drag your files here…"
 msgstr ""
 
+msgid "Write a description…"
+msgstr ""
+
 msgid "Write milestone description..."
 msgstr ""
 
diff --git a/spec/controllers/admin/topics/avatars_controller_spec.rb b/spec/controllers/admin/topics/avatars_controller_spec.rb
new file mode 100644
index 0000000000000..7edc0e0c4979e
--- /dev/null
+++ b/spec/controllers/admin/topics/avatars_controller_spec.rb
@@ -0,0 +1,20 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::Topics::AvatarsController do
+  let(:user) { create(:admin) }
+  let(:topic) { create(:topic, avatar: fixture_file_upload("spec/fixtures/dk.png")) }
+
+  before do
+    sign_in(user)
+    controller.instance_variable_set(:@topic, topic)
+  end
+
+  it 'removes avatar from DB by calling destroy' do
+    delete :destroy, params: { topic_id: topic.id }
+    @topic = assigns(:topic)
+    expect(@topic.avatar.present?).to be_falsey
+    expect(@topic).to be_valid
+  end
+end
diff --git a/spec/controllers/admin/topics_controller_spec.rb b/spec/controllers/admin/topics_controller_spec.rb
new file mode 100644
index 0000000000000..6d66cb4333850
--- /dev/null
+++ b/spec/controllers/admin/topics_controller_spec.rb
@@ -0,0 +1,131 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Admin::TopicsController do
+  let_it_be(:topic) { create(:topic, name: 'topic') }
+  let_it_be(:admin) { create(:admin) }
+  let_it_be(:user) { create(:user) }
+
+  before do
+    sign_in(admin)
+  end
+
+  describe 'GET #index' do
+    it 'renders the template' do
+      get :index
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to render_template('index')
+    end
+
+    context 'as a normal user' do
+      before do
+        sign_in(user)
+      end
+
+      it 'renders a 404 error' do
+        get :index
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+
+  describe 'GET #new' do
+    it 'renders the template' do
+      get :new
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to render_template('new')
+    end
+
+    context 'as a normal user' do
+      before do
+        sign_in(user)
+      end
+
+      it 'renders a 404 error' do
+        get :new
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+
+  describe 'GET #edit' do
+    it 'renders the template' do
+      get :edit, params: { id: topic.id }
+
+      expect(response).to have_gitlab_http_status(:ok)
+      expect(response).to render_template('edit')
+    end
+
+    context 'as a normal user' do
+      before do
+        sign_in(user)
+      end
+
+      it 'renders a 404 error' do
+        get :edit, params: { id: topic.id }
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+
+  describe 'POST #create' do
+    it 'creates topic' do
+      expect do
+        post :create, params: { projects_topic: { name: 'test' } }
+      end.to change { Projects::Topic.count }.by(1)
+    end
+
+    it 'shows error message for invalid topic' do
+      post :create, params: { projects_topic: { name: nil } }
+
+      errors = assigns[:topic].errors
+      expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.blank')))
+    end
+
+    context 'as a normal user' do
+      before do
+        sign_in(user)
+      end
+
+      it 'renders a 404 error' do
+        post :create, params: { projects_topic: { name: 'test' } }
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+
+  describe 'PUT #update' do
+    it 'updates topic' do
+      put :update, params: { id: topic.id, projects_topic: { name: 'test' } }
+
+      expect(response).to redirect_to(edit_admin_topic_path(topic))
+      expect(topic.reload.name).to eq('test')
+    end
+
+    it 'shows error message for invalid topic' do
+      put :update, params: { id: topic.id, projects_topic: { name: nil } }
+
+      errors = assigns[:topic].errors
+      expect(errors).to contain_exactly(errors.full_message(:name, I18n.t('errors.messages.blank')))
+    end
+
+    context 'as a normal user' do
+      before do
+        sign_in(user)
+      end
+
+      it 'renders a 404 error' do
+        put :update, params: { id: topic.id, projects_topic: { name: 'test' } }
+
+        expect(response).to have_gitlab_http_status(:not_found)
+      end
+    end
+  end
+end
diff --git a/spec/controllers/uploads_controller_spec.rb b/spec/controllers/uploads_controller_spec.rb
index 2aa9b86b20ea3..8442c214cd3c8 100644
--- a/spec/controllers/uploads_controller_spec.rb
+++ b/spec/controllers/uploads_controller_spec.rb
@@ -599,6 +599,46 @@
       end
     end
 
+    context "when viewing a topic avatar" do
+      let!(:topic) { create(:topic, avatar: fixture_file_upload("spec/fixtures/dk.png", "image/png")) }
+
+      context "when signed in" do
+        before do
+          sign_in(user)
+        end
+
+        it "responds with status 200" do
+          get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" }
+
+          expect(response).to have_gitlab_http_status(:ok)
+        end
+
+        it_behaves_like 'content publicly cached' do
+          subject do
+            get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" }
+
+            response
+          end
+        end
+      end
+
+      context "when not signed in" do
+        it "responds with status 200" do
+          get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" }
+
+          expect(response).to have_gitlab_http_status(:ok)
+        end
+
+        it_behaves_like 'content publicly cached' do
+          subject do
+            get :show, params: { model: "projects/topic", mounted_as: "avatar", id: topic.id, filename: "dk.png" }
+
+            response
+          end
+        end
+      end
+    end
+
     context 'Appearance' do
       context 'when viewing a custom header logo' do
         let!(:appearance) { create :appearance, header_logo: fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
diff --git a/spec/finders/projects/topics_finder_spec.rb b/spec/finders/projects/topics_finder_spec.rb
new file mode 100644
index 0000000000000..28802c5d49e55
--- /dev/null
+++ b/spec/finders/projects/topics_finder_spec.rb
@@ -0,0 +1,45 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Projects::TopicsFinder do
+  let_it_be(:user) { create(:user) }
+
+  let!(:topic1) { create(:topic, name: 'topicB') }
+  let!(:topic2) { create(:topic, name: 'topicC') }
+  let!(:topic3) { create(:topic, name: 'topicA') }
+
+  let!(:project1) { create(:project, namespace: user.namespace, topic_list: 'topicC, topicA, topicB') }
+  let!(:project2) { create(:project, namespace: user.namespace, topic_list: 'topicC, topicA') }
+  let!(:project3) { create(:project, namespace: user.namespace, topic_list: 'topicC') }
+
+  describe '#execute' do
+    it 'returns topics' do
+      topics = described_class.new.execute
+
+      expect(topics).to eq([topic2, topic3, topic1])
+    end
+
+    context 'filter by name' do
+      using RSpec::Parameterized::TableSyntax
+
+      where(:search, :result) do
+        'topic'  | %w[topicC topicA topicB]
+        'pic'    | %w[topicC topicA topicB]
+        'B'      | %w[]
+        'cB'     | %w[]
+        'icB'    | %w[topicB]
+        'topicA' | %w[topicA]
+        'topica' | %w[topicA]
+      end
+
+      with_them do
+        it 'returns filtered topics' do
+          topics = described_class.new(params: { search: search }).execute
+
+          expect(topics.map(&:name)).to eq(result)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/helpers/avatars_helper_spec.rb b/spec/helpers/avatars_helper_spec.rb
index 047a6ca0b7df9..7190f2fcd4afc 100644
--- a/spec/helpers/avatars_helper_spec.rb
+++ b/spec/helpers/avatars_helper_spec.rb
@@ -7,7 +7,7 @@
 
   let_it_be(:user) { create(:user) }
 
-  describe '#project_icon & #group_icon' do
+  describe '#project_icon, #group_icon, #topic_icon' do
     shared_examples 'resource with a default avatar' do |source_type|
       it 'returns a default avatar div' do
         expect(public_send("#{source_type}_icon", *helper_args))
@@ -71,6 +71,18 @@
         let(:helper_args) { [resource] }
       end
     end
+
+    context 'when providing a topic' do
+      it_behaves_like 'resource with a default avatar', 'topic' do
+        let(:resource) { create(:topic, name: 'foo') }
+        let(:helper_args) { [resource] }
+      end
+
+      it_behaves_like 'resource with a custom avatar', 'topic' do
+        let(:resource) { create(:topic, avatar: File.open(uploaded_image_temp_path)) }
+        let(:helper_args) { [resource] }
+      end
+    end
   end
 
   describe '#avatar_icon_for' do
diff --git a/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb b/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb
new file mode 100644
index 0000000000000..8e07b43f5b969
--- /dev/null
+++ b/spec/lib/gitlab/background_migration/populate_topics_total_projects_count_cache_spec.rb
@@ -0,0 +1,35 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+
+RSpec.describe Gitlab::BackgroundMigration::PopulateTopicsTotalProjectsCountCache, schema: 20211006060436 do
+  it 'correctly populates total projects count cache' do
+    namespaces = table(:namespaces)
+    projects = table(:projects)
+    topics = table(:topics)
+    project_topics = table(:project_topics)
+
+    group = namespaces.create!(name: 'group', path: 'group')
+    project_1 = projects.create!(namespace_id: group.id)
+    project_2 = projects.create!(namespace_id: group.id)
+    project_3 = projects.create!(namespace_id: group.id)
+    topic_1 = topics.create!(name: 'Topic1')
+    topic_2 = topics.create!(name: 'Topic2')
+    topic_3 = topics.create!(name: 'Topic3')
+    topic_4 = topics.create!(name: 'Topic4')
+
+    project_topics.create!(project_id: project_1.id, topic_id: topic_1.id)
+    project_topics.create!(project_id: project_1.id, topic_id: topic_3.id)
+    project_topics.create!(project_id: project_2.id, topic_id: topic_3.id)
+    project_topics.create!(project_id: project_1.id, topic_id: topic_4.id)
+    project_topics.create!(project_id: project_2.id, topic_id: topic_4.id)
+    project_topics.create!(project_id: project_3.id, topic_id: topic_4.id)
+
+    subject.perform(topic_1.id, topic_4.id)
+
+    expect(topic_1.reload.total_projects_count).to eq(1)
+    expect(topic_2.reload.total_projects_count).to eq(0)
+    expect(topic_3.reload.total_projects_count).to eq(2)
+    expect(topic_4.reload.total_projects_count).to eq(3)
+  end
+end
diff --git a/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb
new file mode 100644
index 0000000000000..d07d9a71b06cb
--- /dev/null
+++ b/spec/migrations/20211006060436_schedule_populate_topics_total_projects_count_cache_spec.rb
@@ -0,0 +1,29 @@
+# frozen_string_literal: true
+
+require 'spec_helper'
+require_migration!('schedule_populate_topics_total_projects_count_cache')
+
+RSpec.describe SchedulePopulateTopicsTotalProjectsCountCache do
+  let(:topics) { table(:topics) }
+  let!(:topic_1) { topics.create!(name: 'Topic1') }
+  let!(:topic_2) { topics.create!(name: 'Topic2') }
+  let!(:topic_3) { topics.create!(name: 'Topic3') }
+
+  describe '#up' do
+    before do
+      stub_const("#{described_class}::BATCH_SIZE", 2)
+    end
+
+    it 'schedules BackfillProjectsWithCoverage background jobs', :aggregate_failures do
+      Sidekiq::Testing.fake! do
+        freeze_time do
+          migrate!
+
+          expect(described_class::MIGRATION).to be_scheduled_delayed_migration(2.minutes, topic_1.id, topic_2.id)
+          expect(described_class::MIGRATION).to be_scheduled_delayed_migration(4.minutes, topic_3.id, topic_3.id)
+          expect(BackgroundMigrationWorker.jobs.size).to eq(2)
+        end
+      end
+    end
+  end
+end
diff --git a/spec/models/projects/topic_spec.rb b/spec/models/projects/topic_spec.rb
index b794a8fe7c449..397c65f4d5c53 100644
--- a/spec/models/projects/topic_spec.rb
+++ b/spec/models/projects/topic_spec.rb
@@ -3,12 +3,18 @@
 require 'spec_helper'
 
 RSpec.describe Projects::Topic do
-  let_it_be(:topic, reload: true) { create(:topic) }
+  let_it_be(:topic, reload: true) { create(:topic, name: 'topic') }
 
   subject { topic }
 
   it { expect(subject).to be_valid }
 
+  describe 'modules' do
+    subject { described_class }
+
+    it { is_expected.to include_module(Avatarable) }
+  end
+
   describe 'associations' do
     it { is_expected.to have_many(:project_topics) }
     it { is_expected.to have_many(:projects) }
@@ -20,4 +26,74 @@
     it { is_expected.to validate_length_of(:name).is_at_most(255) }
     it { is_expected.to validate_length_of(:description).is_at_most(1024) }
   end
+
+  describe 'scopes' do
+    describe 'order_by_total_projects_count' do
+      let!(:topic1) { create(:topic, name: 'topicB') }
+      let!(:topic2) { create(:topic, name: 'topicC') }
+      let!(:topic3) { create(:topic, name: 'topicA') }
+      let!(:project1) { create(:project, topic_list: 'topicC, topicA, topicB') }
+      let!(:project2) { create(:project, topic_list: 'topicC, topicA') }
+      let!(:project3) { create(:project, topic_list: 'topicC') }
+
+      it 'sorts topics by total_projects_count' do
+        topics = described_class.order_by_total_projects_count
+
+        expect(topics.map(&:name)).to eq(%w[topicC topicA topicB topic])
+      end
+    end
+
+    describe 'reorder_by_similarity' do
+      let!(:topic1) { create(:topic, name: 'my-topic') }
+      let!(:topic2) { create(:topic, name: 'other') }
+      let!(:topic3) { create(:topic, name: 'topic2') }
+
+      it 'sorts topics by similarity' do
+        topics = described_class.reorder_by_similarity('topic')
+
+        expect(topics.map(&:name)).to eq(%w[topic my-topic topic2 other])
+      end
+    end
+  end
+
+  describe '#search' do
+    it 'returns topics with a matching name' do
+      expect(described_class.search(topic.name)).to eq([topic])
+    end
+
+    it 'returns topics with a partially matching name' do
+      expect(described_class.search(topic.name[0..2])).to eq([topic])
+    end
+
+    it 'returns topics with a matching name regardless of the casing' do
+      expect(described_class.search(topic.name.upcase)).to eq([topic])
+    end
+  end
+
+  describe '#avatar_type' do
+    it "is true if avatar is image" do
+      topic.update_attribute(:avatar, 'uploads/avatar.png')
+      expect(topic.avatar_type).to be_truthy
+    end
+
+    it "is false if avatar is html page" do
+      topic.update_attribute(:avatar, 'uploads/avatar.html')
+      topic.avatar_type
+
+      expect(topic.errors.added?(:avatar, "file format is not supported. Please try one of the following supported formats: png, jpg, jpeg, gif, bmp, tiff, ico, webp")).to be true
+    end
+  end
+
+  describe '#avatar_url' do
+    context 'when avatar file is uploaded' do
+      before do
+        topic.update!(avatar: fixture_file_upload("spec/fixtures/dk.png"))
+      end
+
+      it 'shows correct avatar url' do
+        expect(topic.avatar_url).to eq(topic.avatar.url)
+        expect(topic.avatar_url(only_path: false)).to eq([Gitlab.config.gitlab.url, topic.avatar.url].join)
+      end
+    end
+  end
 end
diff --git a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
index 2c37565328a30..d4e97d96dfde8 100644
--- a/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
+++ b/spec/views/layouts/nav/sidebar/_admin.html.haml_spec.rb
@@ -58,6 +58,15 @@
     it_behaves_like 'page has active sub tab', 'Users'
   end
 
+  context 'on topics' do
+    before do
+      allow(controller).to receive(:controller_name).and_return('admin/topics')
+    end
+
+    it_behaves_like 'page has active tab', 'Overview'
+    it_behaves_like 'page has active sub tab', 'Topics'
+  end
+
   context 'on messages' do
     before do
       allow(controller).to receive(:controller_name).and_return('broadcast_messages')
-- 
GitLab