Commit 10670ba5 authored by Miguel Rincon's avatar Miguel Rincon Committed by Paul Slaughter

Add runner edit button to group

List of runners in a group can now display a an edit button that takes
users to the runner edit form in the relevant group.
parent 060d5d36
...@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue'; ...@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config';
...@@ -57,6 +58,7 @@ export default { ...@@ -57,6 +58,7 @@ export default {
RunnerStats, RunnerStats,
RunnerPagination, RunnerPagination,
RunnerTypeTabs, RunnerTypeTabs,
RunnerActionsCell,
}, },
props: { props: {
registrationToken: { registrationToken: {
...@@ -279,6 +281,9 @@ export default { ...@@ -279,6 +281,9 @@ export default {
<runner-name :runner="runner" /> <runner-name :runner="runner" />
</gl-link> </gl-link>
</template> </template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell :runner="runner" :edit-url="runner.editAdminUrl" />
</template>
</runner-list> </runner-list>
<runner-pagination <runner-pagination
v-model="search.pagination" v-model="search.pagination"
......
...@@ -17,6 +17,11 @@ export default { ...@@ -17,6 +17,11 @@ export default {
type: Object, type: Object,
required: true, required: true,
}, },
editUrl: {
type: String,
default: null,
required: false,
},
}, },
computed: { computed: {
canUpdate() { canUpdate() {
...@@ -31,14 +36,7 @@ export default { ...@@ -31,14 +36,7 @@ export default {
<template> <template>
<gl-button-group> <gl-button-group>
<!-- <runner-edit-button v-if="canUpdate && editUrl" :href="editUrl" />
This button appears for administrators: those with
access to the adminUrl. More advanced permissions policies
will allow more granular permissions.
See https://gitlab.com/gitlab-org/gitlab/-/issues/334802
-->
<runner-edit-button v-if="canUpdate && runner.editAdminUrl" :href="runner.editAdminUrl" />
<runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" /> <runner-pause-button v-if="canUpdate" :runner="runner" :compact="true" />
<runner-delete-button v-if="canDelete" :runner="runner" :compact="true" /> <runner-delete-button v-if="canDelete" :runner="runner" :compact="true" />
</gl-button-group> </gl-button-group>
......
<script> <script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { __ } from '~/locale'; import { I18N_EDIT } from '../constants';
const I18N_EDIT = __('Edit');
export default { export default {
components: { components: {
......
...@@ -5,7 +5,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils'; ...@@ -5,7 +5,6 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { __, s__ } from '~/locale'; import { __, s__ } from '~/locale';
import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatJobCount, tableField } from '../utils'; import { formatJobCount, tableField } from '../utils';
import RunnerActionsCell from './cells/runner_actions_cell.vue';
import RunnerSummaryCell from './cells/runner_summary_cell.vue'; import RunnerSummaryCell from './cells/runner_summary_cell.vue';
import RunnerStatusCell from './cells/runner_status_cell.vue'; import RunnerStatusCell from './cells/runner_status_cell.vue';
import RunnerTags from './runner_tags.vue'; import RunnerTags from './runner_tags.vue';
...@@ -16,7 +15,6 @@ export default { ...@@ -16,7 +15,6 @@ export default {
GlSkeletonLoader, GlSkeletonLoader,
TooltipOnTruncate, TooltipOnTruncate,
TimeAgo, TimeAgo,
RunnerActionsCell,
RunnerSummaryCell, RunnerSummaryCell,
RunnerTags, RunnerTags,
RunnerStatusCell, RunnerStatusCell,
...@@ -121,7 +119,7 @@ export default { ...@@ -121,7 +119,7 @@ export default {
</template> </template>
<template #cell(actions)="{ item }"> <template #cell(actions)="{ item }">
<runner-actions-cell :runner="item" /> <slot name="runner-actions-cell" :runner="item"></slot>
</template> </template>
</gl-table-lite> </gl-table-lite>
......
...@@ -35,7 +35,8 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__( ...@@ -35,7 +35,8 @@ export const I18N_STALE_RUNNER_DESCRIPTION = s__(
'Runners|No contact from this runner in over 3 months', 'Runners|No contact from this runner in over 3 months',
); );
// Active flag // Actions
export const I18N_EDIT = __('Edit');
export const I18N_PAUSE = __('Pause'); export const I18N_PAUSE = __('Pause');
export const I18N_RESUME = __('Resume'); export const I18N_RESUME = __('Resume');
export const I18N_DELETE_RUNNER = s__('Runners|Delete runner'); export const I18N_DELETE_RUNNER = s__('Runners|Delete runner');
......
...@@ -27,6 +27,7 @@ query getGroupRunners( ...@@ -27,6 +27,7 @@ query getGroupRunners(
) { ) {
edges { edges {
webUrl webUrl
editUrl
node { node {
__typename __typename
...RunnerNode ...RunnerNode
......
...@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue'; ...@@ -12,6 +12,7 @@ import RunnerName from '../components/runner_name.vue';
import RunnerStats from '../components/stat/runner_stats.vue'; import RunnerStats from '../components/stat/runner_stats.vue';
import RunnerPagination from '../components/runner_pagination.vue'; import RunnerPagination from '../components/runner_pagination.vue';
import RunnerTypeTabs from '../components/runner_type_tabs.vue'; import RunnerTypeTabs from '../components/runner_type_tabs.vue';
import RunnerActionsCell from '../components/cells/runner_actions_cell.vue';
import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config';
import { import {
...@@ -55,6 +56,7 @@ export default { ...@@ -55,6 +56,7 @@ export default {
RunnerStats, RunnerStats,
RunnerPagination, RunnerPagination,
RunnerTypeTabs, RunnerTypeTabs,
RunnerActionsCell,
}, },
props: { props: {
registrationToken: { registrationToken: {
...@@ -74,8 +76,8 @@ export default { ...@@ -74,8 +76,8 @@ export default {
return { return {
search: fromUrlQueryToSearch(), search: fromUrlQueryToSearch(),
runners: { runners: {
webUrls: [],
items: [], items: [],
urlsById: {},
pageInfo: {}, pageInfo: {},
}, },
}; };
...@@ -91,12 +93,23 @@ export default { ...@@ -91,12 +93,23 @@ export default {
return this.variables; return this.variables;
}, },
update(data) { update(data) {
const { runners } = data?.group || {}; const { edges = [], pageInfo = {} } = data?.group?.runners || {};
const items = [];
const urlsById = {};
edges.forEach(({ node, webUrl, editUrl }) => {
items.push(node);
urlsById[node.id] = {
web: webUrl,
edit: editUrl,
};
});
return { return {
webUrls: runners?.edges.map(({ webUrl }) => webUrl) || [], items,
items: runners?.edges.map(({ node }) => node) || [], urlsById,
pageInfo: runners?.pageInfo || {}, pageInfo,
}; };
}, },
error(error) { error(error) {
...@@ -222,6 +235,12 @@ export default { ...@@ -222,6 +235,12 @@ export default {
} }
return null; return null;
}, },
webUrl(runner) {
return this.runners.urlsById[runner.id]?.web;
},
editUrl(runner) {
return this.runners.urlsById[runner.id]?.edit;
},
reportToSentry(error) { reportToSentry(error) {
captureException({ error, component: this.$options.name }); captureException({ error, component: this.$options.name });
}, },
...@@ -273,13 +292,20 @@ export default { ...@@ -273,13 +292,20 @@ export default {
</div> </div>
<template v-else> <template v-else>
<runner-list :runners="runners.items" :loading="runnersLoading"> <runner-list :runners="runners.items" :loading="runnersLoading">
<template #runner-name="{ runner, index }"> <template #runner-name="{ runner }">
<gl-link :href="runners.webUrls[index]"> <gl-link :href="webUrl(runner)">
<runner-name :runner="runner" /> <runner-name :runner="runner" />
</gl-link> </gl-link>
</template> </template>
<template #runner-actions-cell="{ runner }">
<runner-actions-cell :runner="runner" :edit-url="editUrl(runner)" />
</template>
</runner-list> </runner-list>
<runner-pagination v-model="search.pagination" :page-info="runners.pageInfo" /> <runner-pagination
v-model="search.pagination"
class="gl-mt-3"
:page-info="runners.pageInfo"
/>
</template> </template>
</div> </div>
</template> </template>
...@@ -19,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_ ...@@ -19,6 +19,7 @@ import RunnerFilteredSearchBar from '~/runner/components/runner_filtered_search_
import RunnerList from '~/runner/components/runner_list.vue'; import RunnerList from '~/runner/components/runner_list.vue';
import RunnerStats from '~/runner/components/stat/runner_stats.vue'; import RunnerStats from '~/runner/components/stat/runner_stats.vue';
import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue'; import RegistrationDropdown from '~/runner/components/registration/registration_dropdown.vue';
import RunnerActionsCell from '~/runner/components/cells/runner_actions_cell.vue';
import RunnerPagination from '~/runner/components/runner_pagination.vue'; import RunnerPagination from '~/runner/components/runner_pagination.vue';
import { import {
...@@ -188,6 +189,21 @@ describe('AdminRunnersApp', () => { ...@@ -188,6 +189,21 @@ describe('AdminRunnersApp', () => {
expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`); expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${numericId}`);
}); });
it('renders runner actions for each runner', async () => {
createComponent({ mountFn: mountExtended });
await waitForPromises();
const runnerActions = wrapper.find('tr [data-testid="td-actions"]').find(RunnerActionsCell);
const runner = runnersData.data.runners.nodes[0];
expect(runnerActions.props()).toEqual({
runner,
editUrl: runner.editAdminUrl,
});
});
it('requests the runners with no filters', () => { it('requests the runners with no filters', () => {
expect(mockRunnersQuery).toHaveBeenLastCalledWith({ expect(mockRunnersQuery).toHaveBeenLastCalledWith({
status: undefined, status: undefined,
......
...@@ -15,9 +15,10 @@ describe('RunnerActionsCell', () => { ...@@ -15,9 +15,10 @@ describe('RunnerActionsCell', () => {
const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton); const findRunnerPauseBtn = () => wrapper.findComponent(RunnerPauseButton);
const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton); const findDeleteBtn = () => wrapper.findComponent(RunnerDeleteButton);
const createComponent = (runner = {}, options) => { const createComponent = ({ runner = {}, ...props } = {}) => {
wrapper = shallowMountExtended(RunnerActionsCell, { wrapper = shallowMountExtended(RunnerActionsCell, {
propsData: { propsData: {
editUrl: mockRunner.editAdminUrl,
runner: { runner: {
id: mockRunner.id, id: mockRunner.id,
shortSha: mockRunner.shortSha, shortSha: mockRunner.shortSha,
...@@ -25,8 +26,8 @@ describe('RunnerActionsCell', () => { ...@@ -25,8 +26,8 @@ describe('RunnerActionsCell', () => {
userPermissions: mockRunner.userPermissions, userPermissions: mockRunner.userPermissions,
...runner, ...runner,
}, },
...props,
}, },
...options,
}); });
}; };
...@@ -43,18 +44,20 @@ describe('RunnerActionsCell', () => { ...@@ -43,18 +44,20 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner edit link when user cannot update', () => { it('Does not render the runner edit link when user cannot update', () => {
createComponent({ createComponent({
userPermissions: { runner: {
...mockRunner.userPermissions, userPermissions: {
updateRunner: false, ...mockRunner.userPermissions,
updateRunner: false,
},
}, },
}); });
expect(findEditBtn().exists()).toBe(false); expect(findEditBtn().exists()).toBe(false);
}); });
it('Does not render the runner edit link when editAdminUrl is not provided', () => { it('Does not render the runner edit link when editUrl is not provided', () => {
createComponent({ createComponent({
editAdminUrl: null, editUrl: null,
}); });
expect(findEditBtn().exists()).toBe(false); expect(findEditBtn().exists()).toBe(false);
...@@ -70,9 +73,11 @@ describe('RunnerActionsCell', () => { ...@@ -70,9 +73,11 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner pause button when user cannot update', () => { it('Does not render the runner pause button when user cannot update', () => {
createComponent({ createComponent({
userPermissions: { runner: {
...mockRunner.userPermissions, userPermissions: {
updateRunner: false, ...mockRunner.userPermissions,
updateRunner: false,
},
}, },
}); });
...@@ -89,9 +94,11 @@ describe('RunnerActionsCell', () => { ...@@ -89,9 +94,11 @@ describe('RunnerActionsCell', () => {
it('Does not render the runner delete button when user cannot delete', () => { it('Does not render the runner delete button when user cannot delete', () => {
createComponent({ createComponent({
userPermissions: { runner: {
...mockRunner.userPermissions, userPermissions: {
deleteRunner: false, ...mockRunner.userPermissions,
deleteRunner: false,
},
}, },
}); });
......
...@@ -6,9 +6,6 @@ import { ...@@ -6,9 +6,6 @@ import {
} from 'helpers/vue_test_utils_helper'; } from 'helpers/vue_test_utils_helper';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import RunnerList from '~/runner/components/runner_list.vue'; import RunnerList from '~/runner/components/runner_list.vue';
import RunnerEditButton from '~/runner/components/runner_edit_button.vue';
import RunnerPauseButton from '~/runner/components/runner_pause_button.vue';
import RunnerDeleteButton from '~/runner/components/runner_delete_button.vue';
import { runnersData } from '../mock_data'; import { runnersData } from '../mock_data';
const mockRunners = runnersData.data.runners.nodes; const mockRunners = runnersData.data.runners.nodes;
...@@ -24,13 +21,14 @@ describe('RunnerList', () => { ...@@ -24,13 +21,14 @@ describe('RunnerList', () => {
const findCell = ({ row = 0, fieldKey }) => const findCell = ({ row = 0, fieldKey }) =>
extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`)); extendedWrapper(findRows().at(row).find(`[data-testid="td-${fieldKey}"]`));
const createComponent = ({ props = {} } = {}, mountFn = shallowMountExtended) => { const createComponent = ({ props = {}, ...options } = {}, mountFn = shallowMountExtended) => {
wrapper = mountFn(RunnerList, { wrapper = mountFn(RunnerList, {
propsData: { propsData: {
runners: mockRunners, runners: mockRunners,
activeRunnersCount: mockActiveRunnersCount, activeRunnersCount: mockActiveRunnersCount,
...props, ...props,
}, },
...options,
}); });
}; };
...@@ -91,11 +89,31 @@ describe('RunnerList', () => { ...@@ -91,11 +89,31 @@ describe('RunnerList', () => {
expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String)); expect(findCell({ fieldKey: 'contactedAt' }).text()).toEqual(expect.any(String));
// Actions // Actions
const actions = findCell({ fieldKey: 'actions' }); expect(findCell({ fieldKey: 'actions' }).exists()).toBe(true);
});
describe('Scoped cell slots', () => {
it('Render #runner-name slot in "summary" cell', () => {
createComponent(
{
scopedSlots: { 'runner-name': ({ runner }) => `Summary: ${runner.id}` },
},
mountExtended,
);
expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`);
});
expect(actions.findComponent(RunnerEditButton).exists()).toBe(true); it('Render #runner-actions-cell slot in "actions" cell', () => {
expect(actions.findComponent(RunnerPauseButton).exists()).toBe(true); createComponent(
expect(actions.findComponent(RunnerDeleteButton).exists()).toBe(true); {
scopedSlots: { 'runner-actions-cell': ({ runner }) => `Actions: ${runner.id}` },
},
mountExtended,
);
expect(findCell({ fieldKey: 'actions' }).text()).toBe(`Actions: ${mockRunners[0].id}`);
});
}); });
describe('Table data formatting', () => { describe('Table data formatting', () => {
......
import Vue, { nextTick } from 'vue'; import Vue, { nextTick } from 'vue';
import { GlLink } from '@gitlab/ui'; import { GlButton, GlLink } from '@gitlab/ui';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import setWindowLocation from 'helpers/set_window_location_helper'; import setWindowLocation from 'helpers/set_window_location_helper';
...@@ -30,6 +30,7 @@ import { ...@@ -30,6 +30,7 @@ import {
PARAM_KEY_STATUS, PARAM_KEY_STATUS,
STATUS_ACTIVE, STATUS_ACTIVE,
RUNNER_PAGE_SIZE, RUNNER_PAGE_SIZE,
I18N_EDIT,
} from '~/runner/constants'; } from '~/runner/constants';
import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql'; import getGroupRunnersQuery from '~/runner/graphql/get_group_runners.query.graphql';
import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql'; import getGroupRunnersCountQuery from '~/runner/graphql/get_group_runners_count.query.graphql';
...@@ -42,7 +43,8 @@ Vue.use(VueApollo); ...@@ -42,7 +43,8 @@ Vue.use(VueApollo);
const mockGroupFullPath = 'group1'; const mockGroupFullPath = 'group1';
const mockRegistrationToken = 'AABBCC'; const mockRegistrationToken = 'AABBCC';
const mockGroupRunnersLimitedCount = groupRunnersData.data.group.runners.edges.length; const mockGroupRunnersEdges = groupRunnersData.data.group.runners.edges;
const mockGroupRunnersLimitedCount = mockGroupRunnersEdges.length;
jest.mock('~/flash'); jest.mock('~/flash');
jest.mock('~/runner/sentry_utils'); jest.mock('~/runner/sentry_utils');
...@@ -60,6 +62,7 @@ describe('GroupRunnersApp', () => { ...@@ -60,6 +62,7 @@ describe('GroupRunnersApp', () => {
const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown); const findRegistrationDropdown = () => wrapper.findComponent(RegistrationDropdown);
const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs); const findRunnerTypeTabs = () => wrapper.findComponent(RunnerTypeTabs);
const findRunnerList = () => wrapper.findComponent(RunnerList); const findRunnerList = () => wrapper.findComponent(RunnerList);
const findRunnerRow = (id) => extendedWrapper(wrapper.findByTestId(`runner-row-${id}`));
const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination)); const findRunnerPagination = () => extendedWrapper(wrapper.findComponent(RunnerPagination));
const findRunnerPaginationPrev = () => const findRunnerPaginationPrev = () =>
findRunnerPagination().findByLabelText('Go to previous page'); findRunnerPagination().findByLabelText('Go to previous page');
...@@ -156,20 +159,7 @@ describe('GroupRunnersApp', () => { ...@@ -156,20 +159,7 @@ describe('GroupRunnersApp', () => {
it('shows the runners list', () => { it('shows the runners list', () => {
const runners = findRunnerList().props('runners'); const runners = findRunnerList().props('runners');
expect(runners).toEqual(groupRunnersData.data.group.runners.edges.map(({ node }) => node)); expect(runners).toEqual(mockGroupRunnersEdges.map(({ node }) => node));
});
it('runner item links to the runner group page', async () => {
const { webUrl, node } = groupRunnersData.data.group.runners.edges[0];
const { id, shortSha } = node;
createComponent({ mountFn: mountExtended });
await waitForPromises();
const runnerLink = wrapper.find('tr [data-testid="td-summary"]').find(GlLink);
expect(runnerLink.text()).toBe(`#${getIdFromGraphQLId(id)} (${shortSha})`);
expect(runnerLink.attributes('href')).toBe(webUrl);
}); });
it('requests the runners with group path and no other filters', () => { it('requests the runners with group path and no other filters', () => {
...@@ -196,6 +186,34 @@ describe('GroupRunnersApp', () => { ...@@ -196,6 +186,34 @@ describe('GroupRunnersApp', () => {
); );
}); });
describe('Single runner row', () => {
const { webUrl, editUrl, node } = mockGroupRunnersEdges[0];
const { id: graphqlId, shortSha } = node;
const id = getIdFromGraphQLId(graphqlId);
beforeEach(async () => {
createComponent({ mountFn: mountExtended });
await waitForPromises();
});
it('view link is displayed correctly', () => {
const viewLink = findRunnerRow(id).findByTestId('td-summary').findComponent(GlLink);
expect(viewLink.text()).toBe(`#${id} (${shortSha})`);
expect(viewLink.attributes('href')).toBe(webUrl);
});
it('edit link is displayed correctly', () => {
const editLink = findRunnerRow(id).findByTestId('td-actions').findComponent(GlButton);
expect(editLink.attributes()).toMatchObject({
'aria-label': I18N_EDIT,
href: editUrl,
});
});
});
describe('when a filter is preselected', () => { describe('when a filter is preselected', () => {
beforeEach(async () => { beforeEach(async () => {
setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`); setWindowLocation(`?status[]=${STATUS_ACTIVE}&runner_type[]=${INSTANCE_TYPE}`);
......
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