Коммит c57c261a создал по автору Jonas Wälter's avatar Jonas Wälter Зафиксировано автором Heinrich Lee Yu
Просмотр файлов

Allow admins to merge topics [frontend]

Changelog: added
владелец c5ea92bd
<script>
import { GlAlert, GlButton, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import csrf from '~/lib/utils/csrf';
import TopicSelect from './topic_select.vue';
export default {
components: {
GlAlert,
GlButton,
GlModal,
GlSprintf,
TopicSelect,
},
directives: {
GlModal: GlModalDirective,
},
inject: ['path'],
data() {
return {
sourceTopic: {},
targetTopic: {},
};
},
computed: {
sourceTopicId() {
return getIdFromGraphQLId(this.sourceTopic?.id);
},
targetTopicId() {
return getIdFromGraphQLId(this.targetTopic?.id);
},
validSelectedTopics() {
return (
Object.keys(this.sourceTopic).length &&
Object.keys(this.targetTopic).length &&
this.sourceTopic !== this.targetTopic
);
},
actionPrimary() {
return {
text: __('Merge'),
attributes: {
variant: 'danger',
disabled: !this.validSelectedTopics,
},
};
},
},
methods: {
selectSourceTopic(topic) {
this.sourceTopic = topic;
},
selectTargetTopic(topic) {
this.targetTopic = topic;
},
mergeTopics() {
this.$refs.mergeForm.submit();
},
},
i18n: {
title: s__('MergeTopics|Merge topics'),
body: s__(
'MergeTopics|Move all assigned projects from the source topic to the target topic and remove the source topic.',
),
sourceTopic: s__('MergeTopics|Source topic'),
targetTopic: s__('MergeTopics|Target topic'),
warningTitle: s__('MergeTopics|Merging topics will cause the following:'),
warningBody: s__('MergeTopics|This action cannot be undone.'),
warningRemoveTopic: s__('MergeTopics|%{sourceTopic} will be removed'),
warningMoveProjects: s__('MergeTopics|All assigned projects will be moved to %{targetTopic}'),
},
modal: {
id: 'merge-topics',
actionSecondary: {
text: __('Cancel'),
attributes: {
variant: 'default',
},
},
},
csrf,
};
</script>
<template>
<div class="gl-mr-3">
<gl-button v-gl-modal="$options.modal.id" category="secondary">{{
$options.i18n.title
}}</gl-button>
<gl-modal
:title="$options.i18n.title"
:action-primary="actionPrimary"
:action-secondary="$options.modal.actionSecondary"
:modal-id="$options.modal.id"
size="sm"
@primary="mergeTopics"
>
<p>{{ $options.i18n.body }}</p>
<topic-select
:selected-topic="sourceTopic"
:label-text="$options.i18n.sourceTopic"
@click="selectSourceTopic"
/>
<topic-select
:selected-topic="targetTopic"
:label-text="$options.i18n.targetTopic"
@click="selectTargetTopic"
/>
<gl-alert
v-if="validSelectedTopics"
:title="$options.i18n.warningTitle"
:dismissible="false"
variant="danger"
>
<ul>
<li>
<gl-sprintf :message="$options.i18n.warningRemoveTopic">
<template #sourceTopic>
<strong>{{ sourceTopic.name }}</strong>
</template>
</gl-sprintf>
</li>
<li>
<gl-sprintf :message="$options.i18n.warningMoveProjects">
<template #targetTopic>
<strong>{{ targetTopic.name }}</strong>
</template>
</gl-sprintf>
</li>
</ul>
{{ $options.i18n.warningBody }}
</gl-alert>
<form ref="mergeForm" method="post" :action="path">
<input type="hidden" name="_method" value="post" />
<input type="hidden" name="authenticity_token" :value="$options.csrf.token" />
<input type="hidden" name="source_topic_id" :value="sourceTopicId" />
<input type="hidden" name="target_topic_id" :value="targetTopicId" />
</form>
</gl-modal>
</div>
</template>
<script>
import {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
} from '@gitlab/ui';
import { s__ } from '~/locale';
import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants';
import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql';
export default {
components: {
GlAvatarLabeled,
GlDropdown,
GlDropdownItem,
GlDropdownText,
GlSearchBoxByType,
},
props: {
selectedTopic: {
type: Object,
required: false,
default: () => ({}),
},
labelText: {
type: String,
required: false,
default: null,
},
},
apollo: {
topics: {
query: searchProjectTopics,
variables() {
return {
search: this.search,
};
},
update(data) {
return data.topics?.nodes || [];
},
debounce: 250,
},
},
data() {
return {
topics: [],
search: '',
};
},
computed: {
loading() {
return this.$apollo.queries.topics.loading;
},
isResultEmpty() {
return this.topics.length === 0;
},
dropdownText() {
if (Object.keys(this.selectedTopic).length) {
return this.selectedTopic.name;
}
return this.$options.i18n.dropdownText;
},
},
methods: {
selectTopic(topic) {
this.$emit('click', topic);
},
},
i18n: {
dropdownText: s__('TopicSelect|Select a topic'),
searchPlaceholder: s__('TopicSelect|Search topics'),
emptySearchResult: s__('TopicSelect|No matching results'),
},
AVATAR_SHAPE_OPTION_RECT,
};
</script>
<template>
<div>
<label v-if="labelText">{{ labelText }}</label>
<gl-dropdown block :text="dropdownText">
<gl-search-box-by-type
v-model="search"
:is-loading="loading"
:placeholder="$options.i18n.searchPlaceholder"
/>
<gl-dropdown-item v-for="topic in topics" :key="topic.id" @click="selectTopic(topic)">
<gl-avatar-labeled
:label="topic.title"
:sub-label="topic.name"
:src="topic.avatarUrl"
:entity-name="topic.name"
:size="32"
:shape="$options.AVATAR_SHAPE_OPTION_RECT"
/>
</gl-dropdown-item>
<gl-dropdown-text v-if="isResultEmpty && !loading">
<span class="gl-text-gray-500">{{ $options.i18n.emptySearchResult }}</span>
</gl-dropdown-text>
</gl-dropdown>
</div>
</template>
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);
},
});
};
......@@ -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();
......
import { initMergeTopics } from '~/admin/topics';
initMergeTopics();
......@@ -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: {
......
......@@ -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
......
- 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
......
......@@ -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.
......
......@@ -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 ""
 
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]]]);
});
});
Поддерживает Markdown
0% или .
You are about to add 0 people to the discussion. Proceed with caution.
Сначала завершите редактирование этого сообщения!
Пожалуйста, зарегистрируйтесь или чтобы прокомментировать