+
+
+ {{ heading }}
+
+
+
+
+
+
+ {{ $options.I18N_NONE }}
+
+
+
+
diff --git a/app/assets/javascripts/runner/constants.js b/app/assets/javascripts/runner/constants.js
index 221696836ac..45e61768d1e 100644
--- a/app/assets/javascripts/runner/constants.js
+++ b/app/assets/javascripts/runner/constants.js
@@ -3,6 +3,8 @@ import { __, s__ } from '~/locale';
export const RUNNER_PAGE_SIZE = 20;
export const RUNNER_JOB_COUNT_LIMIT = 1000;
+export const RUNNER_DETAILS_PROJECTS_PAGE_SIZE = 5;
+
export const I18N_FETCH_ERROR = s__('Runners|Something went wrong while fetching runner data.');
export const I18N_DETAILS_TITLE = s__('Runners|Runner #%{runner_id}');
@@ -39,6 +41,13 @@ export const I18N_RESUME = __('Resume');
export const I18N_LOCKED_RUNNER_DESCRIPTION = s__('Runners|You cannot assign to other projects');
export const I18N_PAUSED_RUNNER_DESCRIPTION = s__('Runners|Not available to run jobs');
+// Runner details
+
+export const I18N_ASSIGNED_PROJECTS = s__('Runners|Assigned Projects (%{projectCount})');
+export const I18N_NONE = __('None');
+
+// Styles
+
export const RUNNER_TAG_BADGE_VARIANT = 'neutral';
export const RUNNER_TAG_BG_CLASS = 'gl-bg-blue-100';
diff --git a/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
new file mode 100644
index 00000000000..f97237b8267
--- /dev/null
+++ b/app/assets/javascripts/runner/graphql/get_runner_projects.query.graphql
@@ -0,0 +1,26 @@
+#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
+
+query getRunnerProjects(
+ $id: CiRunnerID!
+ $first: Int
+ $last: Int
+ $before: String
+ $after: String
+) {
+ runner(id: $id) {
+ id
+ projectCount
+ projects(first: $first, last: $last, before: $before, after: $after) {
+ nodes {
+ id
+ avatarUrl
+ name
+ nameWithNamespace
+ webUrl
+ }
+ pageInfo {
+ ...PageInfo
+ }
+ }
+ }
+}
diff --git a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
index 4479df555b9..ae29fa3a4df 100644
--- a/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
+++ b/app/assets/javascripts/runner/graphql/runner_details_shared.fragment.graphql
@@ -19,6 +19,9 @@ fragment RunnerDetailsShared on CiRunner {
deleteRunner
}
groups {
+ # Only a single group can be loaded here, while projects
+ # are loaded separately using the query with pagination
+ # parameters `get_runner_projects.query.graphql`.
nodes {
id
avatarUrl
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 25395dc6852..c6c038b3b01 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -31165,6 +31165,9 @@ msgstr ""
msgid "Runners|Assigned Group"
msgstr ""
+msgid "Runners|Assigned Projects (%{projectCount})"
+msgstr ""
+
msgid "Runners|Associated with one or more projects"
msgstr ""
diff --git a/spec/frontend/fixtures/runner.rb b/spec/frontend/fixtures/runner.rb
index 0660bd81849..befb4e23b22 100644
--- a/spec/frontend/fixtures/runner.rb
+++ b/spec/frontend/fixtures/runner.rb
@@ -11,11 +11,12 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
let_it_be(:admin) { create(:admin) }
let_it_be(:group) { create(:group) }
let_it_be(:project) { create(:project, :repository, :public) }
+ let_it_be(:project_2) { create(:project, :repository, :public) }
let_it_be(:instance_runner) { create(:ci_runner, :instance, version: '1.0.0', revision: '123', description: 'Instance runner', ip_address: '127.0.0.1') }
let_it_be(:group_runner) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner', ip_address: '127.0.0.1') }
let_it_be(:group_runner_2) { create(:ci_runner, :group, groups: [group], active: false, version: '2.0.0', revision: '456', description: 'Group runner 2', ip_address: '127.0.0.1') }
- let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
+ let_it_be(:project_runner) { create(:ci_runner, :project, projects: [project, project_2], active: false, version: '2.0.0', revision: '456', description: 'Project runner', ip_address: '127.0.0.1') }
query_path = 'runner/graphql/'
fixtures_path = 'graphql/runner/'
@@ -87,6 +88,22 @@ RSpec.describe 'Runner (JavaScript fixtures)' do
expect_graphql_errors_to_be_empty
end
end
+
+ describe GraphQL::Query, type: :request do
+ get_runner_projects_query_name = 'get_runner_projects.query.graphql'
+
+ let_it_be(:query) do
+ get_graphql_query_as_string("#{query_path}#{get_runner_projects_query_name}")
+ end
+
+ it "#{fixtures_path}#{get_runner_projects_query_name}.json" do
+ post_graphql(query, current_user: admin, variables: {
+ id: project_runner.to_global_id.to_s
+ })
+
+ expect_graphql_errors_to_be_empty
+ end
+ end
end
describe do
diff --git a/spec/frontend/runner/components/runner_details_spec.js b/spec/frontend/runner/components/runner_details_spec.js
index 51fcf940314..dca4356efc2 100644
--- a/spec/frontend/runner/components/runner_details_spec.js
+++ b/spec/frontend/runner/components/runner_details_spec.js
@@ -7,7 +7,7 @@ import { ACCESS_LEVEL_REF_PROTECTED, ACCESS_LEVEL_NOT_PROTECTED } from '~/runner
import RunnerDetails from '~/runner/components/runner_details.vue';
import RunnerDetail from '~/runner/components/runner_detail.vue';
-import RunnerDetailGroups from '~/runner/components/runner_detail_groups.vue';
+import RunnerGroups from '~/runner/components/runner_groups.vue';
import { runnerData, runnerWithGroupData } from '../mock_data';
@@ -35,7 +35,7 @@ describe('RunnerDetails', () => {
return ErrorWrapper(dtLabel);
};
- const findDetailGroups = () => wrapper.findComponent(RunnerDetailGroups);
+ const findDetailGroups = () => wrapper.findComponent(RunnerGroups);
const createComponent = ({ props = {}, mountFn = shallowMountExtended } = {}) => {
wrapper = mountFn(RunnerDetails, {
diff --git a/spec/frontend/runner/components/runner_detail_groups_spec.js b/spec/frontend/runner/components/runner_groups_spec.js
similarity index 90%
rename from spec/frontend/runner/components/runner_detail_groups_spec.js
rename to spec/frontend/runner/components/runner_groups_spec.js
index c5d78420fc8..b83733b9972 100644
--- a/spec/frontend/runner/components/runner_detail_groups_spec.js
+++ b/spec/frontend/runner/components/runner_groups_spec.js
@@ -1,6 +1,6 @@
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
-import RunnerDetailGroups from '~/runner/components/runner_detail_groups.vue';
+import RunnerGroups from '~/runner/components/runner_groups.vue';
import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
import { runnerData, runnerWithGroupData } from '../mock_data';
@@ -9,14 +9,14 @@ const mockInstanceRunner = runnerData.data.runner;
const mockGroupRunner = runnerWithGroupData.data.runner;
const mockGroup = mockGroupRunner.groups.nodes[0];
-describe('RunnerDetailGroups', () => {
+describe('RunnerGroups', () => {
let wrapper;
const findHeading = () => wrapper.find('h3');
const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
const createComponent = ({ runner = mockGroupRunner, mountFn = shallowMountExtended } = {}) => {
- wrapper = mountFn(RunnerDetailGroups, {
+ wrapper = mountFn(RunnerGroups, {
propsData: {
runner,
},
diff --git a/spec/frontend/runner/components/runner_pagination_spec.js b/spec/frontend/runner/components/runner_pagination_spec.js
index 59feb32dd2a..ecd6e6bd7f9 100644
--- a/spec/frontend/runner/components/runner_pagination_spec.js
+++ b/spec/frontend/runner/components/runner_pagination_spec.js
@@ -104,7 +104,6 @@ describe('RunnerPagination', () => {
expect(wrapper.emitted('input')[0]).toEqual([
{
- before: mockStartCursor,
page: 1,
},
]);
diff --git a/spec/frontend/runner/components/runner_projects_spec.js b/spec/frontend/runner/components/runner_projects_spec.js
new file mode 100644
index 00000000000..68a2130d6d9
--- /dev/null
+++ b/spec/frontend/runner/components/runner_projects_spec.js
@@ -0,0 +1,193 @@
+import { GlSkeletonLoading } from '@gitlab/ui';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { createAlert } from '~/flash';
+import { sprintf } from '~/locale';
+import {
+ I18N_ASSIGNED_PROJECTS,
+ I18N_NONE,
+ RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+} from '~/runner/constants';
+import RunnerProjects from '~/runner/components/runner_projects.vue';
+import RunnerAssignedItem from '~/runner/components/runner_assigned_item.vue';
+import RunnerPagination from '~/runner/components/runner_pagination.vue';
+import { captureException } from '~/runner/sentry_utils';
+
+import getRunnerProjectsQuery from '~/runner/graphql/get_runner_projects.query.graphql';
+
+import { runnerData, runnerProjectsData } from '../mock_data';
+
+jest.mock('~/flash');
+jest.mock('~/runner/sentry_utils');
+
+const mockRunner = runnerData.data.runner;
+const mockRunnerWithProjects = runnerProjectsData.data.runner;
+const mockProjects = mockRunnerWithProjects.projects.nodes;
+
+Vue.use(VueApollo);
+
+describe('RunnerProjects', () => {
+ let wrapper;
+ let mockRunnerProjectsQuery;
+
+ const findHeading = () => wrapper.find('h3');
+ const findGlSkeletonLoading = () => wrapper.findComponent(GlSkeletonLoading);
+ const findRunnerAssignedItems = () => wrapper.findAllComponents(RunnerAssignedItem);
+ const findRunnerPagination = () => wrapper.findComponent(RunnerPagination);
+
+ const createComponent = ({ mountFn = shallowMountExtended } = {}) => {
+ wrapper = mountFn(RunnerProjects, {
+ apolloProvider: createMockApollo([[getRunnerProjectsQuery, mockRunnerProjectsQuery]]),
+ propsData: {
+ runner: mockRunner,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mockRunnerProjectsQuery = jest.fn();
+ });
+
+ afterEach(() => {
+ mockRunnerProjectsQuery.mockReset();
+ wrapper.destroy();
+ });
+
+ it('Requests runner projects', async () => {
+ createComponent();
+
+ await waitForPromises();
+
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(1);
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledWith({
+ id: mockRunner.id,
+ first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ });
+ });
+
+ describe('When there are projects assigned', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockResolvedValueOnce(runnerProjectsData);
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('Shows a heading', async () => {
+ const expected = sprintf(I18N_ASSIGNED_PROJECTS, { projectCount: mockProjects.length });
+
+ expect(findHeading().text()).toBe(expected);
+ });
+
+ it('Shows projects', () => {
+ expect(findRunnerAssignedItems().length).toBe(mockProjects.length);
+ });
+
+ it('Shows a project', () => {
+ const item = findRunnerAssignedItems().at(0);
+ const { webUrl, name, nameWithNamespace, avatarUrl } = mockProjects[0];
+
+ expect(item.props()).toMatchObject({
+ href: webUrl,
+ name,
+ fullName: nameWithNamespace,
+ avatarUrl,
+ });
+ });
+
+ describe('When "Next" page is clicked', () => {
+ beforeEach(async () => {
+ findRunnerPagination().vm.$emit('input', { page: 3, after: 'AFTER_CURSOR' });
+
+ await waitForPromises();
+ });
+
+ it('A new page is requested', () => {
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(2);
+ expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
+ id: mockRunner.id,
+ first: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ after: 'AFTER_CURSOR',
+ });
+ });
+
+ it('When "Prev" page is clicked, the previous page is requested', async () => {
+ findRunnerPagination().vm.$emit('input', { page: 2, before: 'BEFORE_CURSOR' });
+
+ await waitForPromises();
+
+ expect(mockRunnerProjectsQuery).toHaveBeenCalledTimes(3);
+ expect(mockRunnerProjectsQuery).toHaveBeenLastCalledWith({
+ id: mockRunner.id,
+ last: RUNNER_DETAILS_PROJECTS_PAGE_SIZE,
+ before: 'BEFORE_CURSOR',
+ });
+ });
+ });
+ });
+
+ describe('When loading', () => {
+ it('shows loading indicator and no other content', () => {
+ createComponent();
+
+ expect(findGlSkeletonLoading().exists()).toBe(true);
+
+ expect(wrapper.findByText(I18N_NONE).exists()).toBe(false);
+ expect(findRunnerAssignedItems().length).toBe(0);
+
+ expect(findRunnerPagination().attributes('disabled')).toBe('true');
+ });
+ });
+
+ describe('When there are no projects', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockResolvedValueOnce({
+ data: {
+ runner: {
+ id: mockRunner.id,
+ projectCount: 0,
+ projects: {
+ nodes: [],
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ },
+ },
+ },
+ });
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('Shows a "None" label', () => {
+ expect(wrapper.findByText(I18N_NONE).exists()).toBe(true);
+ });
+ });
+
+ describe('When an error occurs', () => {
+ beforeEach(async () => {
+ mockRunnerProjectsQuery.mockRejectedValue(new Error('Error!'));
+
+ createComponent();
+ await waitForPromises();
+ });
+
+ it('shows an error', () => {
+ expect(createAlert).toHaveBeenCalled();
+ });
+
+ it('reports an error', () => {
+ expect(captureException).toHaveBeenCalledWith({
+ component: 'RunnerProjects',
+ error: expect.any(Error),
+ });
+ });
+ });
+});
diff --git a/spec/frontend/runner/components/runner_update_form_spec.js b/spec/frontend/runner/components/runner_update_form_spec.js
index f2f847ce4a7..f92244f41f7 100644
--- a/spec/frontend/runner/components/runner_update_form_spec.js
+++ b/spec/frontend/runner/components/runner_update_form_spec.js
@@ -130,8 +130,8 @@ describe('RunnerUpdateForm', () => {
editAdminUrl,
contactedAt,
userPermissions,
- groups,
version,
+ groups,
...submitted
} = mockRunner;
diff --git a/spec/frontend/runner/mock_data.js b/spec/frontend/runner/mock_data.js
index 229e3395e4c..7260f0fbc9a 100644
--- a/spec/frontend/runner/mock_data.js
+++ b/spec/frontend/runner/mock_data.js
@@ -6,6 +6,7 @@ import runnersCountData from 'test_fixtures/graphql/runner/get_runners_count.que
import runnersDataPaginated from 'test_fixtures/graphql/runner/get_runners.query.graphql.paginated.json';
import runnerData from 'test_fixtures/graphql/runner/get_runner.query.graphql.json';
import runnerWithGroupData from 'test_fixtures/graphql/runner/get_runner.query.graphql.with_group.json';
+import runnerProjectsData from 'test_fixtures/graphql/runner/get_runner_projects.query.graphql.json';
// Group queries
import groupRunnersData from 'test_fixtures/graphql/runner/get_group_runners.query.graphql.json';
@@ -18,6 +19,7 @@ export {
runnersDataPaginated,
runnerData,
runnerWithGroupData,
+ runnerProjectsData,
groupRunnersData,
groupRunnersCountData,
groupRunnersDataPaginated,