Commit 157d5832 authored by Peter Hegman's avatar Peter Hegman Committed by Natalia Tepluhina

Add project selector to personal access token form

Allows users to scope a PAT to a project
parent 84bc3082
<script> <script>
import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui'; import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui';
import ProjectsTokenSelector from './projects_token_selector.vue';
export default { export default {
name: 'ProjectsField', name: 'ProjectsField',
ALL_PROJECTS: 'ALL_PROJECTS', ALL_PROJECTS: 'ALL_PROJECTS',
SELECTED_PROJECTS: 'SELECTED_PROJECTS', SELECTED_PROJECTS: 'SELECTED_PROJECTS',
components: { GlFormGroup, GlFormRadio, GlFormText }, components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector },
props: { props: {
inputAttrs: { inputAttrs: {
type: Object, type: Object,
...@@ -15,8 +16,24 @@ export default { ...@@ -15,8 +16,24 @@ export default {
data() { data() {
return { return {
selectedRadio: this.$options.ALL_PROJECTS, selectedRadio: this.$options.ALL_PROJECTS,
selectedProjects: [],
}; };
}, },
computed: {
allProjectsRadioSelected() {
return this.selectedRadio === this.$options.ALL_PROJECTS;
},
hiddenInputValue() {
return this.allProjectsRadioSelected
? null
: this.selectedProjects.map((project) => project.id).join(',');
},
},
methods: {
handleTokenSelectorFocus() {
this.selectedRadio = this.$options.SELECTED_PROJECTS;
},
},
}; };
</script> </script>
...@@ -32,7 +49,8 @@ export default { ...@@ -32,7 +49,8 @@ export default {
<gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{ <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{
__('Selected projects') __('Selected projects')
}}</gl-form-radio> }}</gl-form-radio>
<input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" /> <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" />
<projects-token-selector v-model="selectedProjects" @focus="handleTokenSelectorFocus" />
</gl-form-group> </gl-form-group>
</div> </div>
</template> </template>
<script>
import {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
} from '@gitlab/ui';
import produce from 'immer';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import getProjectsQuery from '../graphql/queries/get_projects.query.graphql';
const DEBOUNCE_DELAY = 250;
const PROJECTS_PER_PAGE = 20;
export default {
name: 'ProjectsTokenSelector',
components: {
GlTokenSelector,
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlLoadingIcon,
},
model: {
prop: 'selectedProjects',
},
props: {
selectedProjects: {
type: Array,
required: true,
},
},
apollo: {
projects: {
query: getProjectsQuery,
debounce: DEBOUNCE_DELAY,
variables() {
return {
search: this.searchQuery,
after: null,
first: PROJECTS_PER_PAGE,
};
},
update({ projects }) {
return {
list: projects.nodes.map((project) => ({
...project,
id: getIdFromGraphQLId(project.id),
})),
pageInfo: projects.pageInfo,
};
},
result() {
this.isLoadingMoreProjects = false;
this.isSearching = false;
},
},
},
data() {
return {
projects: {
list: [],
pageInfo: {},
},
searchQuery: '',
isLoadingMoreProjects: false,
isSearching: false,
};
},
methods: {
handleSearch(query) {
this.isSearching = true;
this.searchQuery = query;
},
loadMoreProjects() {
this.isLoadingMoreProjects = true;
this.$apollo.queries.projects.fetchMore({
variables: {
after: this.projects.pageInfo.endCursor,
first: PROJECTS_PER_PAGE,
},
updateQuery(previousResult, { fetchMoreResult: { projects: newProjects } }) {
const { projects: previousProjects } = previousResult;
return produce(previousResult, (draftData) => {
/* eslint-disable no-param-reassign */
draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes];
draftData.projects.pageInfo = newProjects.pageInfo;
/* eslint-enable no-param-reassign */
});
},
});
},
},
};
</script>
<template>
<div class="gl-relative">
<gl-token-selector
:selected-tokens="selectedProjects"
:dropdown-items="projects.list"
:loading="isSearching"
:placeholder="__('Select projects')"
menu-class="gl-w-full! gl-max-w-full!"
@input="$emit('input', $event)"
@focus="$emit('focus', $event)"
@text-input="handleSearch"
@keydown.enter.prevent
>
<template #token-content="{ token: project }">
<gl-avatar
:entity-id="project.id"
:entity-name="project.name"
:src="project.avatarUrl"
:size="16"
/>
{{ project.nameWithNamespace }}
</template>
<template #dropdown-item-content="{ dropdownItem: project }">
<gl-avatar-labeled
:entity-id="project.id"
:entity-name="project.name"
:size="32"
:src="project.avatarUrl"
:label="project.name"
:sub-label="project.nameWithNamespace"
/>
</template>
<template #dropdown-footer>
<gl-intersection-observer v-if="projects.pageInfo.hasNextPage" @appear="loadMoreProjects">
<gl-loading-icon v-if="isLoadingMoreProjects" size="md" />
</gl-intersection-observer>
</template>
</gl-token-selector>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getProjects($search: String!, $after: String = "", $first: Int!) {
projects(
search: $search
after: $after
first: $first
membership: true
searchNamespaces: true
sort: "UPDATED_ASC"
) {
nodes {
id
name
nameWithNamespace
avatarUrl
}
pageInfo {
...PageInfo
}
}
}
import Vue from 'vue'; import Vue from 'vue';
import createFlash from '~/flash';
import { __ } from '~/locale';
import ExpiresAtField from './components/expires_at_field.vue'; import ExpiresAtField from './components/expires_at_field.vue';
...@@ -43,10 +45,28 @@ export const initProjectsField = () => { ...@@ -43,10 +45,28 @@ export const initProjectsField = () => {
const inputAttrs = getInputAttrs(el); const inputAttrs = getInputAttrs(el);
if (window.gon.features.personalAccessTokensScopedToProjects) { if (window.gon.features.personalAccessTokensScopedToProjects) {
const ProjectsField = () => import('./components/projects_field.vue'); return new Promise((resolve) => {
Promise.all([
import('./components/projects_field.vue'),
import('vue-apollo'),
import('~/lib/graphql'),
])
.then(
([
{ default: ProjectsField },
{ default: VueApollo },
{ default: createDefaultClient },
]) => {
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({ Vue.use(VueApollo);
resolve(
new Vue({
el, el,
apolloProvider,
render(h) { render(h) {
return h(ProjectsField, { return h(ProjectsField, {
props: { props: {
...@@ -54,6 +74,17 @@ export const initProjectsField = () => { ...@@ -54,6 +74,17 @@ export const initProjectsField = () => {
}, },
}); });
}, },
}),
);
},
)
.catch(() => {
createFlash({
message: __(
'An error occurred while loading the access tokens form, please try again.',
),
});
});
}); });
} }
......
...@@ -3467,6 +3467,9 @@ msgstr "" ...@@ -3467,6 +3467,9 @@ msgstr ""
msgid "An error occurred while loading project creation UI" msgid "An error occurred while loading project creation UI"
msgstr "" msgstr ""
msgid "An error occurred while loading the access tokens form, please try again."
msgstr ""
msgid "An error occurred while loading the data. Please try again." msgid "An error occurred while loading the data. Please try again."
msgstr "" msgstr ""
......
import { within } from '@testing-library/dom'; import { within, fireEvent } from '@testing-library/dom';
import { mount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import ProjectsField from '~/access_tokens/components/projects_field.vue'; import ProjectsField from '~/access_tokens/components/projects_field.vue';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
describe('ProjectsField', () => { describe('ProjectsField', () => {
let wrapper; let wrapper;
...@@ -18,6 +19,10 @@ describe('ProjectsField', () => { ...@@ -18,6 +19,10 @@ describe('ProjectsField', () => {
const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text); const queryByLabelText = (text) => within(wrapper.element).queryByLabelText(text);
const queryByText = (text) => within(wrapper.element).queryByText(text); const queryByText = (text) => within(wrapper.element).queryByText(text);
const findAllProjectsRadio = () => queryByLabelText('All projects');
const findSelectedProjectsRadio = () => queryByLabelText('Selected projects');
const findProjectsTokenSelector = () => wrapper.findComponent(ProjectsTokenSelector);
const findHiddenInput = () => wrapper.find('input[type="hidden"]');
beforeEach(() => { beforeEach(() => {
createComponent(); createComponent();
...@@ -34,25 +39,66 @@ describe('ProjectsField', () => { ...@@ -34,25 +39,66 @@ describe('ProjectsField', () => {
}); });
it('renders "All projects" radio selected by default', () => { it('renders "All projects" radio selected by default', () => {
const allProjectsRadio = queryByLabelText('All projects'); const allProjectsRadio = findAllProjectsRadio();
expect(allProjectsRadio).not.toBe(null); expect(allProjectsRadio).not.toBe(null);
expect(allProjectsRadio.checked).toBe(true); expect(allProjectsRadio.checked).toBe(true);
}); });
it('renders "Selected projects" radio unchecked by default', () => { it('renders "Selected projects" radio unchecked by default', () => {
const selectedProjectsRadio = queryByLabelText('Selected projects'); const selectedProjectsRadio = findSelectedProjectsRadio();
expect(selectedProjectsRadio).not.toBe(null); expect(selectedProjectsRadio).not.toBe(null);
expect(selectedProjectsRadio.checked).toBe(false); expect(selectedProjectsRadio.checked).toBe(false);
}); });
it('renders `projects-token-selector` component', () => {
expect(findProjectsTokenSelector().exists()).toBe(true);
});
it('renders hidden input with correct `name` and `id` attributes', () => { it('renders hidden input with correct `name` and `id` attributes', () => {
expect(wrapper.find('input[type="hidden"]').attributes()).toEqual( expect(findHiddenInput().attributes()).toEqual(
expect.objectContaining({ expect.objectContaining({
id: 'projects', id: 'projects',
name: 'projects', name: 'projects',
}), }),
); );
}); });
describe('when `projects-token-selector` is focused', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('focus');
});
it('auto selects the "Selected projects" radio', () => {
expect(findSelectedProjectsRadio().checked).toBe(true);
});
describe('when `projects-token-selector` is changed', () => {
beforeEach(() => {
findProjectsTokenSelector().vm.$emit('input', [
{
id: 1,
},
{
id: 2,
},
]);
});
it('updates the hidden input value to a comma separated list of project IDs', () => {
expect(findHiddenInput().attributes('value')).toBe('1,2');
});
describe('when radio is changed back to "All projects"', () => {
beforeEach(() => {
fireEvent.click(findAllProjectsRadio());
});
it('removes the hidden input value', () => {
expect(findHiddenInput().attributes('value')).toBe('');
});
});
});
});
}); });
import {
GlAvatar,
GlAvatarLabeled,
GlIntersectionObserver,
GlToken,
GlTokenSelector,
GlLoadingIcon,
} from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import produce from 'immer';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import { getJSONFixture } from 'helpers/fixtures';
import createMockApollo from 'helpers/mock_apollo_helper';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import ProjectsTokenSelector from '~/access_tokens/components/projects_token_selector.vue';
import getProjectsQuery from '~/access_tokens/graphql/queries/get_projects.query.graphql';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
describe('ProjectsTokenSelector', () => {
const getProjectsQueryResponse = getJSONFixture(
'graphql/projects/access_tokens/get_projects.query.graphql.json',
);
const getProjectsQueryResponsePage2 = produce(
getProjectsQueryResponse,
(getProjectsQueryResponseDraft) => {
/* eslint-disable no-param-reassign */
getProjectsQueryResponseDraft.data.projects.pageInfo.hasNextPage = false;
getProjectsQueryResponseDraft.data.projects.pageInfo.endCursor = null;
getProjectsQueryResponseDraft.data.projects.nodes.splice(1, 1);
getProjectsQueryResponseDraft.data.projects.nodes[0].id = 'gid://gitlab/Project/100';
/* eslint-enable no-param-reassign */
},
);
const runDebounce = () => jest.runAllTimers();
const { pageInfo, nodes: projects } = getProjectsQueryResponse.data.projects;
const project1 = projects[0];
const project2 = projects[1];
let wrapper;
let resolveGetProjectsQuery;
const getProjectsQueryRequestHandler = jest.fn(
() =>
new Promise((resolve) => {
resolveGetProjectsQuery = resolve;
}),
);
const createComponent = ({
propsData = {},
apolloProvider = createMockApollo([[getProjectsQuery, getProjectsQueryRequestHandler]]),
resolveQueries = true,
} = {}) => {
Vue.use(VueApollo);
wrapper = extendedWrapper(
mount(ProjectsTokenSelector, {
apolloProvider,
propsData: {
selectedProjects: [],
...propsData,
},
stubs: ['gl-intersection-observer'],
}),
);
runDebounce();
if (resolveQueries) {
resolveGetProjectsQuery(getProjectsQueryResponse);
return waitForPromises();
}
return Promise.resolve();
};
const findTokenSelector = () => wrapper.findComponent(GlTokenSelector);
const findTokenSelectorInput = () => findTokenSelector().find('input[type="text"]');
const findIntersectionObserver = () => wrapper.findComponent(GlIntersectionObserver);
it('renders dropdown items with project avatars', async () => {
await createComponent();
wrapper.findAllComponents(GlAvatarLabeled).wrappers.forEach((avatarLabeledWrapper, index) => {
const project = projects[index];
expect(avatarLabeledWrapper.attributes()).toEqual(
expect.objectContaining({
'entity-id': `${getIdFromGraphQLId(project.id)}`,
'entity-name': project.name,
...(project.avatarUrl && { src: project.avatarUrl }),
}),
);
expect(avatarLabeledWrapper.props()).toEqual(
expect.objectContaining({
label: project.name,
subLabel: project.nameWithNamespace,
}),
);
});
});
it('renders tokens with project avatars', () => {
createComponent({
propsData: {
selectedProjects: [{ ...project2, id: getIdFromGraphQLId(project2.id) }],
},
});
const token = wrapper.findComponent(GlToken);
const avatar = token.findComponent(GlAvatar);
expect(token.text()).toContain(project2.nameWithNamespace);
expect(avatar.attributes('src')).toBe(project2.avatarUrl);
expect(avatar.props()).toEqual(
expect.objectContaining({
entityId: getIdFromGraphQLId(project2.id),
entityName: project2.name,
}),
);
});
describe('when `enter` key is pressed', () => {
it('calls `preventDefault` so form is not submitted when user selects a project from the dropdown', () => {
createComponent();
const event = {
preventDefault: jest.fn(),
};
findTokenSelectorInput().trigger('keydown.enter', event);
expect(event.preventDefault).toHaveBeenCalled();
});
});
describe('when text input is typed in', () => {
const searchTerm = 'foo bar';
beforeEach(async () => {
await createComponent();
await findTokenSelectorInput().setValue(searchTerm);
runDebounce();
});
it('makes GraphQL request with `search` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
search: searchTerm,
after: null,
first: 20,
});
});
it('sets loading state while waiting for GraphQL request to resolve', async () => {
expect(findTokenSelector().props('loading')).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponse);
await waitForPromises();
expect(findTokenSelector().props('loading')).toBe(false);
});
});
describe('when there is a next page of projects and user scrolls to the bottom of the dropdown', () => {
beforeEach(async () => {
await createComponent();
findIntersectionObserver().vm.$emit('appear');
});
it('makes GraphQL request with `after` variable set', async () => {
expect(getProjectsQueryRequestHandler).toHaveBeenLastCalledWith({
after: pageInfo.endCursor,
first: 20,
search: '',
});
});
it('displays loading icon while waiting for GraphQL request to resolve', async () => {
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true);
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(false);
});
});
describe('when there is not a next page of projects', () => {
it('does not render `GlIntersectionObserver`', async () => {
createComponent({ resolveQueries: false });
resolveGetProjectsQuery(getProjectsQueryResponsePage2);
await waitForPromises();
expect(findIntersectionObserver().exists()).toBe(false);
});
});
describe('when `GlTokenSelector` emits `input` event', () => {
it('emits `input` event used by `v-model`', () => {
findTokenSelector().vm.$emit('input', project1);
expect(wrapper.emitted('input')[0]).toEqual([project1]);
});
});
describe('when `GlTokenSelector` emits `focus` event', () => {
it('emits `focus` event', () => {
const event = { fakeEvent: 'foo' };
findTokenSelector().vm.$emit('focus', event);
expect(wrapper.emitted('focus')[0]).toEqual([event]);
});
});
});
import { createWrapper } from '@vue/test-utils'; import { createWrapper } from '@vue/test-utils';
import Vue from 'vue';
import waitForPromises from 'helpers/wait_for_promises';
import { initExpiresAtField, initProjectsField } from '~/access_tokens'; import { initExpiresAtField, initProjectsField } from '~/access_tokens';
import ExpiresAtField from '~/access_tokens/components/expires_at_field.vue'; import * as ExpiresAtField from '~/access_tokens/components/expires_at_field.vue';
import ProjectsField from '~/access_tokens/components/projects_field.vue'; import * as ProjectsField from '~/access_tokens/components/projects_field.vue';
describe('access tokens', () => { describe('access tokens', () => {
const FakeComponent = Vue.component('FakeComponent', {
props: {
inputAttrs: {
type: Object,
required: true,
},
},
render: () => null,
});
beforeEach(() => { beforeEach(() => {
window.gon = { features: { personalAccessTokensScopedToProjects: true } }; window.gon = { features: { personalAccessTokensScopedToProjects: true } };
}); });
afterEach(() => { afterEach(() => {
document.body.innerHTML = ''; document.body.innerHTML = '';
window.gon = {};
}); });
describe.each` describe.each`
...@@ -34,15 +42,17 @@ describe('access tokens', () => { ...@@ -34,15 +42,17 @@ describe('access tokens', () => {
mountEl.appendChild(input); mountEl.appendChild(input);
document.body.appendChild(mountEl); document.body.appendChild(mountEl);
});
it(`mounts component and sets \`inputAttrs\` prop`, async () => { // Mock component so we don't have to deal with mocking Apollo
const wrapper = createWrapper(initFunction()); // eslint-disable-next-line no-param-reassign
expectedComponent.default = FakeComponent;
});
// Wait for dynamic imports to resolve it('mounts component and sets `inputAttrs` prop', async () => {
await waitForPromises(); const vueInstance = await initFunction();
const component = wrapper.findComponent(expectedComponent); const wrapper = createWrapper(vueInstance);
const component = wrapper.findComponent(FakeComponent);
expect(component.exists()).toBe(true); expect(component.exists()).toBe(true);
expect(component.props('inputAttrs')).toEqual({ expect(component.props('inputAttrs')).toEqual({
......
...@@ -3,13 +3,14 @@ ...@@ -3,13 +3,14 @@
require 'spec_helper' require 'spec_helper'
RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
include ApiHelpers
include JavaScriptFixturesHelpers include JavaScriptFixturesHelpers
runners_token = 'runnerstoken:intabulasreferre' runners_token = 'runnerstoken:intabulasreferre'
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token) } let(:project) { create(:project, namespace: namespace, path: 'builds-project', runners_token: runners_token, avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff') } let(:project_with_repo) { create(:project, :repository, description: 'Code and stuff', avatar: fixture_file_upload('spec/fixtures/dk.png', 'image/png')) }
let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) } let(:project_variable_populated) { create(:project, namespace: namespace, path: 'builds-project2', runners_token: runners_token) }
let(:user) { project.owner } let(:user) { project.owner }
...@@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do ...@@ -22,7 +23,6 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
before do before do
project_with_repo.add_maintainer(user) project_with_repo.add_maintainer(user)
sign_in(user) sign_in(user)
allow(SecureRandom).to receive(:hex).and_return('securerandomhex:thereisnospoon')
end end
after do after do
...@@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do ...@@ -48,4 +48,31 @@ RSpec.describe 'Projects (JavaScript fixtures)', type: :controller do
expect(response).to be_successful expect(response).to be_successful
end end
end end
describe GraphQL::Query, type: :request do
include GraphqlHelpers
context 'access token projects query' do
before do
project_variable_populated.add_maintainer(user)
end
before(:all) do
clean_frontend_fixtures('graphql/projects/access_tokens')
end
fragment_paths = ['graphql_shared/fragments/pageInfo.fragment.graphql']
base_input_path = 'access_tokens/graphql/queries/'
base_output_path = 'graphql/projects/access_tokens/'
query_name = 'get_projects.query.graphql'
it "#{base_output_path}#{query_name}.json" do
query = get_graphql_query_as_string("#{base_input_path}#{query_name}", fragment_paths)
post_graphql(query, current_user: user, variables: { search: '', first: 2 })
expect_graphql_errors_to_be_empty
end
end
end
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