+
+
+
+
+
+
+
+
+ {{ $options.i18n.emptySearchResult }}
+
+
+
+
diff --git a/app/assets/javascripts/admin/topics/index.js b/app/assets/javascripts/admin/topics/index.js
index 09e9b20f2206eb16478430f373ab39eb0d9b69fa..d81690e8f4c52fadbc067d14191c48c69231cd70 100644
--- a/app/assets/javascripts/admin/topics/index.js
+++ b/app/assets/javascripts/admin/topics/index.js
@@ -1,7 +1,20 @@
import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import createDefaultClient from '~/lib/graphql';
+import showToast from '~/vue_shared/plugins/global_toast';
import RemoveAvatar from './components/remove_avatar.vue';
+import MergeTopics from './components/merge_topics.vue';
-export default () => {
+const toasts = document.querySelectorAll('.js-toast-message');
+toasts.forEach((toast) => showToast(toast.dataset.message));
+
+Vue.use(VueApollo);
+
+const apolloProvider = new VueApollo({
+ defaultClient: createDefaultClient(),
+});
+
+export const initRemoveAvatar = () => {
const el = document.querySelector('.js-remove-topic-avatar');
if (!el) {
@@ -21,3 +34,20 @@ export default () => {
},
});
};
+
+export const initMergeTopics = () => {
+ const el = document.querySelector('.js-merge-topics');
+
+ if (!el) return false;
+
+ const { path } = el.dataset;
+
+ return new Vue({
+ el,
+ apolloProvider,
+ provide: { path },
+ render(createElement) {
+ return createElement(MergeTopics);
+ },
+ });
+};
diff --git a/app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
similarity index 100%
rename from app/assets/javascripts/projects/settings/topics/queries/project_topics_search.query.graphql
rename to app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql
diff --git a/app/assets/javascripts/pages/admin/topics/edit/index.js b/app/assets/javascripts/pages/admin/topics/edit/index.js
index f5e6d04486580e8ac1e230de1bcd36be18da04d5..b2cbd52fb274800906c95d0ffd3f40fd1e5870c3 100644
--- a/app/assets/javascripts/pages/admin/topics/edit/index.js
+++ b/app/assets/javascripts/pages/admin/topics/edit/index.js
@@ -2,7 +2,7 @@ import $ from 'jquery';
import GLForm from '~/gl_form';
import initFilePickers from '~/file_pickers';
import ZenMode from '~/zen_mode';
-import initRemoveAvatar from '~/admin/topics';
+import { initRemoveAvatar } from '~/admin/topics';
new GLForm($('.js-project-topic-form')); // eslint-disable-line no-new
initFilePickers();
diff --git a/app/assets/javascripts/pages/admin/topics/index.js b/app/assets/javascripts/pages/admin/topics/index.js
new file mode 100644
index 0000000000000000000000000000000000000000..ec0e11660d28419f914bc87a4030b34fe25beaee
--- /dev/null
+++ b/app/assets/javascripts/pages/admin/topics/index.js
@@ -0,0 +1,3 @@
+import { initMergeTopics } from '~/admin/topics';
+
+initMergeTopics();
diff --git a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
index 9c8de9bef2dd227d55abfe9268281dac6b412f92..3d553e71f71feb043062accccbda41cf12fc9c28 100644
--- a/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
+++ b/app/assets/javascripts/projects/settings/topics/components/topics_token_selector.vue
@@ -2,7 +2,7 @@
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
-import searchProjectTopics from '../queries/project_topics_search.query.graphql';
+import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
diff --git a/app/controllers/admin/topics_controller.rb b/app/controllers/admin/topics_controller.rb
index 69bcfdf4791f96a5ba46bdabf5eed97651cdcedc..c3b1c6793adb65618efcb21097664477b635f3e4 100644
--- a/app/controllers/admin/topics_controller.rb
+++ b/app/controllers/admin/topics_controller.rb
@@ -56,9 +56,8 @@ def merge
end
message = _('Topic %{source_topic} was successfully merged into topic %{target_topic}.')
- redirect_to admin_topics_path,
- status: :found,
- notice: message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ flash[:toast] = message % { source_topic: source_topic.name, target_topic: target_topic.name }
+ redirect_to admin_topics_path, status: :found
end
private
diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml
index 6485b8aa4116ec21ac31810c6c725ef8fe470477..77823ed70587cb97f4a5b7fd4ec461b7107500bf 100644
--- a/app/views/admin/topics/index.html.haml
+++ b/app/views/admin/topics/index.html.haml
@@ -1,16 +1,16 @@
- 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')
+.top-area
+ .nav-controls.gl-w-full.gl-mt-3.gl-mb-3
+ = form_tag admin_topics_path, method: :get do |f|
+ - 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')
+ .gl-flex-grow-1
+ .js-merge-topics{ data: { path: merge_admin_topics_path } }
+ = 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
diff --git a/doc/user/admin_area/index.md b/doc/user/admin_area/index.md
index c689d61ad68c838b860924e83f50f150e1eaa9f0..fd42a8dbbf0ad435ffc499d4670736060e43ef75 100644
--- a/doc/user/admin_area/index.md
+++ b/doc/user/admin_area/index.md
@@ -288,6 +288,8 @@ To edit a topic, select **Edit** in that topic's row.
To remove a topic, select **Remove** in that topic's row.
+To remove a topic and move all assigned projects to another topic, select **Merge topics**.
+
To search for topics by name, enter your criteria in the search box. The topic search is case
insensitive and applies partial matching.
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index aa58720e68977bc55f8b089d80eb1b068298461e..3b2b92c707a6914a5052fe79beda2dea0e6f2406 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -24887,6 +24887,30 @@ msgstr ""
msgid "MergeRequest|Search (e.g. *.vue) (%{modifier_key}P)"
msgstr ""
+msgid "MergeTopics|%{sourceTopic} will be removed"
+msgstr ""
+
+msgid "MergeTopics|All assigned projects will be moved to %{targetTopic}"
+msgstr ""
+
+msgid "MergeTopics|Merge topics"
+msgstr ""
+
+msgid "MergeTopics|Merging topics will cause the following:"
+msgstr ""
+
+msgid "MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic."
+msgstr ""
+
+msgid "MergeTopics|Source topic"
+msgstr ""
+
+msgid "MergeTopics|Target topic"
+msgstr ""
+
+msgid "MergeTopics|This action cannot be undone."
+msgstr ""
+
msgid "Merged"
msgstr ""
@@ -41086,6 +41110,15 @@ msgstr ""
msgid "Topic was successfully updated."
msgstr ""
+msgid "TopicSelect|No matching results"
+msgstr ""
+
+msgid "TopicSelect|Search topics"
+msgstr ""
+
+msgid "TopicSelect|Select a topic"
+msgstr ""
+
msgid "Topics"
msgstr ""
diff --git a/spec/frontend/admin/topics/components/topic_select_spec.js b/spec/frontend/admin/topics/components/topic_select_spec.js
new file mode 100644
index 0000000000000000000000000000000000000000..fae5ca1ef5fb32364596b5d9f2be5f719cfd3690
--- /dev/null
+++ b/spec/frontend/admin/topics/components/topic_select_spec.js
@@ -0,0 +1,91 @@
+import { GlAvatarLabeled, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import TopicSelect from '~/admin/topics/components/topic_select.vue';
+
+const mockTopics = [
+ { id: 1, name: 'topic1', title: 'Topic 1', avatarUrl: 'avatar.com/topic1.png' },
+ { id: 2, name: 'GitLab', title: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' },
+];
+
+describe('TopicSelect', () => {
+ let wrapper;
+
+ const findDropdown = () => wrapper.findComponent(GlDropdown);
+ const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem);
+
+ function createComponent(props = {}) {
+ wrapper = shallowMount(TopicSelect, {
+ propsData: props,
+ data() {
+ return {
+ topics: mockTopics,
+ search: '',
+ };
+ },
+ mocks: {
+ $apollo: {
+ queries: {
+ topics: { loading: false },
+ },
+ },
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('mounts', () => {
+ createComponent();
+
+ expect(wrapper.exists()).toBe(true);
+ });
+
+ it('`selectedTopic` prop defaults to `{}`', () => {
+ createComponent();
+
+ expect(wrapper.props('selectedTopic')).toEqual({});
+ });
+
+ it('`labelText` prop defaults to `null`', () => {
+ createComponent();
+
+ expect(wrapper.props('labelText')).toBe(null);
+ });
+
+ it('renders default text if no selected topic', () => {
+ createComponent();
+
+ expect(findDropdown().props('text')).toBe('Select a topic');
+ });
+
+ it('renders selected topic', () => {
+ createComponent({ selectedTopic: mockTopics[0] });
+
+ expect(findDropdown().props('text')).toBe('topic1');
+ });
+
+ it('renders label', () => {
+ createComponent({ labelText: 'my label' });
+
+ expect(wrapper.find('label').text()).toBe('my label');
+ });
+
+ it('renders dropdown items', () => {
+ createComponent();
+
+ const dropdownItems = findAllDropdownItems();
+
+ expect(dropdownItems.at(0).find(GlAvatarLabeled).props('label')).toBe('Topic 1');
+ expect(dropdownItems.at(1).find(GlAvatarLabeled).props('label')).toBe('GitLab');
+ });
+
+ it('emits `click` event when topic selected', () => {
+ createComponent();
+
+ findAllDropdownItems().at(0).vm.$emit('click');
+
+ expect(wrapper.emitted('click')).toEqual([[mockTopics[0]]]);
+ });
+});