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>
import { GlTable, GlButton } from '@gitlab/ui';
import { GlTable, GlButton, GlPagination, GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
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 {
name: 'DeployKeysTable',
i18n: {
pageTitle: __('Public deploy keys'),
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: [
{
......@@ -29,13 +44,83 @@ export default {
{
key: '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: {
GlTable,
GlButton,
GlPagination,
TimeAgoTooltip,
GlLoadingIcon,
GlEmptyState,
},
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>
......@@ -45,10 +130,71 @@ export default {
<h4 class="gl-m-0">
{{ $options.i18n.pageTitle }}
</h4>
<gl-button variant="confirm" :href="createPath">{{
<gl-button variant="confirm" :href="createPath" data-testid="new-deploy-key-button">{{
$options.i18n.newDeployKeyButtonText
}}</gl-button>
</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>
</template>
......@@ -91,6 +91,7 @@ const Api = {
projectNotificationSettingsPath: '/api/:version/projects/:id/notification_settings',
groupNotificationSettingsPath: '/api/:version/groups/:id/notification_settings',
notificationSettingsPath: '/api/:version/notification_settings',
deployKeysPath: '/api/:version/deploy_keys',
group(groupId, callback = () => {}) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
......@@ -950,6 +951,12 @@ const Api = {
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 = {}) {
let url = Api.buildUrl(this.notificationSettingsPath);
......
......@@ -3576,6 +3576,9 @@ msgstr ""
msgid "An error occurred fetching the project authors."
msgstr ""
msgid "An error occurred fetching the public deploy keys. Please try again."
msgstr ""
msgid "An error occurred previewing the blob"
msgstr ""
......@@ -23536,6 +23539,9 @@ msgstr ""
msgid "No projects found"
msgstr ""
msgid "No public deploy keys"
msgstr ""
msgid "No public groups"
msgstr ""
......
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 waitForPromises from 'helpers/wait_for_promises';
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', () => {
let wrapper;
......@@ -14,12 +23,53 @@ describe('DeployKeysTable', () => {
emptyStateSvgPath: '/assets/illustrations/empty-state/empty-deploy-keys.svg',
};
const deployKey = responseBody[0];
const deployKey2 = responseBody[1];
const createComponent = (provide = {}) => {
wrapper = mountExtended(DeployKeysTable, {
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(() => {
wrapper.destroy();
});
......@@ -30,18 +80,128 @@ describe('DeployKeysTable', () => {
expect(wrapper.findByText(DeployKeysTable.i18n.pageTitle).exists()).toBe(true);
});
it('renders table', () => {
it('renders `New deploy key` button', () => {
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', () => {
createComponent();
describe('when `/deploy_keys` API request is pending', () => {
beforeEach(() => {
Api.deployKeys.mockImplementation(() => new Promise(() => {}));
});
const newDeployKeyButton = wrapper.findComponent(GlButton);
it('shows loading icon', async () => {
createComponent();
expect(newDeployKeyButton.text()).toBe(DeployKeysTable.i18n.newDeployKeyButtonText);
expect(newDeployKeyButton.attributes('href')).toBe(defaultProvide.createPath);
await nextTick();
expect(findLoadingIcon().exists()).toBe(true);
});
});
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 Api from '~/api';
import Api, { DEFAULT_PER_PAGE } from '~/api';
import axios from '~/lib/utils/axios_utils';
import httpStatus from '~/lib/utils/http_status';
......@@ -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', () => {
let expectedUrl;
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