Commit af9ed9ec authored by Jonas Wälter's avatar Jonas Wälter Committed by Heinrich Lee Yu

Project topics: Add management in admin area

parent 63bf35e0
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
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
# 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
# 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
...@@ -13,6 +13,7 @@ class UploadsController < ApplicationController ...@@ -13,6 +13,7 @@ class UploadsController < ApplicationController
"group" => Group, "group" => Group,
"appearance" => Appearance, "appearance" => Appearance,
"personal_snippet" => PersonalSnippet, "personal_snippet" => PersonalSnippet,
"projects/topic" => Projects::Topic,
nil => PersonalSnippet nil => PersonalSnippet
}.freeze }.freeze
...@@ -54,6 +55,8 @@ class UploadsController < ApplicationController ...@@ -54,6 +55,8 @@ class UploadsController < ApplicationController
!secret? || can?(current_user, :update_user, model) !secret? || can?(current_user, :update_user, model)
when Appearance when Appearance
true true
when Projects::Topic
true
else else
permission = "read_#{model.class.underscore}".to_sym permission = "read_#{model.class.underscore}".to_sym
...@@ -85,7 +88,7 @@ class UploadsController < ApplicationController ...@@ -85,7 +88,7 @@ class UploadsController < ApplicationController
def cache_settings def cache_settings
case model case model
when User, Appearance when User, Appearance, Projects::Topic
[5.minutes, { public: true, must_revalidate: false }] [5.minutes, { public: true, must_revalidate: false }]
when Project, Group when Project, Group
[5.minutes, { private: true, must_revalidate: true }] [5.minutes, { private: true, must_revalidate: true }]
......
# 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
...@@ -9,6 +9,10 @@ module AvatarsHelper ...@@ -9,6 +9,10 @@ module AvatarsHelper
source_icon(group, options) source_icon(group, options)
end end
def topic_icon(topic, options = {})
source_icon(topic, options)
end
# Takes both user and email and returns the avatar_icon by # Takes both user and email and returns the avatar_icon by
# user (preferred) or email. # user (preferred) or email.
def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true) def avatar_icon_for(user = nil, email = nil, size = nil, scale = 2, only_path: true)
......
...@@ -18,6 +18,7 @@ module Avatarable ...@@ -18,6 +18,7 @@ module Avatarable
prepend ShadowMethods prepend ShadowMethods
include ObjectStorage::BackgroundMove include ObjectStorage::BackgroundMove
include Gitlab::Utils::StrongMemoize include Gitlab::Utils::StrongMemoize
include ApplicationHelper
validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? } validate :avatar_type, if: ->(user) { user.avatar.present? && user.avatar_changed? }
validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed? validates :avatar, file_size: { maximum: MAXIMUM_FILE_SIZE }, if: :avatar_changed?
......
...@@ -3,6 +3,6 @@ ...@@ -3,6 +3,6 @@
module Projects module Projects
class ProjectTopic < ApplicationRecord class ProjectTopic < ApplicationRecord
belongs_to :project belongs_to :project
belongs_to :topic belongs_to :topic, counter_cache: :total_projects_count
end end
end end
# frozen_string_literal: true # frozen_string_literal: true
require 'carrierwave/orm/activerecord'
module Projects module Projects
class Topic < ApplicationRecord class Topic < ApplicationRecord
include Avatarable include Avatarable
include Gitlab::SQL::Pattern
validates :name, presence: true, uniqueness: true, length: { maximum: 255 } validates :name, presence: true, uniqueness: true, length: { maximum: 255 }
validates :description, length: { maximum: 1024 } validates :description, length: { maximum: 1024 }
has_many :project_topics, class_name: 'Projects::ProjectTopic' has_many :project_topics, class_name: 'Projects::ProjectTopic'
has_many :projects, through: :project_topics 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
end end
= 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"
- 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'
- 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)
- 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'
- page_title _("New topic")
%h3.page-title= _('New topic')
%hr
= render 'form', url: admin_topics_path(@topic)
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
%span.sidebar-context-title %span.sidebar-context-title
= _('Admin Area') = _('Admin Area')
%ul.sidebar-top-level-items{ data: { qa_selector: 'admin_sidebar_overview_submenu_content' } } %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 = link_to admin_root_path, class: 'has-sub-items' do
.nav-icon-container .nav-icon-container
= sprite_icon('overview') = sprite_icon('overview')
...@@ -35,6 +35,10 @@ ...@@ -35,6 +35,10 @@
= link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do = link_to admin_groups_path, title: _('Groups'), data: { qa_selector: 'groups_overview_link' } do
%span %span
= _('Groups') = _('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 = nav_link path: 'jobs#index' do
= link_to admin_jobs_path, title: _('Jobs') do = link_to admin_jobs_path, title: _('Jobs') do
%span %span
......
.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.')
- supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false) - supports_quick_actions = local_assigns.fetch(:supports_quick_actions, false)
- supports_file_upload = local_assigns.fetch(:supports_file_upload, true)
.comment-toolbar.clearfix .comment-toolbar.clearfix
.toolbar-text .toolbar-text
= link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank' = link_to _('Markdown'), help_page_path('user/markdown'), target: '_blank'
...@@ -10,6 +11,7 @@ ...@@ -10,6 +11,7 @@
is is
supported supported
- if supports_file_upload
%span.uploading-container %span.uploading-container
%span.uploading-progress-container.hide %span.uploading-progress-container.hide
= sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom') = sprite_icon('media', css_class: 'gl-icon gl-vertical-align-text-bottom')
......
= 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]
...@@ -61,6 +61,13 @@ namespace :admin do ...@@ -61,6 +61,13 @@ namespace :admin do
end end
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 :deploy_keys, only: [:index, :new, :create, :edit, :update, :destroy]
resources :hooks, only: [:index, :create, :edit, :update, :destroy] do resources :hooks, only: [:index, :create, :edit, :update, :destroy] do
......
# frozen_string_literal: true # frozen_string_literal: true
scope path: :uploads do 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", get "-/system/:model/:mounted_as/:id/:filename",
to: "uploads#show", 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 # show uploads for models, snippets (notes) available for now
get '-/system/:model/:id/:secret/:filename', get '-/system/:model/:id/:secret/:filename',
......
# 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
# 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
# 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
# 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
e035616201329b7610e8c3a647bc01c52ce722790ea7bb88d4a38bc0feb4737e
\ No newline at end of file
0d6ec7c1d96f32c645ddc051d8e3b3bd0ad759c52c8938888287b1c6b57d27a3
\ No newline at end of file
918852db691546e4e93a933789968115ac98b5757d480ed1e09118508e6024d5
\ No newline at end of file
19efbbf7aab5837e33ff72d87e101a76da7eeb1d60c05ffc0ceddad1d0cbc69c
\ No newline at end of file
...@@ -19667,6 +19667,7 @@ CREATE TABLE topics ( ...@@ -19667,6 +19667,7 @@ CREATE TABLE topics (
updated_at timestamp with time zone NOT NULL, updated_at timestamp with time zone NOT NULL,
avatar text, avatar text,
description text, description text,
total_projects_count bigint DEFAULT 0 NOT NULL,
CONSTRAINT check_26753fb43a CHECK ((char_length(avatar) <= 255)), CONSTRAINT check_26753fb43a CHECK ((char_length(avatar) <= 255)),
CONSTRAINT check_5d1a07c8c8 CHECK ((char_length(description) <= 1024)), CONSTRAINT check_5d1a07c8c8 CHECK ((char_length(description) <= 1024)),
CONSTRAINT check_7a90d4c757 CHECK ((char_length(name) <= 255)) 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 ...@@ -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 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 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); CREATE INDEX index_u2f_registrations_on_key_handle ON u2f_registrations USING btree (key_handle);
...@@ -28,6 +28,8 @@ There are many places where file uploading is used, according to contexts: ...@@ -28,6 +28,8 @@ There are many places where file uploading is used, according to contexts:
- LFS Objects - LFS Objects
- Merge request diffs - Merge request diffs
- Design Management design thumbnails - Design Management design thumbnails
- Topic
- Topic avatars
## Disk storage ## Disk storage
...@@ -42,6 +44,7 @@ they are still not 100% standardized. You can see them below: ...@@ -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 avatars | yes | `uploads/-/system/user/avatar/:id/:filename` | `AvatarUploader` | User |
| User snippet attachments | yes | `uploads/-/system/personal_snippet/:id/:random_hex/:filename` | `PersonalFileUploader` | Snippet | | 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 | | 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 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 | | 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 | | Design Management design thumbnails | yes | `uploads/-/system/design_management/action/image_v432x230/:id/:filename` | `DesignManagement::DesignV432x230Uploader` | DesignManagement::Action |
......
...@@ -24,7 +24,7 @@ The Admin Area is made up of the following sections: ...@@ -24,7 +24,7 @@ The Admin Area is made up of the following sections:
| Section | Description | | 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). | | **{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. | | **{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. | | **{hook}** System Hooks | Configure [system hooks](../../system_hooks/system_hooks.md) for many events. |
...@@ -237,6 +237,26 @@ insensitive, and applies partial matching. ...@@ -237,6 +237,26 @@ insensitive, and applies partial matching.
To [Create a new group](../group/index.md#create-a-group) click **New group**. 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 ### Administering Jobs
You can administer all jobs in the GitLab instance from the Admin Area's Jobs page. You can administer all jobs in the GitLab instance from the Admin Area's Jobs page.
......
# 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
...@@ -2108,6 +2108,9 @@ msgstr "" ...@@ -2108,6 +2108,9 @@ msgstr ""
msgid "Add to tree" msgid "Add to tree"
msgstr "" msgstr ""
msgid "Add topics to projects to help users find them."
msgstr ""
msgid "Add trigger" msgid "Add trigger"
msgstr "" msgstr ""
...@@ -9708,6 +9711,9 @@ msgstr "" ...@@ -9708,6 +9711,9 @@ msgstr ""
msgid "Create tag %{tagName}" msgid "Create tag %{tagName}"
msgstr "" msgstr ""
msgid "Create topic"
msgstr ""
msgid "Create user" msgid "Create user"
msgstr "" msgstr ""
...@@ -12369,6 +12375,9 @@ msgstr "" ...@@ -12369,6 +12375,9 @@ msgstr ""
msgid "Edit title and description" msgid "Edit title and description"
msgstr "" msgstr ""
msgid "Edit topic: %{topic_name}"
msgstr ""
msgid "Edit user: %{user_name}" msgid "Edit user: %{user_name}"
msgstr "" msgstr ""
...@@ -22390,6 +22399,9 @@ msgstr "" ...@@ -22390,6 +22399,9 @@ msgstr ""
msgid "My company or team" msgid "My company or team"
msgstr "" msgstr ""
msgid "My topic"
msgstr ""
msgid "My-Reaction" msgid "My-Reaction"
msgstr "" msgstr ""
...@@ -22883,6 +22895,9 @@ msgstr "" ...@@ -22883,6 +22895,9 @@ msgstr ""
msgid "New test case" msgid "New test case"
msgstr "" msgstr ""
msgid "New topic"
msgstr ""
msgid "New users set to external" msgid "New users set to external"
msgstr "" msgstr ""
...@@ -25571,6 +25586,9 @@ msgstr "" ...@@ -25571,6 +25586,9 @@ msgstr ""
msgid "Please fill in a descriptive name for your group." msgid "Please fill in a descriptive name for your group."
msgstr "" msgstr ""
msgid "Please fill in a name for your topic."
msgstr ""
msgid "Please fill out this field." msgid "Please fill out this field."
msgstr "" msgstr ""
...@@ -34347,6 +34365,9 @@ msgstr "" ...@@ -34347,6 +34365,9 @@ msgstr ""
msgid "There are no projects shared with this group yet" msgid "There are no projects shared with this group yet"
msgstr "" msgstr ""
msgid "There are no topics to show."
msgstr ""
msgid "There are no variables yet." msgid "There are no variables yet."
msgstr "" msgstr ""
...@@ -35839,6 +35860,21 @@ msgstr "" ...@@ -35839,6 +35860,21 @@ msgstr ""
msgid "TopNav|Go back" msgid "TopNav|Go back"
msgstr "" 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)" msgid "Topics (optional)"
msgstr "" msgstr ""
...@@ -38705,6 +38741,9 @@ msgstr "" ...@@ -38705,6 +38741,9 @@ msgstr ""
msgid "Write a description or drag your files here…" msgid "Write a description or drag your files here…"
msgstr "" msgstr ""
msgid "Write a description…"
msgstr ""
msgid "Write milestone description..." msgid "Write milestone description..."
msgstr "" msgstr ""
......
# 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
# 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
...@@ -599,6 +599,46 @@ RSpec.describe UploadsController do ...@@ -599,6 +599,46 @@ RSpec.describe UploadsController do
end end
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 'Appearance' do
context 'when viewing a custom header logo' do context 'when viewing a custom header logo' do
let!(:appearance) { create :appearance, header_logo: fixture_file_upload('spec/fixtures/dk.png', 'image/png') } let!(:appearance) { create :appearance, header_logo: fixture_file_upload('spec/fixtures/dk.png', 'image/png') }
......
# 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
...@@ -7,7 +7,7 @@ RSpec.describe AvatarsHelper do ...@@ -7,7 +7,7 @@ RSpec.describe AvatarsHelper do
let_it_be(:user) { create(:user) } 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| shared_examples 'resource with a default avatar' do |source_type|
it 'returns a default avatar div' do it 'returns a default avatar div' do
expect(public_send("#{source_type}_icon", *helper_args)) expect(public_send("#{source_type}_icon", *helper_args))
...@@ -71,6 +71,18 @@ RSpec.describe AvatarsHelper do ...@@ -71,6 +71,18 @@ RSpec.describe AvatarsHelper do
let(:helper_args) { [resource] } let(:helper_args) { [resource] }
end end
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 end
describe '#avatar_icon_for' do describe '#avatar_icon_for' do
......
# 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
# 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
...@@ -3,12 +3,18 @@ ...@@ -3,12 +3,18 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe Projects::Topic do 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 } subject { topic }
it { expect(subject).to be_valid } it { expect(subject).to be_valid }
describe 'modules' do
subject { described_class }
it { is_expected.to include_module(Avatarable) }
end
describe 'associations' do describe 'associations' do
it { is_expected.to have_many(:project_topics) } it { is_expected.to have_many(:project_topics) }
it { is_expected.to have_many(:projects) } it { is_expected.to have_many(:projects) }
...@@ -20,4 +26,74 @@ RSpec.describe Projects::Topic do ...@@ -20,4 +26,74 @@ RSpec.describe Projects::Topic do
it { is_expected.to validate_length_of(:name).is_at_most(255) } it { is_expected.to validate_length_of(:name).is_at_most(255) }
it { is_expected.to validate_length_of(:description).is_at_most(1024) } it { is_expected.to validate_length_of(:description).is_at_most(1024) }
end 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 end
...@@ -58,6 +58,15 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do ...@@ -58,6 +58,15 @@ RSpec.describe 'layouts/nav/sidebar/_admin' do
it_behaves_like 'page has active sub tab', 'Users' it_behaves_like 'page has active sub tab', 'Users'
end 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 context 'on messages' do
before do before do
allow(controller).to receive(:controller_name).and_return('broadcast_messages') allow(controller).to receive(:controller_name).and_return('broadcast_messages')
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment