Commit d2a7fb32 authored by Jonas Wälter's avatar Jonas Wälter Committed by Olena Horal-Koretska

Project settings: add token selector for topics

parent 702b46ae
...@@ -9,6 +9,7 @@ import initSearchSettings from '~/search_settings'; ...@@ -9,6 +9,7 @@ import initSearchSettings from '~/search_settings';
import initSettingsPanels from '~/settings_panels'; import initSettingsPanels from '~/settings_panels';
import setupTransferEdit from '~/transfer_edit'; import setupTransferEdit from '~/transfer_edit';
import UserCallout from '~/user_callout'; import UserCallout from '~/user_callout';
import initTopicsTokenSelector from '~/projects/settings/topics';
import initProjectPermissionsSettings from '../shared/permissions'; import initProjectPermissionsSettings from '../shared/permissions';
import initProjectLoadingSpinner from '../shared/save_project_loader'; import initProjectLoadingSpinner from '../shared/save_project_loader';
...@@ -28,3 +29,4 @@ setupTransferEdit('.js-project-transfer-form', 'select.select2'); ...@@ -28,3 +29,4 @@ setupTransferEdit('.js-project-transfer-form', 'select.select2');
dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form')); dirtySubmitFactory(document.querySelectorAll('.js-general-settings-form, .js-mr-settings-form'));
initSearchSettings(); initSearchSettings();
initTopicsTokenSelector();
<script>
import { GlTokenSelector, GlAvatarLabeled } from '@gitlab/ui';
import { s__ } from '~/locale';
import searchProjectTopics from '../queries/project_topics_search.query.graphql';
export default {
components: {
GlTokenSelector,
GlAvatarLabeled,
},
i18n: {
placeholder: s__('ProjectSettings|Search for topic'),
},
props: {
selected: {
type: Array,
required: false,
default: () => [],
},
},
apollo: {
topics: {
query: searchProjectTopics,
variables() {
return {
search: this.search,
};
},
update(data) {
return (
data.topics?.nodes.filter(
(topic) => !this.selectedTokens.some((token) => token.name === topic.name),
) || []
);
},
debounce: 250,
},
},
data() {
return {
topics: [],
selectedTokens: this.selected,
search: '',
};
},
computed: {
loading() {
return this.$apollo.queries.topics.loading;
},
placeholderText() {
return this.selectedTokens.length ? '' : this.$options.i18n.placeholder;
},
},
methods: {
handleEnter(event) {
// Prevent form from submitting when adding a token
if (event.target.value !== '') {
event.preventDefault();
}
},
filterTopics(searchTerm) {
this.search = searchTerm;
},
onTokensUpdate(tokens) {
this.$emit('update', tokens);
},
},
};
</script>
<template>
<gl-token-selector
ref="tokenSelector"
v-model="selectedTokens"
:dropdown-items="topics"
:loading="loading"
allow-user-defined-tokens
:placeholder="placeholderText"
@keydown.enter="handleEnter"
@text-input="filterTopics"
@input="onTokensUpdate"
>
<template #dropdown-item-content="{ dropdownItem }">
<gl-avatar-labeled
:src="dropdownItem.avatarUrl"
:entity-name="dropdownItem.name"
:label="dropdownItem.name"
:size="32"
shape="rect"
/>
</template>
</gl-token-selector>
</template>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createDefaultClient from '~/lib/graphql';
import TopicsTokenSelector from './components/topics_token_selector.vue';
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
export default () => {
const el = document.querySelector('.js-topics-selector');
if (!el) return null;
const { hiddenInputId } = el.dataset;
const hiddenInput = document.getElementById(hiddenInputId);
const selected = hiddenInput.value
? hiddenInput.value.split(/,\s*/).map((token, index) => ({
id: index,
name: token,
}))
: [];
return new Vue({
el,
apolloProvider,
render(createElement) {
return createElement(TopicsTokenSelector, {
props: {
selected,
},
on: {
update(tokens) {
const value = tokens.map(({ name }) => name).join(', ');
hiddenInput.value = value;
// Dispatch `input` event so form submit button becomes active
hiddenInput.dispatchEvent(
new Event('input', {
bubbles: true,
cancelable: true,
}),
);
},
},
});
},
});
};
query searchProjectTopics($search: String) {
topics(search: $search) {
nodes {
id
name
avatarUrl
}
}
}
# frozen_string_literal: true
module Resolvers
class TopicsResolver < BaseResolver
type Types::Projects::TopicType, null: true
argument :search, GraphQL::Types::String,
required: false,
description: 'Search query for topic name.'
def resolve(**args)
if args[:search].present?
::Projects::Topic.search(args[:search]).order_by_total_projects_count
else
::Projects::Topic.order_by_total_projects_count
end
end
end
end
# frozen_string_literal: true
module Types
module Projects
# rubocop: disable Graphql/AuthorizeTypes
class TopicType < BaseObject
graphql_name 'Topic'
field :id, GraphQL::Types::ID, null: false,
description: 'ID of the topic.'
field :name, GraphQL::Types::String, null: false,
description: 'Name of the topic.'
field :description, GraphQL::Types::String, null: true,
description: 'Description of the topic.'
markdown_field :description_html, null: true
field :avatar_url, GraphQL::Types::String, null: true,
description: 'URL to avatar image file of the topic.'
def avatar_url
object.avatar_url(only_path: false)
end
end
# rubocop: enable Graphql/AuthorizeTypes
end
end
...@@ -140,6 +140,11 @@ module Types ...@@ -140,6 +140,11 @@ module Types
null: true, null: true,
resolver: Resolvers::BoardListResolver resolver: Resolvers::BoardListResolver
field :topics, Types::Projects::TopicType.connection_type,
null: true,
resolver: Resolvers::TopicsResolver,
description: "Find project topics."
def design_management def design_management
DesignManagementObject.new(nil) DesignManagementObject.new(nil)
end end
......
- hidden_topics_field_id = 'project_topic_list_field'
= form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f| = form_for [@project], html: { multipart: true, class: "edit-project js-general-settings-form" }, authenticity_token: true do |f|
%input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' } %input{ name: 'update_section', type: 'hidden', value: 'js-general-settings' }
...@@ -15,9 +16,9 @@ ...@@ -15,9 +16,9 @@
.row .row
.form-group.col-md-9 .form-group.col-md-9
= f.label :topics, _('Topics (optional)'), class: 'label-bold' = f.label :topics, _('Topics'), class: 'label-bold'
= f.text_field :topics, value: @project.topic_list.join(', '), maxlength: 2000, class: "form-control gl-form-input" .js-topics-selector{ data: { hidden_input_id: hidden_topics_field_id } }
%p.form-text.text-muted= _('Separate topics with commas.') = f.hidden_field :topics, value: @project.topic_list.join(', '), id: hidden_topics_field_id
.row .row
.form-group.col-md-9 .form-group.col-md-9
......
...@@ -419,6 +419,22 @@ four standard [pagination arguments](#connection-pagination-arguments): ...@@ -419,6 +419,22 @@ four standard [pagination arguments](#connection-pagination-arguments):
| <a id="querytimelogsstarttime"></a>`startTime` | [`Time`](#time) | List timelogs within a time range where the logged time is equal to or after startTime. | | <a id="querytimelogsstarttime"></a>`startTime` | [`Time`](#time) | List timelogs within a time range where the logged time is equal to or after startTime. |
| <a id="querytimelogsusername"></a>`username` | [`String`](#string) | List timelogs for a user. | | <a id="querytimelogsusername"></a>`username` | [`String`](#string) | List timelogs for a user. |
### `Query.topics`
Find project topics.
Returns [`TopicConnection`](#topicconnection).
This field returns a [connection](#connections). It accepts the
four standard [pagination arguments](#connection-pagination-arguments):
`before: String`, `after: String`, `first: Int`, `last: Int`.
#### Arguments
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="querytopicssearch"></a>`search` | [`String`](#string) | Search query for topic name. |
### `Query.usageTrendsMeasurements` ### `Query.usageTrendsMeasurements`
Get statistics on the instance. Get statistics on the instance.
...@@ -7667,6 +7683,29 @@ The edge type for [`Todo`](#todo). ...@@ -7667,6 +7683,29 @@ The edge type for [`Todo`](#todo).
| <a id="todoedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. | | <a id="todoedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="todoedgenode"></a>`node` | [`Todo`](#todo) | The item at the end of the edge. | | <a id="todoedgenode"></a>`node` | [`Todo`](#todo) | The item at the end of the edge. |
#### `TopicConnection`
The connection type for [`Topic`](#topic).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="topicconnectionedges"></a>`edges` | [`[TopicEdge]`](#topicedge) | A list of edges. |
| <a id="topicconnectionnodes"></a>`nodes` | [`[Topic]`](#topic) | A list of nodes. |
| <a id="topicconnectionpageinfo"></a>`pageInfo` | [`PageInfo!`](#pageinfo) | Information to aid in pagination. |
#### `TopicEdge`
The edge type for [`Topic`](#topic).
##### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="topicedgecursor"></a>`cursor` | [`String!`](#string) | A cursor for use in pagination. |
| <a id="topicedgenode"></a>`node` | [`Topic`](#topic) | The item at the end of the edge. |
#### `TreeConnection` #### `TreeConnection`
The connection type for [`Tree`](#tree). The connection type for [`Tree`](#tree).
...@@ -14781,6 +14820,18 @@ Representing a to-do entry. ...@@ -14781,6 +14820,18 @@ Representing a to-do entry.
| <a id="todostate"></a>`state` | [`TodoStateEnum!`](#todostateenum) | State of the to-do item. | | <a id="todostate"></a>`state` | [`TodoStateEnum!`](#todostateenum) | State of the to-do item. |
| <a id="todotargettype"></a>`targetType` | [`TodoTargetEnum!`](#todotargetenum) | Target type of the to-do item. | | <a id="todotargettype"></a>`targetType` | [`TodoTargetEnum!`](#todotargetenum) | Target type of the to-do item. |
### `Topic`
#### Fields
| Name | Type | Description |
| ---- | ---- | ----------- |
| <a id="topicavatarurl"></a>`avatarUrl` | [`String`](#string) | URL to avatar image file of the topic. |
| <a id="topicdescription"></a>`description` | [`String`](#string) | Description of the topic. |
| <a id="topicdescriptionhtml"></a>`descriptionHtml` | [`String`](#string) | The GitLab Flavored Markdown rendering of `description`. |
| <a id="topicid"></a>`id` | [`ID!`](#id) | ID of the topic. |
| <a id="topicname"></a>`name` | [`String!`](#string) | Name of the topic. |
### `Tree` ### `Tree`
#### Fields #### Fields
......
...@@ -28,12 +28,29 @@ functionality of a project. ...@@ -28,12 +28,29 @@ functionality of a project.
Adjust your project's name, description, avatar, [default branch](../repository/branches/default.md), and topics: Adjust your project's name, description, avatar, [default branch](../repository/branches/default.md), and topics:
![general project settings](img/general_settings_v13_11.png)
The project description also partially supports [standard Markdown](../../markdown.md#features-extended-from-standard-markdown). The project description also partially supports [standard Markdown](../../markdown.md#features-extended-from-standard-markdown).
You can use [emphasis](../../markdown.md#emphasis), [links](../../markdown.md#links), and You can use [emphasis](../../markdown.md#emphasis), [links](../../markdown.md#links), and
[line-breaks](../../markdown.md#line-breaks) to add more context to the project description. [line-breaks](../../markdown.md#line-breaks) to add more context to the project description.
#### Topics
Use topics to categorize projects and find similar new projects.
To assign topics to a project:
1. On the top bar, select **Menu > Projects** and find your project.
1. On the left sidebar, select **Settings** > **General**.
1. Under **Topics**, enter the project topics. Existing popular topics are suggested as you type.
1. Select **Save changes**.
For GitLab.com, explore popular topics on the [Explore topics page](https://gitlab.com/explore/projects/topics).
When you select a topic, you can see relevant projects.
NOTE:
The assigned topics are visible only to everyone with access to the project,
but everyone can see which topics exist at all on the GitLab instance.
Do not include sensitive information in the name of a topic.
#### Compliance frameworks **(PREMIUM)** #### Compliance frameworks **(PREMIUM)**
> - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276221) in GitLab 13.9. > - [Introduced](https://gitlab.com/gitlab-org/gitlab/-/issues/276221) in GitLab 13.9.
......
...@@ -27178,6 +27178,9 @@ msgstr "" ...@@ -27178,6 +27178,9 @@ msgstr ""
msgid "ProjectSettings|Requirements management system." msgid "ProjectSettings|Requirements management system."
msgstr "" msgstr ""
msgid "ProjectSettings|Search for topic"
msgstr ""
msgid "ProjectSettings|Security & Compliance" msgid "ProjectSettings|Security & Compliance"
msgstr "" msgstr ""
...@@ -31213,9 +31216,6 @@ msgstr "" ...@@ -31213,9 +31216,6 @@ msgstr ""
msgid "Sep" msgid "Sep"
msgstr "" msgstr ""
msgid "Separate topics with commas."
msgstr ""
msgid "September" msgid "September"
msgstr "" msgstr ""
...@@ -36140,9 +36140,6 @@ msgstr "" ...@@ -36140,9 +36140,6 @@ msgstr ""
msgid "Topics" msgid "Topics"
msgstr "" msgstr ""
msgid "Topics (optional)"
msgstr ""
msgid "Total" msgid "Total"
msgstr "" msgstr ""
......
...@@ -2,22 +2,40 @@ ...@@ -2,22 +2,40 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Projects > Settings > User tags a project' do RSpec.describe 'Projects > Settings > User tags a project', :js do
let(:user) { create(:user) } let(:user) { create(:user) }
let(:project) { create(:project, namespace: user.namespace) } let(:project) { create(:project, namespace: user.namespace) }
let!(:topic) { create(:topic, name: 'topic1') }
before do before do
sign_in(user) sign_in(user)
visit edit_project_path(project) visit edit_project_path(project)
wait_for_all_requests
end end
it 'sets project topics' do it 'select existing topic' do
fill_in 'Topics', with: 'topic1, topic2' fill_in class: 'gl-token-selector-input', with: 'topic1'
wait_for_all_requests
find('.gl-avatar-labeled[entity-name="topic1"]').click
page.within '.general-settings' do
click_button 'Save changes'
end
expect(find('#project_topic_list_field', visible: :hidden).value).to eq 'topic1'
end
it 'select new topic' do
fill_in class: 'gl-token-selector-input', with: 'topic2'
wait_for_all_requests
click_button 'Add "topic2"'
page.within '.general-settings' do page.within '.general-settings' do
click_button 'Save changes' click_button 'Save changes'
end end
expect(find_field('Topics').value).to eq 'topic1, topic2' expect(find('#project_topic_list_field', visible: :hidden).value).to eq 'topic2'
end end
end end
import { GlTokenSelector, GlToken } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import TopicsTokenSelector from '~/projects/settings/topics/components/topics_token_selector.vue';
const mockTopics = [
{ id: 1, name: 'topic1', avatarUrl: 'avatar.com/topic1.png' },
{ id: 2, name: 'GitLab', avatarUrl: 'avatar.com/GitLab.png' },
];
describe('TopicsTokenSelector', () => {
let wrapper;
let div;
let input;
const createComponent = (selected) => {
wrapper = mount(TopicsTokenSelector, {
attachTo: div,
propsData: {
selected,
},
data() {
return {
topics: mockTopics,
};
},
mocks: {
$apollo: {
queries: {
topics: { loading: false },
},
},
},
});
};
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const setTokenSelectorInputValue = (value) => {
const tokenSelectorInput = findTokenSelectorInput();
tokenSelectorInput.element.value = value;
tokenSelectorInput.trigger('input');
return nextTick();
};
const tokenSelectorTriggerEnter = (event) => {
const tokenSelectorInput = findTokenSelectorInput();
tokenSelectorInput.trigger('keydown.enter', event);
};
beforeEach(() => {
div = document.createElement('div');
input = document.createElement('input');
input.setAttribute('type', 'text');
input.id = 'project_topic_list_field';
document.body.appendChild(div);
document.body.appendChild(input);
});
afterEach(() => {
wrapper.destroy();
div.remove();
input.remove();
});
describe('when component is mounted', () => {
it('parses selected into tokens', async () => {
const selected = [
{ id: 11, name: 'topic1' },
{ id: 12, name: 'topic2' },
{ id: 13, name: 'topic3' },
];
createComponent(selected);
await nextTick();
wrapper.findAllComponents(GlToken).wrappers.forEach((tokenWrapper, index) => {
expect(tokenWrapper.text()).toBe(selected[index].name);
});
});
});
describe('when enter key is pressed', () => {
it('does not submit the form if token selector text input has a value', async () => {
createComponent();
await setTokenSelectorInputValue('topic');
const event = { preventDefault: jest.fn() };
tokenSelectorTriggerEnter(event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
});
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Resolvers::TopicsResolver do
include GraphqlHelpers
describe '#resolve' do
let!(:topic1) { create(:topic, name: 'GitLab', total_projects_count: 1) }
let!(:topic2) { create(:topic, name: 'git', total_projects_count: 2) }
let!(:topic3) { create(:topic, name: 'topic3', total_projects_count: 3) }
it 'finds all topics' do
expect(resolve_topics).to eq([topic3, topic2, topic1])
end
context 'with search' do
it 'searches environment by name' do
expect(resolve_topics(search: 'git')).to eq([topic2, topic1])
end
context 'when the search term does not match any topic' do
it 'is empty' do
expect(resolve_topics(search: 'nonsense')).to be_empty
end
end
end
end
def resolve_topics(args = {})
resolve(described_class, args: args)
end
end
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe Types::Projects::TopicType do
specify { expect(described_class.graphql_name).to eq('Topic') }
specify do
expect(described_class).to have_graphql_fields(
:id,
:name,
:description,
:description_html,
:avatar_url
)
end
end
...@@ -28,6 +28,7 @@ RSpec.describe GitlabSchema.types['Query'] do ...@@ -28,6 +28,7 @@ RSpec.describe GitlabSchema.types['Query'] do
runners runners
timelogs timelogs
board_list board_list
topics
] ]
expect(described_class).to have_graphql_fields(*expected_fields).at_least expect(described_class).to have_graphql_fields(*expected_fields).at_least
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment