diff options
Diffstat (limited to 'app/assets/javascripts/access_tokens')
4 files changed, 313 insertions, 2 deletions
diff --git a/app/assets/javascripts/access_tokens/components/projects_field.vue b/app/assets/javascripts/access_tokens/components/projects_field.vue new file mode 100644 index 00000000000..066cea5e90c --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/projects_field.vue @@ -0,0 +1,69 @@ +<script> +import { GlFormGroup, GlFormRadio, GlFormText } from '@gitlab/ui'; +import ProjectsTokenSelector from './projects_token_selector.vue'; + +export default { + name: 'ProjectsField', + ALL_PROJECTS: 'ALL_PROJECTS', + SELECTED_PROJECTS: 'SELECTED_PROJECTS', + components: { GlFormGroup, GlFormRadio, GlFormText, ProjectsTokenSelector }, + props: { + inputAttrs: { + type: Object, + required: true, + }, + }, + data() { + return { + selectedRadio: !this.inputAttrs.value + ? this.$options.ALL_PROJECTS + : this.$options.SELECTED_PROJECTS, + selectedProjects: [], + }; + }, + computed: { + allProjectsRadioSelected() { + return this.selectedRadio === this.$options.ALL_PROJECTS; + }, + hiddenInputValue() { + return this.allProjectsRadioSelected + ? null + : this.selectedProjects.map((project) => project.id).join(','); + }, + initialProjectIds() { + if (!this.inputAttrs.value) { + return []; + } + + return this.inputAttrs.value.split(','); + }, + }, + methods: { + handleTokenSelectorFocus() { + this.selectedRadio = this.$options.SELECTED_PROJECTS; + }, + }, +}; +</script> + +<template> + <div> + <gl-form-group :label="__('Projects')" label-class="gl-pb-0!"> + <gl-form-text class="gl-pb-3">{{ + __('Set access permissions for this token.') + }}</gl-form-text> + <gl-form-radio v-model="selectedRadio" :value="$options.ALL_PROJECTS">{{ + __('All projects') + }}</gl-form-radio> + <gl-form-radio v-model="selectedRadio" :value="$options.SELECTED_PROJECTS">{{ + __('Selected projects') + }}</gl-form-radio> + <input :id="inputAttrs.id" type="hidden" :name="inputAttrs.name" :value="hiddenInputValue" /> + <projects-token-selector + v-model="selectedProjects" + :initial-project-ids="initialProjectIds" + @focus="handleTokenSelectorFocus" + /> + </gl-form-group> + </div> +</template> diff --git a/app/assets/javascripts/access_tokens/components/projects_token_selector.vue b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue new file mode 100644 index 00000000000..a746f62b3a1 --- /dev/null +++ b/app/assets/javascripts/access_tokens/components/projects_token_selector.vue @@ -0,0 +1,156 @@ +<script> +import { + GlTokenSelector, + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlLoadingIcon, +} from '@gitlab/ui'; +import produce from 'immer'; + +import { convertToGraphQLIds, convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; + +import getProjectsQuery from '../graphql/queries/get_projects.query.graphql'; + +const DEBOUNCE_DELAY = 250; +const PROJECTS_PER_PAGE = 20; +const GRAPHQL_ENTITY_TYPE = 'Project'; + +export default { + name: 'ProjectsTokenSelector', + components: { + GlTokenSelector, + GlAvatar, + GlAvatarLabeled, + GlIntersectionObserver, + GlLoadingIcon, + }, + model: { + prop: 'selectedProjects', + }, + props: { + selectedProjects: { + type: Array, + required: true, + }, + initialProjectIds: { + 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: convertNodeIdsFromGraphQLIds(projects.nodes), + pageInfo: projects.pageInfo, + }; + }, + result() { + this.isLoadingMoreProjects = false; + this.isSearching = false; + }, + }, + initialProjects: { + query: getProjectsQuery, + variables() { + return { + ids: convertToGraphQLIds(GRAPHQL_ENTITY_TYPE, this.initialProjectIds), + }; + }, + manual: true, + skip() { + return !this.initialProjectIds.length; + }, + result({ data: { projects } }) { + this.$emit('input', convertNodeIdsFromGraphQLIds(projects.nodes)); + }, + }, + }, + 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) => { + draftData.projects.nodes = [...previousProjects.nodes, ...newProjects.nodes]; + draftData.projects.pageInfo = newProjects.pageInfo; + }); + }, + }); + }, + }, +}; +</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> diff --git a/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql new file mode 100644 index 00000000000..60110437ecd --- /dev/null +++ b/app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql @@ -0,0 +1,28 @@ +#import "~/graphql_shared/fragments/pageInfo.fragment.graphql" + +query getProjects( + $search: String = "" + $after: String = "" + $first: Int = null + $ids: [ID!] = null +) { + projects( + search: $search + after: $after + first: $first + ids: $ids + membership: true + searchNamespaces: true + sort: "UPDATED_ASC" + ) { + nodes { + id + name + nameWithNamespace + avatarUrl + } + pageInfo { + ...PageInfo + } + } +} diff --git a/app/assets/javascripts/access_tokens/index.js b/app/assets/javascripts/access_tokens/index.js index b4353af30d5..43d56295f78 100644 --- a/app/assets/javascripts/access_tokens/index.js +++ b/app/assets/javascripts/access_tokens/index.js @@ -1,4 +1,7 @@ import Vue from 'vue'; +import createFlash from '~/flash'; +import { __ } from '~/locale'; + import ExpiresAtField from './components/expires_at_field.vue'; const getInputAttrs = (el) => { @@ -7,11 +10,12 @@ const getInputAttrs = (el) => { return { id: input.id, name: input.name, + value: input.value, placeholder: input.placeholder, }; }; -const initExpiresAtField = () => { +export const initExpiresAtField = () => { const el = document.querySelector('.js-access-tokens-expires-at'); if (!el) { @@ -32,4 +36,58 @@ const initExpiresAtField = () => { }); }; -export default initExpiresAtField; +export const initProjectsField = () => { + const el = document.querySelector('.js-access-tokens-projects'); + + if (!el) { + return null; + } + + const inputAttrs = getInputAttrs(el); + + if (window.gon.features.personalAccessTokensScopedToProjects) { + 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(), + }); + + Vue.use(VueApollo); + + resolve( + new Vue({ + el, + apolloProvider, + render(h) { + return h(ProjectsField, { + props: { + inputAttrs, + }, + }); + }, + }), + ); + }, + ) + .catch(() => { + createFlash({ + message: __( + 'An error occurred while loading the access tokens form, please try again.', + ), + }); + }); + }); + } + + return null; +}; |