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