diff options
Diffstat (limited to 'app/assets/javascripts/jira_connect/subscriptions/components')
5 files changed, 485 insertions, 0 deletions
diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/app.vue b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue new file mode 100644 index 00000000000..413424be28d --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/app.vue @@ -0,0 +1,125 @@ +<script> +import { GlAlert, GlButton, GlLink, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import { mapState, mapMutations } from 'vuex'; +import { retrieveAlert, getLocation } from '~/jira_connect/subscriptions/utils'; +import { __ } from '~/locale'; +import { SET_ALERT } from '../store/mutation_types'; +import GroupsList from './groups_list.vue'; +import SubscriptionsList from './subscriptions_list.vue'; + +export default { + name: 'JiraConnectApp', + components: { + GlAlert, + GlButton, + GlLink, + GlModal, + GlSprintf, + GroupsList, + SubscriptionsList, + }, + directives: { + GlModalDirective, + }, + inject: { + usersPath: { + default: '', + }, + }, + data() { + return { + location: '', + }; + }, + computed: { + ...mapState(['alert']), + usersPathWithReturnTo() { + if (this.location) { + return `${this.usersPath}?return_to=${this.location}`; + } + + return this.usersPath; + }, + shouldShowAlert() { + return Boolean(this.alert?.message); + }, + }, + modal: { + cancelProps: { + text: __('Cancel'), + }, + }, + created() { + this.setInitialAlert(); + this.setLocation(); + }, + methods: { + ...mapMutations({ + setAlert: SET_ALERT, + }), + async setLocation() { + this.location = await getLocation(); + }, + setInitialAlert() { + const { linkUrl, title, message, variant } = retrieveAlert() || {}; + this.setAlert({ linkUrl, title, message, variant }); + }, + }, +}; +</script> + +<template> + <div> + <gl-alert + v-if="shouldShowAlert" + class="gl-mb-7" + :variant="alert.variant" + :title="alert.title" + @dismiss="setAlert" + > + <gl-sprintf v-if="alert.linkUrl" :message="alert.message"> + <template #link="{ content }"> + <gl-link :href="alert.linkUrl" target="_blank">{{ content }}</gl-link> + </template> + </gl-sprintf> + + <template v-else> + {{ alert.message }} + </template> + </gl-alert> + + <h2 class="gl-text-center">{{ s__('JiraService|GitLab for Jira Configuration') }}</h2> + + <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-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> + <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/subscriptions/components/group_item_name.vue b/app/assets/javascripts/jira_connect/subscriptions/components/group_item_name.vue new file mode 100644 index 00000000000..e6c172dae9e --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/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/subscriptions/components/groups_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue new file mode 100644 index 00000000000..5a49d7c1a90 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list.vue @@ -0,0 +1,132 @@ +<script> +import { GlLoadingIcon, GlPagination, GlAlert, GlSearchBoxByType } from '@gitlab/ui'; +import { fetchGroups } from '~/jira_connect/subscriptions/api'; +import { + DEFAULT_GROUPS_PER_PAGE, + MINIMUM_SEARCH_TERM_LENGTH, +} from '~/jira_connect/subscriptions/constants'; +import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; +import { s__ } from '~/locale'; +import GroupsListItem from './groups_list_item.vue'; + +export default { + components: { + GlLoadingIcon, + GlPagination, + GlAlert, + GlSearchBoxByType, + GroupsListItem, + }, + inject: { + groupsPath: { + default: '', + }, + }, + data() { + return { + groups: [], + isLoadingInitial: true, + isLoadingMore: false, + page: 1, + totalItems: 0, + errorMessage: null, + searchTerm: '', + }; + }, + computed: { + showPagination() { + return this.totalItems > this.$options.DEFAULT_GROUPS_PER_PAGE && this.groups.length > 0; + }, + }, + mounted() { + return this.loadGroups().finally(() => { + this.isLoadingInitial = false; + }); + }, + methods: { + loadGroups() { + // fetchGroups returns no results for search terms 0 < {length} < 3. + // The desired UX is to return the unfiltered results for searches {length} < 3. + // Here, we set the search to an empty string if {length} < 3 + const search = this.searchTerm?.length < MINIMUM_SEARCH_TERM_LENGTH ? '' : this.searchTerm; + + this.isLoadingMore = true; + return fetchGroups(this.groupsPath, { + page: this.page, + perPage: this.$options.DEFAULT_GROUPS_PER_PAGE, + search, + }) + .then((response) => { + const { page, total } = parseIntPagination(normalizeHeaders(response.headers)); + this.page = page; + this.totalItems = total; + this.groups = response.data; + }) + .catch(() => { + this.errorMessage = s__('Integrations|Failed to load namespaces. Please try again.'); + }) + .finally(() => { + this.isLoadingMore = false; + }); + }, + onGroupSearch(searchTerm) { + // keep a copy of the search term for pagination + this.searchTerm = searchTerm; + // reset the current page + this.page = 1; + return this.loadGroups(); + }, + }, + DEFAULT_GROUPS_PER_PAGE, +}; +</script> + +<template> + <div> + <gl-alert v-if="errorMessage" class="gl-mb-5" variant="danger" @dismiss="errorMessage = null"> + {{ errorMessage }} + </gl-alert> + + <gl-search-box-by-type + class="gl-mb-5" + debounce="500" + :placeholder="__('Search by name')" + :is-loading="isLoadingMore" + :value="searchTerm" + @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="showPagination" + v-model="page" + class="gl-mb-0" + :per-page="$options.DEFAULT_GROUPS_PER_PAGE" + :total-items="totalItems" + @input="loadGroups" + /> + </div> + </div> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue new file mode 100644 index 00000000000..ed7585e8a88 --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/components/groups_list_item.vue @@ -0,0 +1,85 @@ +<script> +import { GlButton } from '@gitlab/ui'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { addSubscription } from '~/jira_connect/subscriptions/api'; +import { persistAlert, reloadPage } from '~/jira_connect/subscriptions/utils'; +import { s__ } from '~/locale'; +import GroupItemName from './group_item_name.vue'; + +export default { + components: { + GlButton, + GroupItemName, + }, + inject: { + subscriptionsPath: { + default: '', + }, + }, + props: { + group: { + type: Object, + required: true, + }, + disabled: { + type: Boolean, + required: false, + default: false, + }, + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + onClick() { + this.isLoading = true; + + addSubscription(this.subscriptionsPath, this.group.full_path) + .then(() => { + persistAlert({ + title: s__('Integrations|Namespace successfully linked'), + message: s__( + 'Integrations|You should now see GitLab.com activity inside your Jira Cloud issues. %{linkStart}Learn more%{linkEnd}', + ), + linkUrl: helpPagePath('integration/jira_development_panel.html', { anchor: 'usage' }), + variant: 'success', + }); + + reloadPage(); + }) + .catch((error) => { + this.$emit( + 'error', + error?.response?.data?.error || + s__('Integrations|Failed to link namespace. Please try again.'), + ); + this.isLoading = false; + }); + }, + }, +}; +</script> + +<template> + <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"> + <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"> + <group-item-name :group="group" /> + </div> + + <gl-button + category="secondary" + variant="confirm" + :loading="isLoading" + :disabled="disabled" + @click.prevent="onClick" + > + {{ __('Link') }} + </gl-button> + </div> + </div> + </li> +</template> diff --git a/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue b/app/assets/javascripts/jira_connect/subscriptions/components/subscriptions_list.vue new file mode 100644 index 00000000000..7062fb370ed --- /dev/null +++ b/app/assets/javascripts/jira_connect/subscriptions/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/subscriptions/api'; +import { reloadPage } from '~/jira_connect/subscriptions/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> |