summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/releases/components
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/releases/components')
-rw-r--r--app/assets/javascripts/releases/components/app_index_apollo_client.vue275
-rw-r--r--app/assets/javascripts/releases/components/releases_empty_state.vue44
-rw-r--r--app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue37
-rw-r--r--app/assets/javascripts/releases/components/releases_sort.vue6
-rw-r--r--app/assets/javascripts/releases/components/releases_sort_apollo_client.vue91
5 files changed, 450 insertions, 3 deletions
diff --git a/app/assets/javascripts/releases/components/app_index_apollo_client.vue b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
new file mode 100644
index 00000000000..ea0aa409577
--- /dev/null
+++ b/app/assets/javascripts/releases/components/app_index_apollo_client.vue
@@ -0,0 +1,275 @@
+<script>
+import { GlButton } from '@gitlab/ui';
+import createFlash from '~/flash';
+import { historyPushState, getParameterByName } from '~/lib/utils/common_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
+import { setUrlParams } from '~/lib/utils/url_utility';
+import { __ } from '~/locale';
+import { PAGE_SIZE, DEFAULT_SORT } from '~/releases/constants';
+import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
+import { convertAllReleasesGraphQLResponse } from '~/releases/util';
+import ReleaseBlock from './release_block.vue';
+import ReleaseSkeletonLoader from './release_skeleton_loader.vue';
+import ReleasesEmptyState from './releases_empty_state.vue';
+import ReleasesPaginationApolloClient from './releases_pagination_apollo_client.vue';
+import ReleasesSortApolloClient from './releases_sort_apollo_client.vue';
+
+export default {
+ name: 'ReleasesIndexApolloClientApp',
+ components: {
+ GlButton,
+ ReleaseBlock,
+ ReleaseSkeletonLoader,
+ ReleasesEmptyState,
+ ReleasesPaginationApolloClient,
+ ReleasesSortApolloClient,
+ },
+ inject: {
+ projectPath: {
+ default: '',
+ },
+ newReleasePath: {
+ default: '',
+ },
+ },
+ apollo: {
+ /**
+ * The same query as `fullGraphqlResponse`, except that it limits its
+ * results to a single item. This causes this request to complete much more
+ * quickly than `fullGraphqlResponse`, which allows the page to show
+ * meaningful content to the user much earlier.
+ */
+ singleGraphqlResponse: {
+ query: allReleasesQuery,
+ // This trick only works when paginating _forward_.
+ // When paginating backwards, limiting the query to a single item loads
+ // the _last_ item in the page, which is not useful for our purposes.
+ skip() {
+ return !this.includeSingleQuery;
+ },
+ variables() {
+ return {
+ ...this.queryVariables,
+ first: 1,
+ };
+ },
+ update(data) {
+ return { data };
+ },
+ error() {
+ this.singleRequestError = true;
+ },
+ },
+ fullGraphqlResponse: {
+ query: allReleasesQuery,
+ variables() {
+ return this.queryVariables;
+ },
+ update(data) {
+ return { data };
+ },
+ error(error) {
+ this.fullRequestError = true;
+
+ createFlash({
+ message: this.$options.i18n.errorMessage,
+ captureError: true,
+ error,
+ });
+ },
+ },
+ },
+ data() {
+ return {
+ singleRequestError: false,
+ fullRequestError: false,
+ cursors: {
+ before: getParameterByName('before'),
+ after: getParameterByName('after'),
+ },
+ sort: DEFAULT_SORT,
+ };
+ },
+ computed: {
+ queryVariables() {
+ let paginationParams = { first: PAGE_SIZE };
+ if (this.cursors.after) {
+ paginationParams = {
+ after: this.cursors.after,
+ first: PAGE_SIZE,
+ };
+ } else if (this.cursors.before) {
+ paginationParams = {
+ before: this.cursors.before,
+ last: PAGE_SIZE,
+ };
+ }
+
+ return {
+ fullPath: this.projectPath,
+ ...paginationParams,
+ sort: this.sort,
+ };
+ },
+ /**
+ * @returns {Boolean} Whether or not to request/include
+ * the results of the single-item query
+ */
+ includeSingleQuery() {
+ return Boolean(!this.cursors.before || this.cursors.after);
+ },
+ isSingleRequestLoading() {
+ return this.$apollo.queries.singleGraphqlResponse.loading;
+ },
+ isFullRequestLoading() {
+ return this.$apollo.queries.fullGraphqlResponse.loading;
+ },
+ /**
+ * @returns {Boolean} `true` if the `singleGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isSingleRequestLoaded() {
+ return Boolean(!this.isSingleRequestLoading && this.singleGraphqlResponse?.data.project);
+ },
+ /**
+ * @returns {Boolean} `true` if the `fullGraphqlResponse`
+ * query has finished loading without errors
+ */
+ isFullRequestLoaded() {
+ return Boolean(!this.isFullRequestLoading && this.fullGraphqlResponse?.data.project);
+ },
+ releases() {
+ if (this.isFullRequestLoaded) {
+ return convertAllReleasesGraphQLResponse(this.fullGraphqlResponse).data;
+ }
+
+ if (this.isSingleRequestLoaded && this.includeSingleQuery) {
+ return convertAllReleasesGraphQLResponse(this.singleGraphqlResponse).data;
+ }
+
+ return [];
+ },
+ pageInfo() {
+ if (!this.isFullRequestLoaded) {
+ return {
+ hasPreviousPage: false,
+ hasNextPage: false,
+ };
+ }
+
+ return this.fullGraphqlResponse.data.project.releases.pageInfo;
+ },
+ shouldRenderEmptyState() {
+ return this.isFullRequestLoaded && this.releases.length === 0;
+ },
+ shouldRenderLoadingIndicator() {
+ return (
+ (this.isSingleRequestLoading && !this.singleRequestError && !this.isFullRequestLoaded) ||
+ (this.isFullRequestLoading && !this.fullRequestError)
+ );
+ },
+ shouldRenderPagination() {
+ return this.isFullRequestLoaded && !this.shouldRenderEmptyState;
+ },
+ },
+ created() {
+ this.updateQueryParamsFromUrl();
+
+ window.addEventListener('popstate', this.updateQueryParamsFromUrl);
+ },
+ destroyed() {
+ window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
+ },
+ methods: {
+ getReleaseKey(release, index) {
+ return [release.tagName, release.name, index].join('|');
+ },
+ updateQueryParamsFromUrl() {
+ this.cursors.before = getParameterByName('before');
+ this.cursors.after = getParameterByName('after');
+ },
+ onPaginationButtonPress() {
+ this.updateQueryParamsFromUrl();
+
+ // In some cases, Apollo Client is able to pull its results from the cache instead of making
+ // a new network request. In these cases, the page's content gets swapped out immediately without
+ // changing the page's scroll, leaving the user looking at the bottom of the new page.
+ // To make the experience consistent, regardless of how the data is sourced, we manually
+ // scroll to the top of the page every time a pagination button is pressed.
+ scrollUp();
+ },
+ onSortChanged(newSort) {
+ if (this.sort === newSort) {
+ return;
+ }
+
+ // Remove the "before" and "after" query parameters from the URL,
+ // effectively placing the user back on page 1 of the results.
+ // This prevents the frontend from requesting the results sorted
+ // by one field (e.g. `released_at`) while using a pagination cursor
+ // intended for a different field (e.g.) `created_at`).
+ // For more details, see the MR that introduced this change:
+ // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/63434
+ historyPushState(
+ setUrlParams({
+ before: null,
+ after: null,
+ }),
+ );
+
+ this.updateQueryParamsFromUrl();
+
+ this.sort = newSort;
+ },
+ },
+ i18n: {
+ newRelease: __('New release'),
+ errorMessage: __('An error occurred while fetching the releases. Please try again.'),
+ },
+};
+</script>
+<template>
+ <div class="flex flex-column mt-2">
+ <div class="gl-align-self-end gl-mb-3">
+ <releases-sort-apollo-client :value="sort" class="gl-mr-2" @input="onSortChanged" />
+
+ <gl-button
+ v-if="newReleasePath"
+ :href="newReleasePath"
+ :aria-describedby="shouldRenderEmptyState && 'releases-description'"
+ category="primary"
+ variant="success"
+ >{{ $options.i18n.newRelease }}</gl-button
+ >
+ </div>
+
+ <releases-empty-state v-if="shouldRenderEmptyState" />
+
+ <release-block
+ v-for="(release, index) in releases"
+ :key="getReleaseKey(release, index)"
+ :release="release"
+ :class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
+ />
+
+ <release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
+
+ <releases-pagination-apollo-client
+ v-if="shouldRenderPagination"
+ :page-info="pageInfo"
+ @prev="onPaginationButtonPress"
+ @next="onPaginationButtonPress"
+ />
+ </div>
+</template>
+<style>
+.linked-card::after {
+ width: 1px;
+ content: ' ';
+ border: 1px solid #e5e5e5;
+ height: 17px;
+ top: 100%;
+ position: absolute;
+ left: 32px;
+}
+</style>
diff --git a/app/assets/javascripts/releases/components/releases_empty_state.vue b/app/assets/javascripts/releases/components/releases_empty_state.vue
new file mode 100644
index 00000000000..800497c186a
--- /dev/null
+++ b/app/assets/javascripts/releases/components/releases_empty_state.vue
@@ -0,0 +1,44 @@
+<script>
+import { GlEmptyState, GlLink } from '@gitlab/ui';
+import { __ } from '~/locale';
+
+export default {
+ name: 'ReleasesEmptyState',
+ components: {
+ GlEmptyState,
+ GlLink,
+ },
+ inject: {
+ documentationPath: {
+ default: '',
+ },
+ illustrationPath: {
+ default: '',
+ },
+ },
+ i18n: {
+ emptyStateTitle: __('Getting started with releases'),
+ emptyStateText: __(
+ "Releases are based on Git tags and mark specific points in a project's development history. They can contain information about the type of changes and can also deliver binaries, like compiled versions of your software.",
+ ),
+ releasesDocumentation: __('Releases documentation'),
+ moreInformation: __('More information'),
+ },
+};
+</script>
+<template>
+ <gl-empty-state :title="$options.i18n.emptyStateTitle" :svg-path="illustrationPath">
+ <template #description>
+ <span id="releases-description">
+ {{ $options.i18n.emptyStateText }}
+ <gl-link
+ :href="documentationPath"
+ :aria-label="$options.i18n.releasesDocumentation"
+ target="_blank"
+ >
+ {{ $options.i18n.moreInformation }}
+ </gl-link>
+ </span>
+ </template>
+ </gl-empty-state>
+</template>
diff --git a/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
new file mode 100644
index 00000000000..73339677a4b
--- /dev/null
+++ b/app/assets/javascripts/releases/components/releases_pagination_apollo_client.vue
@@ -0,0 +1,37 @@
+<script>
+import { GlKeysetPagination } from '@gitlab/ui';
+import { isBoolean } from 'lodash';
+import { historyPushState, buildUrlWithCurrentLocation } from '~/lib/utils/common_utils';
+
+export default {
+ name: 'ReleasesPaginationApolloClient',
+ components: { GlKeysetPagination },
+ props: {
+ pageInfo: {
+ type: Object,
+ required: true,
+ validator: (info) => isBoolean(info.hasPreviousPage) && isBoolean(info.hasNextPage),
+ },
+ },
+ methods: {
+ onPrev(before) {
+ historyPushState(buildUrlWithCurrentLocation(`?before=${before}`));
+ },
+ onNext(after) {
+ historyPushState(buildUrlWithCurrentLocation(`?after=${after}`));
+ },
+ },
+};
+</script>
+<template>
+ <div class="gl-display-flex gl-justify-content-center">
+ <gl-keyset-pagination
+ v-bind="pageInfo"
+ :prev-text="__('Prev')"
+ :next-text="__('Next')"
+ v-on="$listeners"
+ @prev="onPrev($event)"
+ @next="onNext($event)"
+ />
+ </div>
+</template>
diff --git a/app/assets/javascripts/releases/components/releases_sort.vue b/app/assets/javascripts/releases/components/releases_sort.vue
index 4988904a2cd..d4210dad19c 100644
--- a/app/assets/javascripts/releases/components/releases_sort.vue
+++ b/app/assets/javascripts/releases/components/releases_sort.vue
@@ -1,7 +1,7 @@
<script>
import { GlSorting, GlSortingItem } from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
-import { ASCENDING_ODER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
+import { ASCENDING_ORDER, DESCENDING_ORDER, SORT_OPTIONS } from '../constants';
export default {
name: 'ReleasesSort',
@@ -22,13 +22,13 @@ export default {
return option.label;
},
isSortAscending() {
- return this.sort === ASCENDING_ODER;
+ return this.sort === ASCENDING_ORDER;
},
},
methods: {
...mapActions('index', ['setSorting']),
onDirectionChange() {
- const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ODER;
+ const sort = this.isSortAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
this.setSorting({ sort });
this.$emit('sort:changed');
},
diff --git a/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
new file mode 100644
index 00000000000..7257b34bbf6
--- /dev/null
+++ b/app/assets/javascripts/releases/components/releases_sort_apollo_client.vue
@@ -0,0 +1,91 @@
+<script>
+import { GlSorting, GlSortingItem } from '@gitlab/ui';
+import {
+ ASCENDING_ORDER,
+ DESCENDING_ORDER,
+ SORT_OPTIONS,
+ RELEASED_AT,
+ CREATED_AT,
+ RELEASED_AT_ASC,
+ RELEASED_AT_DESC,
+ CREATED_ASC,
+ ALL_SORTS,
+ SORT_MAP,
+} from '../constants';
+
+export default {
+ name: 'ReleasesSortApolloclient',
+ components: {
+ GlSorting,
+ GlSortingItem,
+ },
+ props: {
+ value: {
+ type: String,
+ required: true,
+ validator: (sort) => ALL_SORTS.includes(sort),
+ },
+ },
+ computed: {
+ orderBy() {
+ if (this.value === RELEASED_AT_ASC || this.value === RELEASED_AT_DESC) {
+ return RELEASED_AT;
+ }
+
+ return CREATED_AT;
+ },
+ direction() {
+ if (this.value === RELEASED_AT_ASC || this.value === CREATED_ASC) {
+ return ASCENDING_ORDER;
+ }
+
+ return DESCENDING_ORDER;
+ },
+ sortOptions() {
+ return SORT_OPTIONS;
+ },
+ sortText() {
+ return this.sortOptions.find((s) => s.orderBy === this.orderBy).label;
+ },
+ isDirectionAscending() {
+ return this.direction === ASCENDING_ORDER;
+ },
+ },
+ methods: {
+ onDirectionChange() {
+ const direction = this.isDirectionAscending ? DESCENDING_ORDER : ASCENDING_ORDER;
+ this.emitInputEventIfChanged(this.orderBy, direction);
+ },
+ onSortItemClick(item) {
+ this.emitInputEventIfChanged(item.orderBy, this.direction);
+ },
+ isActiveSortItem(item) {
+ return this.orderBy === item.orderBy;
+ },
+ emitInputEventIfChanged(orderBy, direction) {
+ const newSort = SORT_MAP[orderBy][direction];
+ if (newSort !== this.value) {
+ this.$emit('input', SORT_MAP[orderBy][direction]);
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-sorting
+ :text="sortText"
+ :is-ascending="isDirectionAscending"
+ data-testid="releases-sort"
+ @sortDirectionChange="onDirectionChange"
+ >
+ <gl-sorting-item
+ v-for="item of sortOptions"
+ :key="item.orderBy"
+ :active="isActiveSortItem(item)"
+ @click="onSortItemClick(item)"
+ >
+ {{ item.label }}
+ </gl-sorting-item>
+ </gl-sorting>
+</template>