Commit 308f4f65 authored by Vitaly Slobodin's avatar Vitaly Slobodin

Merge branch '344927-refactor-admin-deploy-keys-table-to-vue' into 'master'

Add table and pagination to deploy keys Vue conversion

See merge request gitlab-org/gitlab!74238
parents 26e363f9 f3355c81
<script> <script>
import { GlTable, GlButton } from '@gitlab/ui'; import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { cleanLeadingSeparator } from '~/lib/utils/url_utility';
import createFlash from '~/flash';
export default { export default {
name: 'DeployKeysTable', name: 'DeployKeysTable',
i18n: { i18n: {
pageTitle: __('Public deploy keys'), pageTitle: __('Public deploy keys'),
newDeployKeyButtonText: __('New deploy key'), newDeployKeyButtonText: __('New deploy key'),
emptyStateTitle: __('No public deploy keys'),
emptyStateDescription: __(
'Deploy keys grant read/write access to all repositories in your instance',
),
remove: __('Remove deploy key'),
edit: __('Edit deploy key'),
pagination: {
next: __('Next'),
prev: __('Prev'),
},
apiErrorMessage: __('An error occurred fetching the public deploy keys. Please try again.'),
}, },
fields: [ fields: [
{ {
...@@ -29,13 +44,83 @@ export default { ...@@ -29,13 +44,83 @@ export default {
{ {
key: 'actions', key: 'actions',
label: __('Actions'), label: __('Actions'),
tdClass: 'gl-lg-w-1px gl-white-space-nowrap',
thClass: 'gl-lg-w-1px gl-white-space-nowrap',
}, },
], ],
DEFAULT_PER_PAGE,
components: { components: {
GlTable, GlTable,
GlButton, GlButton,
GlPagination,
TimeAgoTooltip,
GlLoadingIcon,
GlEmptyState,
}, },
inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'], inject: ['editPath', 'deletePath', 'createPath', 'emptyStateSvgPath'],
data() {
return {
page: 1,
totalItems: 0,
loading: false,
items: [],
};
},
computed: {
shouldShowTable() {
return this.totalItems !== 0 || this.loading;
},
},
watch: {
page(newPage) {
this.fetchDeployKeys(newPage);
},
},
mounted() {
this.fetchDeployKeys();
},
methods: {
editHref(id) {
return this.editPath.replace(':id', id);
},
projectHref(project) {
return `/${cleanLeadingSeparator(project.path_with_namespace)}`;
},
async fetchDeployKeys(page) {
this.loading = true;
try {
const { headers, data: items } = await Api.deployKeys({
page,
public: true,
});
if (this.totalItems === 0) {
this.totalItems = parseInt(headers?.['x-total'], 10) || 0;
}
this.items = items.map(
({ id, title, fingerprint, projects_with_write_access, created_at }) => ({
id,
title,
fingerprint,
projects: projects_with_write_access,
created: created_at,
}),
);
} catch (error) {
createFlash({
message: this.$options.i18n.apiErrorMessage,
captureError: true,
error,
});
this.totalItems = 0;
this.items = [];
}
this.loading = false;
},
},
}; };
</script> </script>
...@@ -45,10 +130,71 @@ export default { ...@@ -45,10 +130,71 @@ export default {
<h4 class="gl-m-0"> <h4 class="gl-m-0">
{{ $options.i18n.pageTitle }} {{ $options.i18n.pageTitle }}
</h4> </h4>
<gl-button variant="confirm" :href="createPath">{{ <gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{
$options.i18n.newDeployKeyButtonText $options.i18n.newDeployKeyButtonText
}}</gl-button> }}</gl-button>
</div> </div>
<gl-table :fields="$options.fields" data-testid="deploy-keys-list" /> <template v-if="shouldShowTable">
<gl-table
:busy="loading"
:items="items"
:fields="$options.fields"
stacked="lg"
data-testid="deploy-keys-list"
>
<template #table-busy>
<gl-loading-icon size="lg" class="gl-my-5" />
</template>
<template #cell(projects)="{ item: { projects } }">
<a
v-for="project in projects"
:key="project.id"
:href="projectHref(project)"
class="gl-display-block"
>{{ project.name_with_namespace }}</a
>
</template>
<template #cell(fingerprint)="{ item: { fingerprint } }">
<code>{{ fingerprint }}</code>
</template>
<template #cell(created)="{ item: { created } }">
<time-ago-tooltip :time="created" />
</template>
<template #head(actions)="{ label }">
<span class="gl-sr-only">{{ label }}</span>
</template>
<template #cell(actions)="{ item: { id } }">
<gl-button
icon="pencil"
:aria-label="$options.i18n.edit"
:href="editHref(id)"
class="gl-mr-2"
/>
<gl-button variant="danger" icon="remove" :aria-label="$options.i18n.remove" />
</template>
</gl-table>
<gl-pagination
v-if="!loading"
v-model="page"
:per-page="$options.DEFAULT_PER_PAGE"
:total-items="totalItems"
:next-text="$options.i18n.pagination.next"
:prev-text="$options.i18n.pagination.prev"
align="center"
/>
</template>
<gl-empty-state
v-else
:svg-path="emptyStateSvgPath"
:title="$options.i18n.emptyStateTitle"
:description="$options.i18n.emptyStateDescription"
:primary-button-text="$options.i18n.newDeployKeyButtonText"
:primary-button-link="createPath"
/>
</div> </div>
</template> </template>
...@@ -91,6 +91,7 @@ const Api = { ...@@ -91,6 +91,7 @@ const Api = {
projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings', projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings',
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings', groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings', notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
group(groupId, callback = () => {}) { group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId); const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
...@@ -950,6 +951,12 @@ const Api = { ...@@ -950,6 +951,12 @@ const Api = {
return axios.delete(url); return axios.delete(url);
}, },
deployKeys(params = {}) {
const url = Api.buildUrl(this.deployKeysPath);
return axios.get(url, { params: { per_page: DEFAULT_PER_PAGE, ...params } });
},
async updateNotificationSettings(projectId, groupId, data = {}) { async updateNotificationSettings(projectId, groupId, data = {}) {
let url = Api.buildUrl(this.notificationSettingsPath); let url = Api.buildUrl(this.notificationSettingsPath);
......
...@@ -3576,6 +3576,9 @@ msgstr "" ...@@ -3576,6 +3576,9 @@ msgstr ""
msgid "An error occurred fetching the project authors." msgid "An error occurred fetching the project authors."
msgstr "" msgstr ""
msgid "An error occurred fetching the public deploy keys. Please try again."
msgstr ""
msgid "An error occurred previewing the blob" msgid "An error occurred previewing the blob"
msgstr "" msgstr ""
...@@ -23536,6 +23539,9 @@ msgstr "" ...@@ -23536,6 +23539,9 @@ msgstr ""
msgid "No projects found" msgid "No projects found"
msgstr "" msgstr ""
msgid "No public deploy keys"
msgstr ""
msgid "No public groups" msgid "No public groups"
msgstr "" msgstr ""
......
import { merge } from 'lodash'; import { merge } from 'lodash';
import { GlTable, GlButton } from '@gitlab/ui'; import { GlLoadingIcon, GlEmptyState, GlPagination } from '@gitlab/ui';
import { nextTick } from 'vue';
import responseBody from 'test_fixtures/api/deploy_keys/index.json';
import { mountExtended } from 'helpers/vue_test_utils_helper'; import { mountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import DeployKeysTable from '~/admin/deploy_keys/components/table.vue'; import DeployKeysTable from '~/admin/deploy_keys/components/table.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import createFlash from '~/flash';
jest.mock('~/api');
jest.mock('~/flash');
describe('DeployKeysTable', () => { describe('DeployKeysTable', () => {
let wrapper; let wrapper;
...@@ -14,12 +23,53 @@ describe('DeployKeysTable', () => { ...@@ -14,12 +23,53 @@ describe('DeployKeysTable', () => {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg', emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg',
}; };
const deployKey = responseBody[0];
const deployKey2 = responseBody[1];
const createComponent = (provide = {}) => { const createComponent = (provide = {}) => {
wrapper = mountExtended(DeployKeysTable, { wrapper = mountExtended(DeployKeysTable, {
provide: merge({}, defaultProvide, provide), provide: merge({}, defaultProvide, provide),
}); });
}; };
const findEditButton = (index) =>
wrapper.findAllByLabelText(DeployKeysTable.i18n.edit, { selector: 'a' }).at(index);
const findRemoveButton = (index) =>
wrapper.findAllByLabelText(DeployKeysTable.i18n.remove, { selector: 'button' }).at(index);
const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findTimeAgoTooltip = (index) => wrapper.findAllComponents(TimeAgoTooltip).at(index);
const findPagination = () => wrapper.findComponent(GlPagination);
const expectDeployKeyIsRendered = (expectedDeployKey, expectedRowIndex) => {
const editButton = findEditButton(expectedRowIndex);
const timeAgoTooltip = findTimeAgoTooltip(expectedRowIndex);
expect(wrapper.findByText(expectedDeployKey.title).exists()).toBe(true);
expect(wrapper.findByText(expectedDeployKey.fingerprint, { selector: 'code' }).exists()).toBe(
true,
);
expect(timeAgoTooltip.exists()).toBe(true);
expect(timeAgoTooltip.props('time')).toBe(expectedDeployKey.created_at);
expect(editButton.exists()).toBe(true);
expect(editButton.attributes('href')).toBe(`/admin/deploy_keys/${expectedDeployKey.id}/edit`);
expect(findRemoveButton(expectedRowIndex).exists()).toBe(true);
};
const itRendersTheEmptyState = () => {
it('renders empty state', () => {
const emptyState = wrapper.findComponent(GlEmptyState);
expect(emptyState.exists()).toBe(true);
expect(emptyState.props()).toMatchObject({
svgPath: defaultProvide.emptyStateSvgPath,
title: DeployKeysTable.i18n.emptyStateTitle,
description: DeployKeysTable.i18n.emptyStateDescription,
primaryButtonText: DeployKeysTable.i18n.newDeployKeyButtonText,
primaryButtonLink: defaultProvide.createPath,
});
});
};
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
...@@ -30,18 +80,128 @@ describe('DeployKeysTable', () => { ...@@ -30,18 +80,128 @@ describe('DeployKeysTable', () => {
expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true); expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true);
}); });
it('renders table', () => { it('renders `New deploy key` button', () => {
createComponent(); createComponent();
expect(wrapper.findComponent(GlTable).exists()).toBe(true); const newDeployKeyButton = wrapper.findByTestId('new-deploy-key-button');
expect(newDeployKeyButton.exists()).toBe(true);
expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath);
}); });
it('renders `New deploy key` button', () => { describe('when `/deploy_keys` API request is pending', () => {
beforeEach(() => {
Api.deployKeys.mockImplementation(() => new Promise(() => {}));
});
it('shows loading icon', async () => {
createComponent(); createComponent();
const newDeployKeyButton = wrapper.findComponent(GlButton); await nextTick();
expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText); expect(findLoadingIcon().exists()).toBe(true);
expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath); });
});
describe('when `/deploy_keys` API request is successful', () => {
describe('when there are deploy keys', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValue({
data: responseBody,
headers: { 'x-total': `${responseBody.length}` },
});
createComponent();
});
it('renders deploy keys in table', () => {
expectDeployKeyIsRendered(deployKey, 0);
expectDeployKeyIsRendered(deployKey2, 1);
});
});
describe('pagination', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValueOnce({
data: [deployKey],
headers: { 'x-total': '2' },
});
createComponent();
});
it('renders pagination', () => {
const pagination = findPagination();
expect(pagination.exists()).toBe(true);
expect(pagination.props()).toMatchObject({
value: 1,
perPage: DEFAULT_PER_PAGE,
totalItems: responseBody.length,
nextText: DeployKeysTable.i18n.pagination.next,
prevText: DeployKeysTable.i18n.pagination.prev,
align: 'center',
});
});
describe('when pagination is changed', () => {
it('calls API with `page` parameter', async () => {
const pagination = findPagination();
expectDeployKeyIsRendered(deployKey, 0);
Api.deployKeys.mockResolvedValue({
data: [deployKey2],
headers: { 'x-total': '2' },
});
pagination.vm.$emit('input', 2);
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
expect(pagination.exists()).toBe(false);
await waitForPromises();
expect(Api.deployKeys).toHaveBeenCalledWith({
page: 2,
public: true,
});
expectDeployKeyIsRendered(deployKey2, 0);
});
});
});
describe('when there are no deploy keys', () => {
beforeEach(() => {
Api.deployKeys.mockResolvedValue({
data: [],
headers: { 'x-total': '0' },
});
createComponent();
});
itRendersTheEmptyState();
});
});
describe('when `deploy_keys` API request is unsuccessful', () => {
const error = new Error('Network Error');
beforeEach(() => {
Api.deployKeys.mockRejectedValue(error);
createComponent();
});
itRendersTheEmptyState();
it('displays flash', () => {
expect(createFlash).toHaveBeenCalledWith({
message: DeployKeysTable.i18n.apiErrorMessage,
captureError: true,
error,
});
});
}); });
}); });
import MockAdapter from 'axios-mock-adapter'; import MockAdapter from 'axios-mock-adapter';
import Api from '~/api'; import Api, { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status'; import httpStatus from '~/lib/utils/http_status';
...@@ -1574,6 +1574,51 @@ describe('Api', () => { ...@@ -1574,6 +1574,51 @@ describe('Api', () => {
}); });
}); });
describe('deployKeys', () => {
it('fetches deploy keys', async () => {
const deployKeys = [
{
id: 7,
title: 'My title 1',
created_at: '2021-10-29T16:59:55.229Z',
expires_at: null,
key:
'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDLvQzRX960N7dxPdge9o5a96+M4GEGQ7rxT2D3wAQDtQFjQV5ZcKb5wfeLtYLe3kRVI4lCO10PXeQppb1XBaYmVO31IaRkcgmMEPVyfp76Dp4CJZz6aMEbbcqfaHkDre0Fa8kzTXnBJVh2NeDbBfGMjFM5NRQLhKykodNsepO6dQ== dummy@gitlab.com',
fingerprint: '81:93:63:b9:1e:24:a2:aa:e0:87:d3:3f:42:81:f2:c2',
projects_with_write_access: [
{
id: 11,
description: null,
name: 'project1',
name_with_namespace: 'John Doe3 / project1',
path: 'project1',
path_with_namespace: 'namespace1/project1',
created_at: '2021-10-29T16:59:54.668Z',
},
{
id: 12,
description: null,
name: 'project2',
name_with_namespace: 'John Doe4 / project2',
path: 'project2',
path_with_namespace: 'namespace2/project2',
created_at: '2021-10-29T16:59:55.116Z',
},
],
},
];
const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/deploy_keys`;
mock.onGet(expectedUrl).reply(httpStatus.OK, deployKeys);
const params = { page: 2, public: true };
const { data } = await Api.deployKeys(params);
expect(data).toEqual(deployKeys);
expect(mock.history.get[0].params).toEqual({ ...params, per_page: DEFAULT_PER_PAGE });
});
});
describe('Feature Flag User List', () => { describe('Feature Flag User List', () => {
let expectedUrl; let expectedUrl;
let projectId; let projectId;
......
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe API::DeployKeys, '(JavaScript fixtures)', type: :request do
include ApiHelpers
include JavaScriptFixturesHelpers
let_it_be(:admin) { create(:admin) }
let_it_be(:project) { create(:project) }
let_it_be(:project2) { create(:project) }
let_it_be(:deploy_key) { create(:deploy_key, public: true) }
let_it_be(:deploy_key2) { create(:deploy_key, public: true) }
let_it_be(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) }
let_it_be(:deploy_keys_project2) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key) }
let_it_be(:deploy_keys_project3) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key2) }
let_it_be(:deploy_keys_project4) { create(:deploy_keys_project, :write_access, project: project2, deploy_key: deploy_key2) }
it 'api/deploy_keys/index.json' do
get api("/deploy_keys", admin)
expect(response).to be_successful
end
end
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