diff --git a/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql index 0c0a874d950812f09cb803ece1c23915b85aa19f..cfcfce8d6fc7834ff557cb2a9b69f8b7a1cb5247 100644 --- a/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql +++ b/app/assets/javascripts/graphql_shared/queries/project_topics_search.query.graphql @@ -1,5 +1,5 @@ -query searchProjectTopics($search: String) { - topics(search: $search) { +query searchProjectTopics($search: String, $organizationId: OrganizationsOrganizationID) { + topics(search: $search, organizationId: $organizationId) { nodes { id name 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 15c43435e01d2729ded8c874e7ea1ff6bc914460..92dd80bb9c6931f8cdedd9ba177270c70727498f 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 @@ -4,6 +4,8 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { s__ } from '~/locale'; import { AVATAR_SHAPE_OPTION_RECT } from '~/vue_shared/constants'; import searchProjectTopics from '~/graphql_shared/queries/project_topics_search.query.graphql'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPE_ORGANIZATION } from '~/graphql_shared/constants'; export default { components: { @@ -26,6 +28,10 @@ export default { required: false, default: () => [], }, + organizationId: { + type: String, + required: true, + }, }, apollo: { topics: { @@ -33,6 +39,7 @@ export default { variables() { return { search: this.search, + organizationId: convertToGraphQLId(TYPE_ORGANIZATION, this.organizationId), }; }, update(data) { diff --git a/app/assets/javascripts/projects/settings/topics/index.js b/app/assets/javascripts/projects/settings/topics/index.js index 3fbd1a61abedb8c95888f08ba9bc5dea2249e141..8819b046d99be809d042e8b077406356e6d7a206 100644 --- a/app/assets/javascripts/projects/settings/topics/index.js +++ b/app/assets/javascripts/projects/settings/topics/index.js @@ -14,7 +14,7 @@ export default () => { if (!el) return null; - const { hiddenInputId } = el.dataset; + const { hiddenInputId, organizationId } = el.dataset; const hiddenInput = document.getElementById(hiddenInputId); const selected = hiddenInput.value @@ -31,6 +31,7 @@ export default () => { return createElement(TopicsTokenSelector, { props: { selected, + organizationId, }, on: { update(tokens) { diff --git a/app/graphql/resolvers/topics_resolver.rb b/app/graphql/resolvers/topics_resolver.rb index 4aadc9485240f335186e253fcce5dd6e8318d87f..844a73f9a6c574e24c1bed40953222efe8f771ff 100644 --- a/app/graphql/resolvers/topics_resolver.rb +++ b/app/graphql/resolvers/topics_resolver.rb @@ -2,18 +2,40 @@ module Resolvers class TopicsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + type Types::Projects::TopicType, null: true argument :search, GraphQL::Types::String, required: false, description: 'Search query for topic name.' + argument :organization_id, Types::GlobalIDType[::Organizations::Organization], + required: false, + prepare: ->(global_id, _ctx) { global_id&.model_id }, + experiment: { milestone: '17.7' }, + description: 'Global ID of the organization.' + def resolve(**args) - if args[:search].present? - ::Projects::Topic.search(args[:search]).order_by_non_private_projects_count - else - ::Projects::Topic.order_by_non_private_projects_count - end + organization = authorized_find!(id: args[:organization_id] || ::Current.organization_id) + + return organization_topics(organization.id) unless args[:search].present? + + organization_topics(organization.id).search(args[:search]) + end + + private + + def find_object(id:) + ::Organizations::Organization.find_by_id(id) + end + + def authorized_resource?(organization) + Ability.allowed?(current_user, :read_organization, organization) + end + + def organization_topics(organization_id) + ::Projects::Topic.for_organization(organization_id).order_by_non_private_projects_count end end end diff --git a/app/views/projects/settings/_general.html.haml b/app/views/projects/settings/_general.html.haml index a57bbf236e7818511bc55bbd4e1d8b83de63e035..6f5453281ee20688b83ce0cf0f3c72afd8e31232 100644 --- a/app/views/projects/settings/_general.html.haml +++ b/app/views/projects/settings/_general.html.haml @@ -36,7 +36,7 @@ .row .form-group.col-md-9 - .js-topics-selector{ data: { hidden_input_id: hidden_topics_field_id } } + .js-topics-selector{ data: { hidden_input_id: hidden_topics_field_id, organization_id: @project.organization.id } } = f.hidden_field :topics, value: @project.topic_list.join(', '), id: hidden_topics_field_id = f.submit _('Save changes'), pajamas_button: true, data: { testid: 'save-naming-topics-avatar-button' } diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md index 3f34169b1d11ed892e42fae291f5176340c8a662..309045cd6e73d23677491903d41af77c33aa2196 100644 --- a/doc/api/graphql/reference/index.md +++ b/doc/api/graphql/reference/index.md @@ -1273,6 +1273,7 @@ four standard [pagination arguments](#pagination-arguments): | Name | Type | Description | | ---- | ---- | ----------- | +| <a id="querytopicsorganizationid"></a>`organizationId` **{warning-solid}** | [`OrganizationsOrganizationID`](#organizationsorganizationid) | **Introduced** in GitLab 17.7. **Status**: Experiment. Global ID of the organization. | | <a id="querytopicssearch"></a>`search` | [`String`](#string) | Search query for topic name. | ### `Query.usageTrendsMeasurements` diff --git a/ee/spec/views/projects/edit.html.haml_spec.rb b/ee/spec/views/projects/edit.html.haml_spec.rb index fd189086a8f0361c16f10838e70777b6dc5ffb3d..b1ba8c16a85bf10e52633b17a56c9bc8e767cfbb 100644 --- a/ee/spec/views/projects/edit.html.haml_spec.rb +++ b/ee/spec/views/projects/edit.html.haml_spec.rb @@ -3,7 +3,8 @@ require 'spec_helper' RSpec.describe 'projects/edit' do - let(:project) { create(:project) } + let_it_be(:organization) { create(:organization) } + let(:project) { create(:project, organization: organization) } let(:user) { create(:admin) } before do @@ -94,7 +95,9 @@ end context 'when project is pending deletion' do - let_it_be(:project) { build_stubbed(:project, marked_for_deletion_at: Date.current) } + let_it_be(:project) do + build_stubbed(:project, marked_for_deletion_at: Date.current, organization: organization) + end it_behaves_like 'renders restore project settings' end @@ -110,7 +113,9 @@ end context 'when project is pending deletion' do - let_it_be(:project) { build_stubbed(:project, marked_for_deletion_at: Date.current) } + let_it_be(:project) do + build_stubbed(:project, marked_for_deletion_at: Date.current, organization: organization) + end it_behaves_like 'does not render restore project settings' end diff --git a/spec/graphql/resolvers/topics_resolver_spec.rb b/spec/graphql/resolvers/topics_resolver_spec.rb index 89f4583bce8882d728386773c2c08de435e58e73..de42c5b95e31726a3b5d67d02bfa78264ade70da 100644 --- a/spec/graphql/resolvers/topics_resolver_spec.rb +++ b/spec/graphql/resolvers/topics_resolver_spec.rb @@ -6,28 +6,92 @@ include GraphqlHelpers describe '#resolve' do - let!(:topic1) { create(:topic, name: 'GitLab', non_private_projects_count: 1) } - let!(:topic2) { create(:topic, name: 'git', non_private_projects_count: 2) } - let!(:topic3) { create(:topic, name: 'topic3', non_private_projects_count: 3) } + let_it_be(:organization) { create(:organization) } + let(:organization_id) { organization.to_global_id } - it 'finds all topics' do - expect(resolve_topics).to eq([topic3, topic2, topic1]) + let(:topic1) do + create(:topic, name: 'GitLab', non_private_projects_count: 1, organization: organization) end - context 'with search' do - it 'searches environment by name' do - expect(resolve_topics(search: 'git')).to eq([topic2, topic1]) + let(:topic2) do + create(:topic, name: 'git', non_private_projects_count: 2, organization: organization) + end + + let(:topic3) do + create(:topic, name: 'topic3', non_private_projects_count: 3, organization: organization) + end + + shared_examples 'topics query' do + it 'finds all topics' do + expect(resolve_topics).to eq([topic3, 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 + 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 + + context 'with organization id' do + it 'finds all topics' do + expect(resolve_topics(organization_id: organization_id)).to eq([topic3, topic2, topic1]) + end + + it 'matches searched organization topics' do + expect(resolve_topics(organization_id: organization_id, search: 'topic')).to eq([topic3]) + end + end + end + + shared_examples 'resource not available' do + it 'raises a GraphQL exception' do + expect_graphql_error_to_be_created(Gitlab::Graphql::Errors::ResourceNotAvailable) do + resolve_topics(organization_id: organization_id) end end end + + context 'when no current user is set' do + let_it_be(:organization) { create(:organization, :public) } + let(:user) { nil } + + it_behaves_like 'topics query' + end + + context 'when no current user is set having no public organization' do + let(:user) { nil } + + before do + Organizations::Organization.update_all(visibility_level: Organizations::Organization::INTERNAL) + end + + it_behaves_like 'resource not available' + end + + context 'when current user is set' do + let_it_be(:user) { create(:user, organizations: [organization]) } + + it_behaves_like 'topics query' + end + + context 'when current user is not a member of the organization' do + let_it_be(:user) { create(:user) } + let_it_be(:private_organization) { create(:organization, :private) } + + let(:organization_id) { private_organization.to_global_id } + + it_behaves_like 'resource not available' + end end def resolve_topics(args = {}) - resolve(described_class, args: args) + args[:organization_id] = organization.to_global_id unless args[:organization_id] + resolve(described_class, args: args, ctx: { current_user: user }) end end