diff options
Diffstat (limited to 'app/assets/javascripts/jira_connect')
8 files changed, 292 insertions, 170 deletions
diff --git a/app/assets/javascripts/jira_connect/api.js b/app/assets/javascripts/jira_connect/api.js index d78aba0a3f7..abf2c070e68 100644 --- a/app/assets/javascripts/jira_connect/api.js +++ b/app/assets/javascripts/jira_connect/api.js @@ -1,24 +1,5 @@ import axios from 'axios'; - -export const getJwt = () => { - return new Promise((resolve) => { - AP.context.getToken((token) => { - resolve(token); - }); - }); -}; - -export const getLocation = () => { - return new Promise((resolve) => { - if (typeof AP.getLocation !== 'function') { - resolve(); - } - - AP.getLocation((location) => { - resolve(location); - }); - }); -}; +import { getJwt } from '~/jira_connect/utils'; export const addSubscription = async (addPath, namespace) => { const jwt = await getJwt(); @@ -39,11 +20,12 @@ export const removeSubscription = async (removePath) => { }); }; -export const fetchGroups = async (groupsPath, { page, perPage }) => { +export const fetchGroups = async (groupsPath, { page, perPage, search }) => { return axios.get(groupsPath, { params: { page, per_page: perPage, + search, }, }); }; diff --git a/app/assets/javascripts/jira_connect/components/app.vue b/app/assets/javascripts/jira_connect/components/app.vue index fe5ad8b67d7..ff4dfb23687 100644 --- a/app/assets/javascripts/jira_connect/components/app.vue +++ b/app/assets/javascripts/jira_connect/components/app.vue @@ -1,27 +1,26 @@ <script> -import { GlAlert, GlButton, GlModal, GlModalDirective, GlLink, GlSprintf } from '@gitlab/ui'; +import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; import { mapState, mapMutations } from 'vuex'; -import { getLocation } from '~/jira_connect/api'; +import { retrieveAlert, getLocation } from '~/jira_connect/utils'; import { __ } from '~/locale'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { SET_ALERT } from '../store/mutation_types'; -import { retrieveAlert } from '../utils'; import GroupsList from './groups_list.vue'; +import SubscriptionsList from './subscriptions_list.vue'; export default { name: 'JiraConnectApp', components: { GlAlert, GlButton, - GlModal, - GroupsList, GlLink, + GlModal, GlSprintf, + GroupsList, + SubscriptionsList, }, directives: { GlModalDirective, }, - mixins: [glFeatureFlagsMixin()], inject: { usersPath: { default: '', @@ -91,37 +90,36 @@ export default { <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> - <div - class="jira-connect-app-body gl-display-flex gl-justify-content-space-between gl-my-7 gl-pb-4 gl-border-b-solid gl-border-b-1 gl-border-b-gray-200" - > - <h5 class="gl-align-self-center gl-mb-0" data-testid="new-jira-connect-ui-heading"> - {{ s__('Integrations|Linked namespaces') }} - </h5> - <gl-button - v-if="usersPath" - category="primary" - variant="info" - class="gl-align-self-center" - :href="usersPathWithReturnTo" - target="_blank" - >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button - > - <template v-else> + <div class="jira-connect-app-body gl-my-7 gl-px-5 gl-pb-4"> + <div class="gl-display-flex gl-justify-content-end"> <gl-button - v-gl-modal-directive="'add-namespace-modal'" + v-if="usersPath" category="primary" variant="info" class="gl-align-self-center" - >{{ s__('Integrations|Add namespace') }}</gl-button - > - <gl-modal - modal-id="add-namespace-modal" - :title="s__('Integrations|Link namespaces')" - :action-cancel="$options.modal.cancelProps" + :href="usersPathWithReturnTo" + target="_blank" + >{{ s__('Integrations|Sign in to add namespaces') }}</gl-button > - <groups-list /> - </gl-modal> - </template> + <template v-else> + <gl-button + v-gl-modal-directive="'add-namespace-modal'" + category="primary" + variant="info" + class="gl-align-self-center" + >{{ s__('Integrations|Add namespace') }}</gl-button + > + <gl-modal + modal-id="add-namespace-modal" + :title="s__('Integrations|Link namespaces')" + :action-cancel="$options.modal.cancelProps" + > + <groups-list /> + </gl-modal> + </template> + </div> + + <subscriptions-list /> </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/group_item_name.vue b/app/assets/javascripts/jira_connect/components/group_item_name.vue new file mode 100644 index 00000000000..e6c172dae9e --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/group_item_name.vue @@ -0,0 +1,34 @@ +<script> +import { GlAvatar, GlIcon } from '@gitlab/ui'; + +export default { + components: { + GlAvatar, + GlIcon, + }, + props: { + group: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center"> + <gl-icon name="folder-o" class="gl-mr-3" /> + <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> + <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> + </div> + + <div> + <span class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold"> + {{ group.full_name }} + </span> + <div v-if="group.description"> + <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> + </div> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list.vue b/app/assets/javascripts/jira_connect/components/groups_list.vue index 69f2903388c..275ff820419 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list.vue @@ -1,5 +1,5 @@ <script> -import { GlTabs, GlTab, GlLoadingIcon, GlPagination, GlAlert } from '@gitlab/ui'; +import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; import { fetchGroups } from '~/jira_connect/api'; import { defaultPerPage } from '~/jira_connect/constants'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; @@ -8,11 +8,10 @@ import GroupsListItem from './groups_list_item.vue'; export default { components: { - GlTabs, - GlTab, GlLoadingIcon, GlPagination, GlAlert, + GlSearchBoxByType, GroupsListItem, }, inject: { @@ -23,7 +22,8 @@ export default { data() { return { groups: [], - isLoading: false, + isLoadingInitial: true, + isLoadingMore: false, page: 1, perPage: defaultPerPage, totalItems: 0, @@ -31,15 +31,18 @@ export default { }; }, mounted() { - this.loadGroups(); + return this.loadGroups().finally(() => { + this.isLoadingInitial = false; + }); }, methods: { - loadGroups() { - this.isLoading = true; + loadGroups({ searchTerm } = {}) { + this.isLoadingMore = true; - fetchGroups(this.groupsPath, { + return fetchGroups(this.groupsPath, { page: this.page, perPage: this.perPage, + search: searchTerm, }) .then((response) => { const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); @@ -51,50 +54,61 @@ export default { this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); }) .finally(() => { - this.isLoading = false; + this.isLoadingMore = false; }); }, + onGroupSearch(searchTerm) { + return this.loadGroups({ searchTerm }); + }, }, }; </script> <template> <div> - <gl-alert v-if="errorMessage" class="gl-mb-6" variant="danger" @dismiss="errorMessage = null"> + <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null"> {{ errorMessage }} </gl-alert> - <gl-tabs> - <gl-tab :title="__('Groups and subgroups')" class="gl-pt-3"> - <gl-loading-icon v-if="isLoading" size="md" /> - <div v-else-if="groups.length === 0" class="gl-text-center"> - <h5>{{ s__('Integrations|No available namespaces.') }}</h5> - <p class="gl-mt-5"> - {{ - s__('Integrations|You must have owner or maintainer permissions to link namespaces.') - }} - </p> - </div> - <ul v-else class="gl-list-style-none gl-pl-0"> - <groups-list-item - v-for="group in groups" - :key="group.id" - :group="group" - @error="errorMessage = $event" - /> - </ul> + <gl-search-box-by-type + class="gl-mb-5" + debounce="500" + :placeholder="__('Search by name')" + :is-loading="isLoadingMore" + @input="onGroupSearch" + /> + + <gl-loading-icon v-if="isLoadingInitial" size="md" /> + <div v-else-if="groups.length === 0" class="gl-text-center"> + <h5>{{ s__('Integrations|No available namespaces.') }}</h5> + <p class="gl-mt-5"> + {{ s__('Integrations|You must have owner or maintainer permissions to link namespaces.') }} + </p> + </div> + <ul + v-else + class="gl-list-style-none gl-pl-0 gl-border-t-1 gl-border-t-solid gl-border-t-gray-100" + :class="{ 'gl-opacity-5': isLoadingMore }" + data-testid="groups-list" + > + <groups-list-item + v-for="group in groups" + :key="group.id" + :group="group" + :disabled="isLoadingMore" + @error="errorMessage = $event" + /> + </ul> - <div class="gl-display-flex gl-justify-content-center gl-mt-5"> - <gl-pagination - v-if="totalItems > perPage && groups.length > 0" - v-model="page" - class="gl-mb-0" - :per-page="perPage" - :total-items="totalItems" - @input="loadGroups" - /> - </div> - </gl-tab> - </gl-tabs> + <div class="gl-display-flex gl-justify-content-center gl-mt-5"> + <gl-pagination + v-if="totalItems > perPage && groups.length > 0" + v-model="page" + class="gl-mb-0" + :per-page="perPage" + :total-items="totalItems" + @input="loadGroups" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/jira_connect/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/components/groups_list_item.vue index b8959a2a505..ad046920dd1 100644 --- a/app/assets/javascripts/jira_connect/components/groups_list_item.vue +++ b/app/assets/javascripts/jira_connect/components/groups_list_item.vue @@ -1,15 +1,15 @@ <script> -import { GlAvatar, GlButton, GlIcon } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; import { addSubscription } from '~/jira_connect/api'; +import { persistAlert, reloadPage } from '~/jira_connect/utils'; import { s__ } from '~/locale'; -import { persistAlert } from '../utils'; +import GroupItemName from './group_item_name.vue'; export default { components: { - GlAvatar, GlButton, - GlIcon, + GroupItemName, }, inject: { subscriptionsPath: { @@ -21,6 +21,11 @@ export default { type: Object, required: true, }, + disabled: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -42,7 +47,7 @@ export default { variant: 'success', }); - AP.navigator.reload(); + reloadPage(); }) .catch((error) => { this.$emit( @@ -50,8 +55,6 @@ export default { error?.response?.data?.error || s__('Integrations|Failed to link namespace. Please try again.'), ); - }) - .finally(() => { this.isLoading = false; }); }, @@ -60,34 +63,22 @@ export default { </script> <template> - <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-200"> + <li class="gl-border-b-1 gl-border-b-solid gl-border-b-gray-100"> <div class="gl-display-flex gl-align-items-center gl-py-3"> - <gl-icon name="folder-o" class="gl-mr-3" /> - <div class="gl-display-none gl-flex-shrink-0 gl-sm-display-flex gl-mr-3"> - <gl-avatar :size="32" shape="rect" :entity-name="group.name" :src="group.avatar_url" /> - </div> <div class="gl-min-w-0 gl-display-flex gl-flex-grow-1 gl-flex-shrink-1 gl-align-items-center"> <div class="gl-min-w-0 gl-flex-grow-1 flex-shrink-1"> - <div class="gl-display-flex gl-align-items-center gl-flex-wrap"> - <span - class="gl-mr-3 gl-text-gray-900! gl-font-weight-bold" - data-testid="group-list-item-name" - > - {{ group.full_name }} - </span> - </div> - <div v-if="group.description" data-testid="group-list-item-description"> - <p class="gl-mt-2! gl-mb-0 gl-text-gray-600" v-text="group.description"></p> - </div> + <group-item-name :group="group" /> </div> <gl-button category="secondary" - variant="success" + variant="confirm" :loading="isLoading" + :disabled="disabled" @click.prevent="onClick" - >{{ __('Link') }}</gl-button > + {{ __('Link') }} + </gl-button> </div> </div> </li> diff --git a/app/assets/javascripts/jira_connect/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue new file mode 100644 index 00000000000..a606e2edbbb --- /dev/null +++ b/app/assets/javascripts/jira_connect/components/subscriptions_list.vue @@ -0,0 +1,109 @@ +<script> +import { GlButton, GlEmptyState, GlTable } from '@gitlab/ui'; +import { isEmpty } from 'lodash'; +import { mapMutations } from 'vuex'; +import { removeSubscription } from '~/jira_connect/api'; +import { reloadPage } from '~/jira_connect/utils'; +import { __, s__ } from '~/locale'; +import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import { SET_ALERT } from '../store/mutation_types'; +import GroupItemName from './group_item_name.vue'; + +export default { + components: { + GlButton, + GlEmptyState, + GlTable, + GroupItemName, + TimeagoTooltip, + }, + inject: { + subscriptions: { + default: [], + }, + }, + data() { + return { + loadingItem: null, + }; + }, + fields: [ + { + key: 'name', + label: s__('Integrations|Linked namespaces'), + }, + { + key: 'created_at', + label: __('Added'), + tdClass: 'gl-vertical-align-middle! gl-w-20p', + }, + { + key: 'actions', + label: '', + tdClass: 'gl-text-right gl-vertical-align-middle! gl-pl-0!', + }, + ], + i18n: { + emptyTitle: s__('Integrations|No linked namespaces'), + emptyDescription: s__( + 'Integrations|Namespaces are the GitLab groups and subgroups you link to this Jira instance.', + ), + unlinkError: s__('Integrations|Failed to unlink namespace. Please try again.'), + }, + methods: { + ...mapMutations({ + setAlert: SET_ALERT, + }), + isEmpty, + isLoadingItem(item) { + return this.loadingItem === item; + }, + unlinkBtnClass(item) { + return this.isLoadingItem(item) ? '' : 'gl-ml-6'; + }, + onClick(item) { + this.loadingItem = item; + + removeSubscription(item.unlink_path) + .then(() => { + reloadPage(); + }) + .catch((error) => { + this.setAlert({ + message: error?.response?.data?.error || this.$options.i18n.unlinkError, + variant: 'danger', + }); + this.loadingItem = null; + }); + }, + }, +}; +</script> + +<template> + <div> + <gl-empty-state + v-if="isEmpty(subscriptions)" + :title="$options.i18n.emptyTitle" + :description="$options.i18n.emptyDescription" + /> + <gl-table v-else :items="subscriptions" :fields="$options.fields"> + <template #cell(name)="{ item }"> + <group-item-name :group="item.group" /> + </template> + <template #cell(created_at)="{ item }"> + <timeago-tooltip :time="item.created_at" /> + </template> + <template #cell(actions)="{ item }"> + <gl-button + :class="unlinkBtnClass(item)" + category="secondary" + :loading="isLoadingItem(item)" + :disabled="!isEmpty(loadingItem)" + @click.prevent="onClick(item)" + >{{ __('Unlink') }}</gl-button + > + </template> + </gl-table> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/index.js b/app/assets/javascripts/jira_connect/index.js index ecdb41607a4..dc8bb3b0c77 100644 --- a/app/assets/javascripts/jira_connect/index.js +++ b/app/assets/javascripts/jira_connect/index.js @@ -1,25 +1,14 @@ import setConfigs from '@gitlab/ui/dist/config'; import Vue from 'vue'; -import { addSubscription, removeSubscription, getLocation } from '~/jira_connect/api'; +import { getLocation, sizeToParent } from '~/jira_connect/utils'; import GlFeatureFlagsPlugin from '~/vue_shared/gl_feature_flags_plugin'; import Translate from '~/vue_shared/translate'; import JiraConnectApp from './components/app.vue'; import createStore from './store'; -import { SET_ALERT } from './store/mutation_types'; const store = createStore(); -const reqComplete = () => { - AP.navigator.reload(); -}; - -const reqFailed = (res, fallbackErrorMessage) => { - const { error = fallbackErrorMessage } = res || {}; - - store.commit(SET_ALERT, { message: error, variant: 'danger' }); -}; - const updateSignInLinks = async () => { const location = await getLocation(); Array.from(document.querySelectorAll('.js-jira-connect-sign-in')).forEach((el) => { @@ -28,43 +17,7 @@ const updateSignInLinks = async () => { }); }; -const initRemoveSubscriptionButtonHandlers = () => { - Array.from(document.querySelectorAll('.js-jira-connect-remove-subscription')).forEach((el) => { - el.addEventListener('click', function onRemoveSubscriptionClick(e) { - e.preventDefault(); - - const removePath = e.target.getAttribute('href'); - removeSubscription(removePath) - .then(reqComplete) - .catch((err) => - reqFailed(err.response.data, 'Failed to remove namespace. Please try again.'), - ); - }); - }); -}; - -const initAddSubscriptionFormHandler = () => { - const formEl = document.querySelector('#add-subscription-form'); - if (!formEl) { - return; - } - - formEl.addEventListener('submit', function onAddSubscriptionForm(e) { - e.preventDefault(); - - const addPath = e.target.getAttribute('action'); - const namespace = (e.target.querySelector('#namespace-input') || {}).value; - - addSubscription(addPath, namespace) - .then(reqComplete) - .catch((err) => reqFailed(err.response.data, 'Failed to add namespace. Please try again.')); - }); -}; - export async function initJiraConnect() { - initAddSubscriptionFormHandler(); - initRemoveSubscriptionButtonHandlers(); - await updateSignInLinks(); const el = document.querySelector('.js-jira-connect-app'); @@ -76,14 +29,15 @@ export async function initJiraConnect() { Vue.use(Translate); Vue.use(GlFeatureFlagsPlugin); - const { groupsPath, subscriptionsPath, usersPath } = el.dataset; - AP.sizeToParent(); + const { groupsPath, subscriptions, subscriptionsPath, usersPath } = el.dataset; + sizeToParent(); return new Vue({ el, store, provide: { groupsPath, + subscriptions: JSON.parse(subscriptions), subscriptionsPath, usersPath, }, diff --git a/app/assets/javascripts/jira_connect/utils.js b/app/assets/javascripts/jira_connect/utils.js index 2a6c53ba42c..ecd1a31339a 100644 --- a/app/assets/javascripts/jira_connect/utils.js +++ b/app/assets/javascripts/jira_connect/utils.js @@ -1,6 +1,8 @@ import AccessorUtilities from '~/lib/utils/accessor'; import { ALERT_LOCALSTORAGE_KEY } from './constants'; +const isFunction = (fn) => typeof fn === 'function'; + /** * Persist alert data to localStorage. */ @@ -31,3 +33,41 @@ export const retrieveAlert = () => { return JSON.parse(initialAlertJSON); }; + +export const getJwt = () => { + return new Promise((resolve) => { + if (isFunction(AP?.context?.getToken)) { + AP.context.getToken((token) => { + resolve(token); + }); + } else { + resolve(); + } + }); +}; + +export const getLocation = () => { + return new Promise((resolve) => { + if (isFunction(AP?.getLocation)) { + AP.getLocation((location) => { + resolve(location); + }); + } else { + resolve(); + } + }); +}; + +export const reloadPage = () => { + if (isFunction(AP?.navigator?.reload)) { + AP.navigator.reload(); + } else { + window.location.reload(); + } +}; + +export const sizeToParent = () => { + if (isFunction(AP?.sizeToParent)) { + AP.sizeToParent(); + } +}; |