diff --git a/app/assets/javascripts/admin/topics/components/merge_topics.vue b/app/assets/javascripts/admin/topics/components/merge_topics.vue new file mode 100644 index 0000000000000000000000000000000000000000..921b762bbefa2a42df99c10be9360c3d98dbcba4 --- /dev/null +++ b/app/assets/javascripts/admin/topics/components/merge_topics.vue @@ -0,0 +1,141 @@ + + diff --git a/app/assets/javascripts/admin/topics/components/topic_select.vue b/app/assets/javascripts/admin/topics/components/topic_select.vue new file mode 100644 index 0000000000000000000000000000000000000000..8bf5be1afd136c8edee76107df411232beaaed18 --- /dev/null +++ b/app/assets/javascripts/admin/topics/components/topic_select.vue @@ -0,0 +1,106 @@ + + + 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]]]); + }); +});