Commit 4928475c authored by Jose Ivan Vargas's avatar Jose Ivan Vargas

Merge branch 'nfriend-convert-releases-index-page-to-apollo-client' into 'master'

Convert Releases index page to Apollo Client [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!61828
parents 43e7065f 0839172e
<script>
import { GlButton } from '@gitlab/ui';
import createFlash from '~/flash';
import { getParameterByName } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import { PAGE_SIZE } 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';
export default {
name: 'ReleasesIndexApolloClientApp',
components: {
GlButton,
ReleaseBlock,
ReleaseSkeletonLoader,
ReleasesEmptyState,
},
inject: {
projectPath: {
default: '',
},
newReleasePath: {
default: '',
},
},
apollo: {
graphqlResponse: {
query: allReleasesQuery,
variables() {
return this.queryVariables;
},
update(data) {
return { data };
},
error(error) {
this.hasError = true;
createFlash({
message: this.$options.i18n.errorMessage,
captureError: true,
error,
});
},
},
},
data() {
return {
hasError: false,
cursors: {
before: getParameterByName('before'),
after: getParameterByName('after'),
},
};
},
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,
};
},
isLoading() {
return this.$apollo.queries.graphqlResponse.loading;
},
releases() {
if (!this.graphqlResponse || this.hasError) {
return [];
}
return convertAllReleasesGraphQLResponse(this.graphqlResponse).data;
},
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
},
shouldRenderLoadingIndicator() {
return this.isLoading && !this.hasError;
},
},
created() {
this.updateQueryParamsFromUrl();
window.addEventListener('popstate', this.updateQueryParamsFromUrl);
},
destroyed() {
window.removeEventListener('popstate', this.updateQueryParamsFromUrl);
},
methods: {
updateQueryParamsFromUrl() {
this.cursors.before = getParameterByName('before');
this.cursors.after = getParameterByName('after');
},
},
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">
<gl-button
v-if="newReleasePath"
:href="newReleasePath"
:aria-describedby="shouldRenderEmptyState && 'releases-description'"
category="primary"
variant="success"
>{{ $options.i18n.newRelease }}</gl-button
>
</div>
<release-skeleton-loader v-if="shouldRenderLoadingIndicator" />
<releases-empty-state v-else-if="shouldRenderEmptyState" />
<div v-else-if="shouldRenderSuccessState">
<release-block
v-for="(release, index) in releases"
:key="index"
:release="release"
:class="{ 'linked-card': releases.length > 1 && index !== releases.length - 1 }"
/>
</div>
</div>
</template>
<style>
.linked-card::after {
width: 1px;
content: ' ';
border: 1px solid #e5e5e5;
height: 17px;
top: 100%;
position: absolute;
left: 32px;
}
</style>
<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>
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import Vuex from 'vuex';
import createDefaultClient from '~/lib/graphql';
import ReleaseIndexApp from './components/app_index.vue';
import ReleaseIndexApollopClientApp from './components/app_index_apollo_client.vue';
import createStore from './stores';
import createIndexModule from './stores/modules/index';
Vue.use(Vuex);
export default () => {
const el = document.getElementById('js-releases-page');
if (window.gon?.features?.releasesIndexApolloClient) {
Vue.use(VueApollo);
const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(),
});
return new Vue({
el,
apolloProvider,
provide: { ...el.dataset },
render: (h) => h(ReleaseIndexApollopClientApp),
});
}
Vue.use(Vuex);
return new Vue({
el,
store: createStore({
......
......@@ -10,6 +10,9 @@ class Projects::ReleasesController < Projects::ApplicationController
before_action :authorize_download_code!, except: [:index]
before_action :authorize_update_release!, only: %i[edit update]
before_action :authorize_create_release!, only: :new
before_action only: :index do
push_frontend_feature_flag(:releases_index_apollo_client, project, default_enabled: :yaml)
end
feature_category :release_orchestration
......
---
name: releases_index_apollo_client
introduced_by_url:
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/331006
milestone: '14.0'
type: development
group: group::release
default_enabled: false
......@@ -15,6 +15,7 @@ RSpec.describe 'User views releases', :js do
let_it_be(:guest) { create(:user) }
before do
stub_feature_flags(releases_index_apollo_client: false)
project.add_maintainer(maintainer)
project.add_guest(guest)
end
......
import { cloneDeep } from 'lodash';
import Vue from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import createFlash from '~/flash';
import ReleasesIndexApolloClientApp from '~/releases/components/app_index_apollo_client.vue';
import ReleaseBlock from '~/releases/components/release_block.vue';
import ReleaseSkeletonLoader from '~/releases/components/release_skeleton_loader.vue';
import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
import { PAGE_SIZE } from '~/releases/constants';
import allReleasesQuery from '~/releases/graphql/queries/all_releases.query.graphql';
Vue.use(VueApollo);
jest.mock('~/flash');
let mockQueryParams;
jest.mock('~/lib/utils/common_utils', () => ({
...jest.requireActual('~/lib/utils/common_utils'),
getParameterByName: jest
.fn()
.mockImplementation((parameterName) => mockQueryParams[parameterName]),
}));
describe('app_index_apollo_client.vue', () => {
const originalAllReleasesQueryResponse = getJSONFixture(
'graphql/releases/graphql/queries/all_releases.query.graphql.json',
);
const projectPath = 'project/path';
const newReleasePath = 'path/to/new/release/page';
let wrapper;
let allReleasesQueryResponse;
let allReleasesQueryMock;
const createComponent = (queryResponse = Promise.resolve(allReleasesQueryResponse)) => {
const apolloProvider = createMockApollo([
[allReleasesQuery, allReleasesQueryMock.mockReturnValueOnce(queryResponse)],
]);
wrapper = shallowMountExtended(ReleasesIndexApolloClientApp, {
apolloProvider,
provide: {
newReleasePath,
projectPath,
},
});
};
beforeEach(() => {
mockQueryParams = {};
allReleasesQueryResponse = cloneDeep(originalAllReleasesQueryResponse);
allReleasesQueryMock = jest.fn();
});
afterEach(() => {
wrapper.destroy();
});
// Finders
const findLoadingIndicator = () => wrapper.findComponent(ReleaseSkeletonLoader);
const findEmptyState = () => wrapper.findComponent(ReleasesEmptyState);
const findNewReleaseButton = () =>
wrapper.findByText(ReleasesIndexApolloClientApp.i18n.newRelease);
const findAllReleaseBlocks = () => wrapper.findAllComponents(ReleaseBlock);
// Expectations
const expectLoadingIndicator = () => {
it('renders a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(true);
});
};
const expectNoLoadingIndicator = () => {
it('does not render a loading indicator', () => {
expect(findLoadingIndicator().exists()).toBe(false);
});
};
const expectEmptyState = () => {
it('renders the empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
};
const expectNoEmptyState = () => {
it('does not render the empty state', () => {
expect(findEmptyState().exists()).toBe(false);
});
};
const expectFlashMessage = (message = ReleasesIndexApolloClientApp.i18n.errorMessage) => {
it(`shows a flash message that reads "${message}"`, () => {
expect(createFlash).toHaveBeenCalledTimes(1);
expect(createFlash).toHaveBeenCalledWith({
message,
captureError: true,
error: expect.any(Error),
});
});
};
const expectNewReleaseButton = () => {
it('renders the "New Release" button', () => {
expect(findNewReleaseButton().exists()).toBe(true);
});
};
const expectNoFlashMessage = () => {
it(`does not show a flash message`, () => {
expect(createFlash).not.toHaveBeenCalled();
});
};
const expectReleases = (count) => {
it(`renders ${count} release(s)`, () => {
expect(findAllReleaseBlocks()).toHaveLength(count);
});
};
// Tests
describe('when the component is loading data', () => {
beforeEach(() => {
createComponent(new Promise(() => {}));
});
expectLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
});
describe('when the data has successfully loaded, but there are no releases', () => {
beforeEach(() => {
allReleasesQueryResponse.data.project.releases.nodes = [];
createComponent(Promise.resolve(allReleasesQueryResponse));
});
expectNoLoadingIndicator();
expectEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(0);
});
describe('when an error occurs while loading data', () => {
beforeEach(() => {
createComponent(Promise.reject(new Error('Oops!')));
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectFlashMessage();
expectNewReleaseButton();
expectReleases(0);
});
describe('when the data has successfully loaded', () => {
beforeEach(() => {
createComponent();
});
expectNoLoadingIndicator();
expectNoEmptyState();
expectNoFlashMessage();
expectNewReleaseButton();
expectReleases(originalAllReleasesQueryResponse.data.project.releases.nodes.length);
});
describe('URL parameters', () => {
const before = 'beforeCursor';
const after = 'afterCursor';
describe('when the URL contains no query parameters', () => {
beforeEach(() => {
createComponent();
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
first: PAGE_SIZE,
fullPath: projectPath,
});
});
});
describe('when the URL contains a "before" query parameter', () => {
beforeEach(() => {
mockQueryParams = { before };
createComponent();
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
before,
last: PAGE_SIZE,
fullPath: projectPath,
});
});
});
describe('when the URL contains an "after" query parameter', () => {
beforeEach(() => {
mockQueryParams = { after };
createComponent();
});
it('makes a request with the correct GraphQL query parameters', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
});
});
});
describe('when the URL contains both "before" and "after" query parameters', () => {
beforeEach(() => {
mockQueryParams = { before, after };
createComponent();
});
it('ignores the "before" parameter and behaves as if only the "after" parameter was provided', () => {
expect(allReleasesQueryMock).toHaveBeenCalledWith({
after,
first: PAGE_SIZE,
fullPath: projectPath,
});
});
});
});
describe('New release button', () => {
beforeEach(() => {
createComponent();
});
it('renders the new release button with the correct href', () => {
expect(findNewReleaseButton().attributes().href).toBe(newReleasePath);
});
});
});
import { GlEmptyState } from '@gitlab/ui';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import ReleasesEmptyState from '~/releases/components/releases_empty_state.vue';
describe('releases_empty_state.vue', () => {
const documentationPath = 'path/to/releases/documentation';
const illustrationPath = 'path/to/releases/empty/state/illustration';
let wrapper;
const createComponent = () => {
wrapper = shallowMountExtended(ReleasesEmptyState, {
provide: {
documentationPath,
illustrationPath,
},
});
};
beforeEach(() => {
createComponent();
});
afterEach(() => {
wrapper.destroy();
});
it('renders a GlEmptyState and provides it with the correct props', () => {
const emptyStateProps = wrapper.findComponent(GlEmptyState).props();
expect(emptyStateProps).toEqual(
expect.objectContaining({
title: ReleasesEmptyState.i18n.emptyStateTitle,
svgPath: illustrationPath,
}),
);
});
it('renders the empty state text', () => {
expect(wrapper.findByText(ReleasesEmptyState.i18n.emptyStateText).exists()).toBe(true);
});
it('renders a link to the documentation', () => {
const documentationLink = wrapper.findByText(ReleasesEmptyState.i18n.moreInformation);
expect(documentationLink.exists()).toBe(true);
expect(documentationLink.attributes()).toEqual(
expect.objectContaining({
'aria-label': ReleasesEmptyState.i18n.releasesDocumentation,
href: documentationPath,
target: '_blank',
}),
);
});
});
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment