Implement UI for Admin Search of Hashtags (#30880)
This commit is contained in:
		
							parent
							
								
									6d2ed0dcba
								
							
						
					
					
						commit
						c40e481169
					
				| @ -2,7 +2,15 @@ | ||||
| 
 | ||||
| module Admin | ||||
|   class TagsController < BaseController | ||||
|     before_action :set_tag | ||||
|     before_action :set_tag, except: [:index] | ||||
| 
 | ||||
|     PER_PAGE = 20 | ||||
| 
 | ||||
|     def index | ||||
|       authorize :tag, :index? | ||||
| 
 | ||||
|       @tags = filtered_tags.page(params[:page]).per(PER_PAGE) | ||||
|     end | ||||
| 
 | ||||
|     def show | ||||
|       authorize @tag, :show? | ||||
| @ -31,5 +39,13 @@ module Admin | ||||
|     def tag_params | ||||
|       params.require(:tag).permit(:name, :display_name, :trendable, :usable, :listable) | ||||
|     end | ||||
| 
 | ||||
|     def filtered_tags | ||||
|       TagFilter.new(filter_params.with_defaults(order: 'newest')).results | ||||
|     end | ||||
| 
 | ||||
|     def filter_params | ||||
|       params.slice(:page, *TagFilter::KEYS).permit(:page, *TagFilter::KEYS) | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | ||||
							
								
								
									
										15
									
								
								app/helpers/admin/tags_helper.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								app/helpers/admin/tags_helper.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,15 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| module Admin::TagsHelper | ||||
|   def admin_tags_moderation_options | ||||
|     [ | ||||
|       [t('admin.tags.moderation.reviewed'), 'reviewed'], | ||||
|       [t('admin.tags.moderation.review_requested'), 'review_requested'], | ||||
|       [t('admin.tags.moderation.unreviewed'), 'unreviewed'], | ||||
|       [t('admin.tags.moderation.trendable'), 'trendable'], | ||||
|       [t('admin.tags.moderation.not_trendable'), 'not_trendable'], | ||||
|       [t('admin.tags.moderation.usable'), 'usable'], | ||||
|       [t('admin.tags.moderation.not_usable'), 'not_usable'], | ||||
|     ] | ||||
|   end | ||||
| end | ||||
| @ -350,6 +350,10 @@ | ||||
|       color: $primary-text-color; | ||||
|       font-weight: 700; | ||||
|     } | ||||
| 
 | ||||
|     .warning-hint { | ||||
|       font-weight: normal !important; | ||||
|     } | ||||
|   } | ||||
| 
 | ||||
|   &__body { | ||||
|  | ||||
| @ -286,6 +286,10 @@ a.table-action-link { | ||||
|         padding: 0; | ||||
|       } | ||||
| 
 | ||||
|       &--padded { | ||||
|         padding: 12px 16px 16px; | ||||
|       } | ||||
| 
 | ||||
|       &--with-image { | ||||
|         display: flex; | ||||
|         align-items: center; | ||||
|  | ||||
							
								
								
									
										74
									
								
								app/models/admin/tag_filter.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										74
									
								
								app/models/admin/tag_filter.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,74 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| class Admin::TagFilter | ||||
|   KEYS = %i( | ||||
|     status | ||||
|     name | ||||
|     order | ||||
|   ).freeze | ||||
| 
 | ||||
|   attr_reader :params | ||||
| 
 | ||||
|   def initialize(params) | ||||
|     @params = params.to_h.symbolize_keys | ||||
|   end | ||||
| 
 | ||||
|   def results | ||||
|     scope = Tag.reorder(nil) | ||||
| 
 | ||||
|     params.each do |key, value| | ||||
|       next if key == :page | ||||
| 
 | ||||
|       scope.merge!(scope_for(key, value)) if value.present? | ||||
|     end | ||||
| 
 | ||||
|     scope | ||||
|   end | ||||
| 
 | ||||
|   private | ||||
| 
 | ||||
|   def scope_for(key, value) | ||||
|     case key | ||||
|     when :status | ||||
|       status_scope(value) | ||||
|     when :name | ||||
|       Tag.search_for(value.to_s.strip, params[:limit], params[:offset], exclude_unlistable: false) | ||||
|     when :order | ||||
|       order_scope(value) | ||||
|     else | ||||
|       raise Mastodon::InvalidParameterError, "Unknown filter: #{key}" | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def status_scope(value) | ||||
|     case value.to_s | ||||
|     when 'reviewed' | ||||
|       Tag.reviewed | ||||
|     when 'review_requested' | ||||
|       Tag.pending_review | ||||
|     when 'unreviewed' | ||||
|       Tag.unreviewed | ||||
|     when 'trendable' | ||||
|       Tag.trendable | ||||
|     when 'not_trendable' | ||||
|       Tag.not_trendable | ||||
|     when 'usable' | ||||
|       Tag.usable | ||||
|     when 'not_usable' | ||||
|       Tag.not_usable | ||||
|     else | ||||
|       raise Mastodon::InvalidParameterError, "Unknown status: #{value}" | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   def order_scope(value) | ||||
|     case value.to_s | ||||
|     when 'newest' | ||||
|       Tag.order(created_at: :desc) | ||||
|     when 'oldest' | ||||
|       Tag.order(created_at: :asc) | ||||
|     else | ||||
|       raise Mastodon::InvalidParameterError, "Unknown order: #{value}" | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -52,6 +52,7 @@ class Tag < ApplicationRecord | ||||
|   scope :unreviewed, -> { where(reviewed_at: nil) } | ||||
|   scope :pending_review, -> { unreviewed.where.not(requested_review_at: nil) } | ||||
|   scope :usable, -> { where(usable: [true, nil]) } | ||||
|   scope :not_usable, -> { where(usable: false) } | ||||
|   scope :listable, -> { where(listable: [true, nil]) } | ||||
|   scope :trendable, -> { Setting.trendable_by_default ? where(trendable: [true, nil]) : where(trendable: true) } | ||||
|   scope :not_trendable, -> { where(trendable: false) } | ||||
| @ -74,6 +75,10 @@ class Tag < ApplicationRecord | ||||
|     attributes['display_name'] || name | ||||
|   end | ||||
| 
 | ||||
|   def formatted_name | ||||
|     "##{display_name}" | ||||
|   end | ||||
| 
 | ||||
|   def usable | ||||
|     boolean_with_default('usable', true) | ||||
|   end | ||||
| @ -132,8 +137,10 @@ class Tag < ApplicationRecord | ||||
| 
 | ||||
|     def search_for(term, limit = 5, offset = 0, options = {}) | ||||
|       stripped_term = term.strip | ||||
|       options.reverse_merge!({ exclude_unlistable: true, exclude_unreviewed: false }) | ||||
| 
 | ||||
|       query = Tag.listable.matches_name(stripped_term) | ||||
|       query = Tag.matches_name(stripped_term) | ||||
|       query = query.merge(Tag.listable) if options[:exclude_unlistable] | ||||
|       query = query.merge(matching_name(stripped_term).or(where.not(reviewed_at: nil))) if options[:exclude_unreviewed] | ||||
| 
 | ||||
|       query.order(Arel.sql('length(name) ASC, name ASC')) | ||||
|  | ||||
							
								
								
									
										27
									
								
								app/views/admin/tags/_tag.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										27
									
								
								app/views/admin/tags/_tag.html.haml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,27 @@ | ||||
| .batch-table__row{ class: [!tag.requires_review? && !tag.usable? && 'batch-table__row--muted'] } | ||||
|   .batch-table__row__content.batch-table__row__content--padded.pending-account | ||||
|     .pending-account__header | ||||
|       %strong | ||||
|         = link_to tag.formatted_name, admin_tag_path(tag.id) | ||||
| 
 | ||||
|       %br/ | ||||
| 
 | ||||
|       - if tag.usable? | ||||
|         = t('admin.tags.moderation.usable') | ||||
|       - else | ||||
|         = t('admin.tags.moderation.not_usable') | ||||
| 
 | ||||
|       · | ||||
|       - if tag.trendable? | ||||
|         = t('admin.tags.moderation.trendable') | ||||
|       - else | ||||
|         = t('admin.tags.moderation.not_trendable') | ||||
| 
 | ||||
|       - if tag.requested_review? || tag.requires_review? | ||||
|         · | ||||
|         - if tag.requested_review? | ||||
|           %span.negative-hint | ||||
|             = t('admin.tags.moderation.review_requested') | ||||
|         - else | ||||
|           %span.warning-hint | ||||
|             = t('admin.tags.moderation.pending_review') | ||||
							
								
								
									
										39
									
								
								app/views/admin/tags/index.html.haml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								app/views/admin/tags/index.html.haml
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,39 @@ | ||||
| - content_for :page_title do | ||||
|   = t('admin.tags.title') | ||||
| 
 | ||||
| = form_with url: admin_tags_url, method: :get, class: :simple_form do |form| | ||||
|   .filters | ||||
|     .filter-subset.filter-subset--with-select | ||||
|       %strong= t('admin.tags.moderation.title') | ||||
|       .input.select.optional | ||||
|         = form.select :status, | ||||
|                       options_for_select(admin_tags_moderation_options, params[:status]), | ||||
|                       prompt: t('generic.all') | ||||
| 
 | ||||
|     .filter-subset.filter-subset--with-select | ||||
|       %strong= t 'generic.order_by' | ||||
|       .input.select | ||||
|         = form.select :order, | ||||
|                       options_for_select([[t('admin.tags.newest'), 'newest'], [t('admin.tags.oldest'), 'oldest']], params[:order]) | ||||
| 
 | ||||
|   .fields-group | ||||
|     .input.string.optional | ||||
|       = form.text_field :name, | ||||
|                         value: params[:name], | ||||
|                         class: 'string optional', | ||||
|                         placeholder: t('admin.tags.name') | ||||
| 
 | ||||
|   .actions | ||||
|     %button.button= t('admin.tags.search') | ||||
|     = link_to t('admin.tags.reset'), admin_tags_path, class: 'button negative' | ||||
| 
 | ||||
| %hr.spacer/ | ||||
| 
 | ||||
| .batch-table | ||||
|   .batch-table__body | ||||
|     - if @tags.empty? | ||||
|       = nothing_here 'nothing-here--under-tabs' | ||||
|     - else | ||||
|       = render partial: 'tag', collection: @tags | ||||
| 
 | ||||
| = paginate @tags | ||||
| @ -1,12 +1,13 @@ | ||||
| - content_for :page_title do | ||||
|   = "##{@tag.display_name}" | ||||
|   = @tag.formatted_name | ||||
| 
 | ||||
| - if current_user.can?(:view_dashboard) | ||||
|   - content_for :heading_actions do | ||||
| - content_for :heading_actions do | ||||
|   - if current_user.can?(:view_dashboard) | ||||
|     = l(@time_period.first) | ||||
|     = ' - ' | ||||
|     = l(@time_period.last) | ||||
| 
 | ||||
| - if current_user.can?(:view_dashboard) | ||||
|   .dashboard | ||||
|     .dashboard__item | ||||
|       = react_admin_component :counter, | ||||
|  | ||||
| @ -4,9 +4,7 @@ | ||||
| 
 | ||||
|   .batch-table__row__content.pending-account | ||||
|     .pending-account__header | ||||
|       = link_to admin_tag_path(tag.id) do | ||||
|         = material_symbol 'tag' | ||||
|         = tag.display_name | ||||
|       = link_to tag.formatted_name, admin_tag_path(tag.id) | ||||
| 
 | ||||
|       %br/ | ||||
| 
 | ||||
|  | ||||
| @ -885,7 +885,23 @@ en: | ||||
|         action: Check here for more information | ||||
|         message_html: "<strong>Your object storage is misconfigured. The privacy of your users is at risk.</strong>" | ||||
|     tags: | ||||
|       moderation: | ||||
|         not_trendable: Not trendable | ||||
|         not_usable: Not usable | ||||
|         pending_review: Pending review | ||||
|         review_requested: Review requested | ||||
|         reviewed: Reviewed | ||||
|         title: Status | ||||
|         trendable: Trendable | ||||
|         unreviewed: Unreviewed | ||||
|         usable: Usable | ||||
|       name: Name | ||||
|       newest: Newest | ||||
|       oldest: Oldest | ||||
|       reset: Reset | ||||
|       review: Review status | ||||
|       search: Search | ||||
|       title: Hashtags | ||||
|       updated_msg: Hashtag settings updated successfully | ||||
|     title: Administration | ||||
|     trends: | ||||
|  | ||||
| @ -314,7 +314,7 @@ en: | ||||
|         listable: Allow this hashtag to appear in searches and suggestions | ||||
|         name: Hashtag | ||||
|         trendable: Allow this hashtag to appear under trends | ||||
|         usable: Allow posts to use this hashtag | ||||
|         usable: Allow posts to use this hashtag locally | ||||
|       user: | ||||
|         role: Role | ||||
|         time_zone: Time zone | ||||
|  | ||||
| @ -40,13 +40,14 @@ SimpleNavigation::Configuration.run do |navigation| | ||||
| 
 | ||||
|     n.item :trends, safe_join([fa_icon('fire fw'), t('admin.trends.title')]), admin_trends_statuses_path, if: -> { current_user.can?(:manage_taxonomies) && !self_destruct } do |s| | ||||
|       s.item :statuses, safe_join([fa_icon('comments-o fw'), t('admin.trends.statuses.title')]), admin_trends_statuses_path, highlights_on: %r{/admin/trends/statuses} | ||||
|       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/tags|/admin/trends/tags} | ||||
|       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.trends.tags.title')]), admin_trends_tags_path, highlights_on: %r{/admin/trends/tags} | ||||
|       s.item :links, safe_join([fa_icon('newspaper-o fw'), t('admin.trends.links.title')]), admin_trends_links_path, highlights_on: %r{/admin/trends/links} | ||||
|     end | ||||
| 
 | ||||
|     n.item :moderation, safe_join([fa_icon('gavel fw'), t('moderation.title')]), nil, if: -> { current_user.can?(:manage_reports, :view_audit_log, :manage_users, :manage_invites, :manage_taxonomies, :manage_federation, :manage_blocks) && !self_destruct } do |s| | ||||
|       s.item :reports, safe_join([fa_icon('flag fw'), t('admin.reports.title')]), admin_reports_path, highlights_on: %r{/admin/reports|admin/report_notes}, if: -> { current_user.can?(:manage_reports) } | ||||
|       s.item :accounts, safe_join([fa_icon('users fw'), t('admin.accounts.title')]), admin_accounts_path(origin: 'local'), highlights_on: %r{/admin/accounts|admin/account_moderation_notes|/admin/pending_accounts|/admin/disputes|/admin/users}, if: -> { current_user.can?(:manage_users) } | ||||
|       s.item :tags, safe_join([fa_icon('hashtag fw'), t('admin.tags.title')]), admin_tags_path, highlights_on: %r{/admin/tags}, if: -> { current_user.can?(:manage_taxonomies) } | ||||
|       s.item :invites, safe_join([fa_icon('user-plus fw'), t('admin.invites.title')]), admin_invites_path, if: -> { current_user.can?(:manage_invites) } | ||||
|       s.item :follow_recommendations, safe_join([fa_icon('user-plus fw'), t('admin.follow_recommendations.title')]), admin_follow_recommendations_path, highlights_on: %r{/admin/follow_recommendations}, if: -> { current_user.can?(:manage_taxonomies) } | ||||
|       s.item :instances, safe_join([fa_icon('cloud fw'), t('admin.instances.title')]), admin_instances_path(limited: limited_federation_mode? ? nil : '1'), highlights_on: %r{/admin/instances|/admin/domain_blocks|/admin/domain_allows}, if: -> { current_user.can?(:manage_federation) } | ||||
|  | ||||
| @ -163,7 +163,7 @@ namespace :admin do | ||||
|   resources :roles, except: [:show] | ||||
|   resources :account_moderation_notes, only: [:create, :destroy] | ||||
|   resource :follow_recommendations, only: [:show, :update] | ||||
|   resources :tags, only: [:show, :update] | ||||
|   resources :tags, only: [:index, :show, :update] | ||||
| 
 | ||||
|   namespace :trends do | ||||
|     resources :links, only: [:index] do | ||||
|  | ||||
| @ -9,6 +9,43 @@ RSpec.describe Admin::TagsController do | ||||
|     sign_in Fabricate(:user, role: UserRole.find_by(name: 'Admin')) | ||||
|   end | ||||
| 
 | ||||
|   describe 'GET #index' do | ||||
|     before do | ||||
|       Fabricate(:tag) | ||||
| 
 | ||||
|       tag_filter = instance_double(Admin::TagFilter, results: Tag.all) | ||||
|       allow(Admin::TagFilter).to receive(:new).and_return(tag_filter) | ||||
|     end | ||||
| 
 | ||||
|     let(:params) { { order: 'newest' } } | ||||
| 
 | ||||
|     it 'returns http success' do | ||||
|       get :index | ||||
| 
 | ||||
|       expect(response).to have_http_status(200) | ||||
|       expect(response).to render_template(:index) | ||||
| 
 | ||||
|       expect(Admin::TagFilter) | ||||
|         .to have_received(:new) | ||||
|         .with(hash_including(params)) | ||||
|     end | ||||
| 
 | ||||
|     describe 'with filters' do | ||||
|       let(:params) { { order: 'newest', name: 'test' } } | ||||
| 
 | ||||
|       it 'returns http success' do | ||||
|         get :index, params: { name: 'test' } | ||||
| 
 | ||||
|         expect(response).to have_http_status(200) | ||||
|         expect(response).to render_template(:index) | ||||
| 
 | ||||
|         expect(Admin::TagFilter) | ||||
|           .to have_received(:new) | ||||
|           .with(hash_including(params)) | ||||
|       end | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe 'GET #show' do | ||||
|     let!(:tag) { Fabricate(:tag) } | ||||
| 
 | ||||
|  | ||||
							
								
								
									
										36
									
								
								spec/models/admin/tag_filter_spec.rb
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								spec/models/admin/tag_filter_spec.rb
									
									
									
									
									
										Normal file
									
								
							| @ -0,0 +1,36 @@ | ||||
| # frozen_string_literal: true | ||||
| 
 | ||||
| require 'rails_helper' | ||||
| 
 | ||||
| describe Admin::TagFilter do | ||||
|   describe 'with invalid params' do | ||||
|     it 'raises with key error' do | ||||
|       filter = described_class.new(wrong: true) | ||||
| 
 | ||||
|       expect { filter.results }.to raise_error(/wrong/) | ||||
|     end | ||||
| 
 | ||||
|     it 'raises with status scope error' do | ||||
|       filter = described_class.new(status: 'unknown') | ||||
| 
 | ||||
|       expect { filter.results }.to raise_error(/Unknown status: unknown/) | ||||
|     end | ||||
| 
 | ||||
|     it 'raises with order value error' do | ||||
|       filter = described_class.new(order: 'unknown') | ||||
| 
 | ||||
|       expect { filter.results }.to raise_error(/Unknown order: unknown/) | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#results' do | ||||
|     let(:listable_tag) { Fabricate(:tag, name: 'test1', listable: true) } | ||||
|     let(:not_listable_tag) { Fabricate(:tag, name: 'test2', listable: false) } | ||||
| 
 | ||||
|     it 'returns tags filtered by name' do | ||||
|       filter = described_class.new(name: 'test') | ||||
| 
 | ||||
|       expect(filter.results).to eq([listable_tag, not_listable_tag]) | ||||
|     end | ||||
|   end | ||||
| end | ||||
| @ -112,6 +112,18 @@ RSpec.describe Tag do | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '#formatted_name' do | ||||
|     it 'returns name with a proceeding hash symbol' do | ||||
|       tag = Fabricate(:tag, name: 'foo') | ||||
|       expect(tag.formatted_name).to eq '#foo' | ||||
|     end | ||||
| 
 | ||||
|     it 'returns display_name with a proceeding hash symbol, if display name present' do | ||||
|       tag = Fabricate(:tag, name: 'foobar', display_name: 'FooBar') | ||||
|       expect(tag.formatted_name).to eq '#FooBar' | ||||
|     end | ||||
|   end | ||||
| 
 | ||||
|   describe '.recently_used' do | ||||
|     let(:account) { Fabricate(:account) } | ||||
|     let(:other_person_status) { Fabricate(:status) } | ||||
| @ -240,5 +252,23 @@ RSpec.describe Tag do | ||||
| 
 | ||||
|       expect(results).to eq [tag, similar_tag] | ||||
|     end | ||||
| 
 | ||||
|     it 'finds only listable tags' do | ||||
|       tag = Fabricate(:tag, name: 'match') | ||||
|       _miss_tag = Fabricate(:tag, name: 'matchunlisted', listable: false) | ||||
| 
 | ||||
|       results = described_class.search_for('match') | ||||
| 
 | ||||
|       expect(results).to eq [tag] | ||||
|     end | ||||
| 
 | ||||
|     it 'finds non-listable tags as well via option' do | ||||
|       tag = Fabricate(:tag, name: 'match') | ||||
|       unlisted_tag = Fabricate(:tag, name: 'matchunlisted', listable: false) | ||||
| 
 | ||||
|       results = described_class.search_for('match', 5, 0, exclude_unlistable: false) | ||||
| 
 | ||||
|       expect(results).to eq [tag, unlisted_tag] | ||||
|     end | ||||
|   end | ||||
| end | ||||
|  | ||||
		Loading…
	
	
			
			x
			
			
		
	
		Reference in New Issue
	
	Block a user