summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/access_tokens
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/access_tokens')
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_field.vue69
-rw-r--r--app/assets/javascripts/access_tokens/components/projects_token_selector.vue156
-rw-r--r--app/assets/javascripts/access_tokens/graphql/queries/get_projects.query.graphql28
-rw-r--r--app/assets/javascripts/access_tokens/index.js62
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;
+};