Adds Vuex Store for the releases page

This commit is contained in:
Filipa Lacerda 2018-12-20 09:31:32 +00:00 committed by Phil Hughes
parent 8bcd342a50
commit 5c9aec7c78
21 changed files with 645 additions and 81 deletions

View File

@ -29,6 +29,7 @@ const Api = {
commitPipelinesPath: '/:project_id/commit/:sha/pipelines',
branchSinglePath: '/api/:version/projects/:id/repository/branches/:branch',
createBranchPath: '/api/:version/projects/:id/repository/branches',
releasesPath: '/api/:version/project/:id/releases',
group(groupId, callback) {
const url = Api.buildUrl(Api.groupPath).replace(':id', groupId);
@ -307,6 +308,12 @@ const Api = {
});
},
releases(id) {
const url = Api.buildUrl(this.releasesPath).replace(':id', encodeURIComponent(id));
return axios.get(url);
},
buildUrl(url) {
let urlRoot = '';
if (gon.relative_url_root != null) {

View File

@ -0,0 +1,3 @@
import initReleases from '~/releases';
document.addEventListener('DOMContentLoaded', initReleases);

View File

@ -0,0 +1,82 @@
<script>
import { mapState, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import ReleaseBlock from './release_block.vue';
export default {
name: 'ReleasesApp',
components: {
GlLoadingIcon,
GlEmptyState,
ReleaseBlock,
},
props: {
projectId: {
type: String,
required: true,
},
documentationLink: {
type: String,
required: true,
},
illustrationPath: {
type: String,
required: true,
},
},
computed: {
...mapState(['isLoading', 'releases', 'hasError']),
shouldRenderEmptyState() {
return !this.releases.length && !this.hasError && !this.isLoading;
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
},
},
created() {
this.fetchReleases(this.projectId);
},
methods: {
...mapActions(['fetchReleases']),
},
};
</script>
<template>
<div class="prepend-top-default">
<gl-loading-icon v-if="isLoading" :size="2" class="js-loading prepend-top-20" />
<gl-empty-state
v-else-if="shouldRenderEmptyState"
class="js-empty-state"
:title="__('Getting started with releases')"
:svg-path="illustrationPath"
:description="
__(
'Releases mark specific points in a project\'s development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API.',
)
"
:primary-button-link="documentationLink"
:primary-button-text="__('Open Documentation')"
/>
<div v-else-if="shouldRenderSuccessState" class="js-success-state">
<release-block
v-for="(release, index) in releases"
:key="release.tag_name"
: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>

View File

@ -17,66 +17,36 @@ export default {
},
mixins: [timeagoMixin],
props: {
name: {
type: String,
required: true,
},
tag: {
type: String,
required: true,
},
commit: {
release: {
type: Object,
required: true,
},
description: {
type: String,
required: false,
default: '',
},
author: {
type: Object,
required: true,
},
createdAt: {
type: String,
required: false,
default: '',
},
assetsCount: {
type: Number,
required: false,
default: 0,
},
sources: {
type: Array,
required: false,
default: () => [],
},
links: {
type: Array,
required: false,
default: () => [],
default: () => ({}),
},
},
computed: {
releasedTimeAgo() {
return sprintf('released %{time}', {
time: this.timeFormated(this.createdAt),
time: this.timeFormated(this.release.created_at),
});
},
userImageAltDescription() {
return this.author && this.author.username
? sprintf("%{username}'s avatar", { username: this.author.username })
return this.commit.author && this.commit.author.username
? sprintf("%{username}'s avatar", { username: this.commit.author.username })
: null;
},
commit() {
return this.release.commit || {};
},
assets() {
return this.release.assets || {};
},
},
};
</script>
<template>
<div class="card">
<div class="card-body">
<h2 class="card-title mt-0">{{ name }}</h2>
<h2 class="card-title mt-0">{{ release.name }}</h2>
<div class="card-subtitle d-flex flex-wrap text-secondary">
<div class="append-right-8">
@ -86,40 +56,47 @@ export default {
<div class="append-right-8">
<icon name="tag" class="align-middle" />
<span v-gl-tooltip.bottom :title="__('Tag')">{{ tag }}</span>
<span v-gl-tooltip.bottom :title="__('Tag')">{{ release.tag_name }}</span>
</div>
<div class="append-right-4">
&bull;
<span v-gl-tooltip.bottom :title="tooltipTitle(createdAt)">{{ releasedTimeAgo }}</span>
<span v-gl-tooltip.bottom :title="tooltipTitle(release.created_at)">{{
releasedTimeAgo
}}</span>
</div>
<div class="d-flex">
<div v-if="commit.author" class="d-flex">
by
<user-avatar-link
class="prepend-left-4"
:link-href="author.path"
:img-src="author.avatar_url"
:link-href="commit.author.path"
:img-src="commit.author.avatar_url"
:img-alt="userImageAltDescription"
:tooltip-text="author.username"
:tooltip-text="commit.author.username"
/>
</div>
</div>
<div class="card-text prepend-top-default">
<div
v-if="assets.links.length || assets.sources.length"
Sclass="card-text prepend-top-default"
>
<b>
{{ __('Assets') }} <span class="js-assets-count badge badge-pill">{{ assetsCount }}</span>
{{ __('Assets') }}
<span class="js-assets-count badge badge-pill">{{ assets.count }}</span>
</b>
<ul class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
<li v-for="link in links" :key="link.name" class="append-bottom-8">
<ul v-if="assets.links.length" class="pl-0 mb-0 prepend-top-8 list-unstyled js-assets-list">
<li v-for="link in assets.links" :key="link.name" class="append-bottom-8">
<gl-link v-gl-tooltip.bottom :title="__('Download asset')" :href="link.url">
<icon name="package" class="align-middle append-right-4" /> {{ link.name }}
<icon name="package" class="align-middle append-right-4 align-text-bottom" />
{{ link.name }}
</gl-link>
</li>
</ul>
<div class="dropdown">
<div v-if="assets.sources.length" class="dropdown">
<button
type="button"
class="btn btn-link"
@ -132,14 +109,14 @@ export default {
</button>
<div class="js-sources-dropdown dropdown-menu">
<li v-for="asset in sources" :key="asset.url">
<li v-for="asset in assets.sources" :key="asset.url">
<gl-link :href="asset.url">{{ __('Download') }} {{ asset.format }}</gl-link>
</li>
</div>
</div>
</div>
<div class="card-text prepend-top-default"><div v-html="description"></div></div>
<div class="card-text prepend-top-default"><div v-html="release.description_html"></div></div>
</div>
</div>
</template>

View File

@ -0,0 +1,24 @@
import Vue from 'vue';
import App from './components/app.vue';
import createStore from './store';
export default () => {
const element = document.getElementById('js-releases-page');
return new Vue({
el: element,
store: createStore(),
components: {
App,
},
render(createElement) {
return createElement('app', {
props: {
endpoint: element.dataset.endpoint,
documentationLink: element.dataset.documentationPath,
illustrationPath: element.dataset.illustrationPath,
},
});
},
});
};

View File

@ -0,0 +1,37 @@
import * as types from './mutation_types';
import createFlash from '~/flash';
import { __ } from '~/locale';
import api from '~/api';
/**
* Commits a mutation to update the state while the main endpoint is being requested.
*/
export const requestReleases = ({ commit }) => commit(types.REQUEST_RELEASES);
/**
* Fetches the main endpoint.
* Will dispatch requestNamespace action before starting the request.
* Will dispatch receiveNamespaceSuccess if the request is successfull
* Will dispatch receiveNamesapceError if the request returns an error
*
* @param {String} projectId
*/
export const fetchReleases = ({ dispatch }, projectId) => {
dispatch('requestReleases');
api
.releases(projectId)
.then(({ data }) => dispatch('receiveReleasesSuccess', data))
.catch(() => dispatch('receiveReleasesError'));
};
export const receiveReleasesSuccess = ({ commit }, data) =>
commit(types.RECEIVE_RELEASES_SUCCESS, data);
export const receiveReleasesError = ({ commit }) => {
commit(types.RECEIVE_RELEASES_ERROR);
createFlash(__('An error occured while fetching the releases. Please try again.'));
};
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};

View File

@ -0,0 +1,14 @@
import Vue from 'vue';
import Vuex from 'vuex';
import state from './state';
import * as actions from './actions';
import mutations from './mutations';
Vue.use(Vuex);
export default () =>
new Vuex.Store({
actions,
mutations,
state: state(),
});

View File

@ -0,0 +1,3 @@
export const REQUEST_RELEASES = 'REQUEST_RELEASES';
export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';

View File

@ -0,0 +1,37 @@
import * as types from './mutation_types';
export default {
/**
* Sets isLoading to true while the request is being made.
* @param {Object} state
*/
[types.REQUEST_RELEASES](state) {
state.isLoading = true;
},
/**
* Sets isLoading to false.
* Sets hasError to false.
* Sets the received data
* @param {Object} state
* @param {Object} data
*/
[types.RECEIVE_RELEASES_SUCCESS](state, data) {
state.hasError = false;
state.isLoading = false;
state.releases = data;
},
/**
* Sets isLoading to false.
* Sets hasError to true.
* Resets the data
* @param {Object} state
* @param {Object} data
*/
[types.RECEIVE_RELEASES_ERROR](state) {
state.isLoading = false;
state.releases = [];
state.hasError = true;
},
};

View File

@ -0,0 +1,5 @@
export default () => ({
isLoading: false,
hasError: false,
releases: [],
});

View File

@ -1,5 +1,5 @@
- @no_container = true
- page_title _('Releases')
%div{ 'class' => container_class }
#js-releases-page
%div{ class: container_class }
#js-releases-page{ data: { project_id: @project.id, illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('user/project/releases') } }

View File

@ -0,0 +1,5 @@
---
title: Creates frontend app for releases
merge_request: 23796
author:
type: added

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@ -0,0 +1,12 @@
# Releases
> [Introduced](https://gitlab.com/gitlab-org/gitlab-ce/issues/41766) in GitLab 11.7.
Releases mark specific points in a project's development history, communicate
information about the type of change, and deliver on prepared, often compiled,
versions of the software to be reused elsewhere. Currently, releases can only be
created through the API.
Navigate to **Project > Releases** in order to see the list of releases of a project.
![Releases list](img/releases.png)

View File

@ -525,6 +525,9 @@ msgstr ""
msgid "An error has occurred"
msgstr ""
msgid "An error occured while fetching the releases. Please try again."
msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
@ -3172,6 +3175,9 @@ msgstr ""
msgid "Geo"
msgstr ""
msgid "Getting started with releases"
msgstr ""
msgid "Git"
msgstr ""
@ -4625,6 +4631,9 @@ msgstr ""
msgid "Open"
msgstr ""
msgid "Open Documentation"
msgstr ""
msgid "Open in Xcode"
msgstr ""
@ -5524,6 +5533,9 @@ msgstr ""
msgid "Releases"
msgstr ""
msgid "Releases mark specific points in a project's development history, communicate information about the type of change, and deliver on prepared, often compiled, versions of the software to be reused elsewhere. Currently, releases can only be created through the API."
msgstr ""
msgid "Remind later"
msgstr ""

View File

@ -0,0 +1,79 @@
import Vue from 'vue';
import app from '~/releases/components/app.vue';
import createStore from '~/releases/store';
import api from '~/api';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { resetStore } from '../store/helpers';
import { releases } from '../mock_data';
describe('Releases App ', () => {
const Component = Vue.extend(app);
let store;
let vm;
const props = {
projectId: 'gitlab-ce',
documentationLink: 'help/releases',
illustrationPath: 'illustration/path',
};
beforeEach(() => {
store = createStore();
});
afterEach(() => {
resetStore(store);
vm.$destroy();
});
describe('while loading', () => {
beforeEach(() => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] }));
vm = mountComponentWithStore(Component, { props, store });
});
it('renders loading icon', done => {
expect(vm.$el.querySelector('.js-loading')).not.toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
expect(vm.$el.querySelector('.js-success-state')).toBeNull();
setTimeout(() => {
done();
}, 0);
});
});
describe('with successful request', () => {
beforeEach(() => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases }));
vm = mountComponentWithStore(Component, { props, store });
});
it('renders success state', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-loading')).toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).toBeNull();
expect(vm.$el.querySelector('.js-success-state')).not.toBeNull();
done();
}, 0);
});
});
describe('with empty request', () => {
beforeEach(() => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: [] }));
vm = mountComponentWithStore(Component, { props, store });
});
it('renders empty state', done => {
setTimeout(() => {
expect(vm.$el.querySelector('.js-loading')).toBeNull();
expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull();
expect(vm.$el.querySelector('.js-success-state')).toBeNull();
done();
}, 0);
});
});
});

View File

@ -28,6 +28,16 @@ describe('Release block', () => {
committer_name: 'Jack Smith',
committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00',
author: {
avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
id: 482476,
name: 'John Doe',
path: '/johndoe',
state: 'active',
status_tooltip_html: null,
username: 'johndoe',
web_url: 'https://gitlab.com/johndoe',
},
},
assets: {
count: 6,
@ -66,32 +76,10 @@ describe('Release block', () => {
],
},
};
const props = {
name: release.name,
tag: release.tag_name,
commit: release.commit,
description: release.description_html,
author: {
avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
id: 482476,
name: 'John Doe',
path: '/johndoe',
state: 'active',
status_tooltip_html: null,
username: 'johndoe',
web_url: 'https://gitlab.com/johndoe',
},
createdAt: release.created_at,
assetsCount: release.assets.count,
sources: release.assets.sources,
links: release.assets.links,
};
let vm;
beforeEach(() => {
vm = mountComponent(Component, props);
vm = mountComponent(Component, { release });
});
afterEach(() => {

View File

@ -0,0 +1,128 @@
export const release = {
name: 'Bionic Beaver',
tag_name: '18.04',
description: '## changelog\n\n* line 1\n* line2',
description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>',
author_name: 'Release bot',
author_email: 'release-bot@example.com',
created_at: '2012-05-28T05:00:00-07:00',
commit: {
id: '2695effb5807a22ff3d138d593fd856244e155e7',
short_id: '2695effb',
title: 'Initial commit',
created_at: '2017-07-26T11:08:53.000+02:00',
parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'],
message: 'Initial commit',
author: {
avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
id: 482476,
name: 'John Doe',
path: '/johndoe',
state: 'active',
status_tooltip_html: null,
username: 'johndoe',
web_url: 'https://gitlab.com/johndoe',
},
authored_date: '2012-05-28T04:42:42-07:00',
committer_name: 'Jack Smith',
committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00',
},
assets: {
count: 6,
sources: [
{
format: 'zip',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.zip',
},
{
format: 'tar.gz',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.gz',
},
{
format: 'tar.bz2',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.bz2',
},
{
format: 'tar',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar',
},
],
links: [
{
name: 'release-18.04.dmg',
url: 'https://my-external-hosting.example.com/scrambled-url/',
external: true,
},
{
name: 'binary-linux-amd64',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
};
export const releases = [
release,
{
name: 'JoJos Bizarre Adventure',
tag_name: '19.00',
description: '## changelog\n\n* line 1\n* line2',
description_html: '<div><h2>changelog</h2><ul><li>line1</li<li>line 2</li></ul></div>',
author_name: 'Release bot',
author_email: 'release-bot@example.com',
created_at: '2012-05-28T05:00:00-07:00',
commit: {
id: '2695effb5807a22ff3d138d593fd856244e155e7',
short_id: '2695effb',
title: 'Initial commit',
created_at: '2017-07-26T11:08:53.000+02:00',
parent_ids: ['2a4b78934375d7f53875269ffd4f45fd83a84ebe'],
message: 'Initial commit',
author: {
avatar_url: 'uploads/-/system/user/avatar/johndoe/avatar.png',
id: 482476,
name: 'John Doe',
path: '/johndoe',
state: 'active',
status_tooltip_html: null,
username: 'johndoe',
web_url: 'https://gitlab.com/johndoe',
},
authored_date: '2012-05-28T04:42:42-07:00',
committer_name: 'Jack Smith',
committer_email: 'jack@example.com',
committed_date: '2012-05-28T04:42:42-07:00',
},
assets: {
count: 4,
sources: [
{
format: 'tar.gz',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.gz',
},
{
format: 'tar.bz2',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar.bz2',
},
{
format: 'tar',
url: 'https://gitlab.com/gitlab-org/gitlab-ce/-/archive/v11.3.12/gitlab-ce-v11.3.12.tar',
},
],
links: [
{
name: 'binary-linux-amd64',
url:
'https://gitlab.com/gitlab-org/gitlab-ce/-/jobs/artifacts/v11.6.0-rc4/download?job=rspec-mysql+41%2F50',
external: false,
},
],
},
},
];

View File

@ -0,0 +1,98 @@
import {
requestReleases,
fetchReleases,
receiveReleasesSuccess,
receiveReleasesError,
} from '~/releases/store/actions';
import state from '~/releases/store/state';
import * as types from '~/releases/store/mutation_types';
import api from '~/api';
import testAction from 'spec/helpers/vuex_action_helper';
import { releases } from '../mock_data';
describe('Releases State actions', () => {
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe('requestReleases', () => {
it('should commit REQUEST_RELEASES mutation', done => {
testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done);
});
});
describe('fetchReleases', () => {
describe('success', () => {
it('dispatches requestReleases and receiveReleasesSuccess ', done => {
spyOn(api, 'releases').and.returnValue(Promise.resolve({ data: releases }));
testAction(
fetchReleases,
releases,
mockedState,
[],
[
{
type: 'requestReleases',
},
{
payload: releases,
type: 'receiveReleasesSuccess',
},
],
done,
);
});
});
describe('error', () => {
it('dispatches requestReleases and receiveReleasesError ', done => {
spyOn(api, 'releases').and.returnValue(Promise.reject());
testAction(
fetchReleases,
null,
mockedState,
[],
[
{
type: 'requestReleases',
},
{
type: 'receiveReleasesError',
},
],
done,
);
});
});
});
describe('receiveReleasesSuccess', () => {
it('should commit RECEIVE_RELEASES_SUCCESS mutation', done => {
testAction(
receiveReleasesSuccess,
releases,
mockedState,
[{ type: types.RECEIVE_RELEASES_SUCCESS, payload: releases }],
[],
done,
);
});
});
describe('receiveReleasesError', () => {
it('should commit RECEIVE_RELEASES_ERROR mutation', done => {
testAction(
receiveReleasesError,
null,
mockedState,
[{ type: types.RECEIVE_RELEASES_ERROR }],
[],
done,
);
});
});
});

View File

@ -0,0 +1,6 @@
import state from '~/releases/store/state';
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState(state());
};

View File

@ -0,0 +1,47 @@
import state from '~/releases/store/state';
import mutations from '~/releases/store/mutations';
import * as types from '~/releases/store/mutation_types';
import { releases } from '../mock_data';
describe('Releases Store Mutations', () => {
let stateCopy;
beforeEach(() => {
stateCopy = state();
});
describe('REQUEST_RELEASES', () => {
it('sets isLoading to true', () => {
mutations[types.REQUEST_RELEASES](stateCopy);
expect(stateCopy.isLoading).toEqual(true);
});
});
describe('RECEIVE_RELEASES_SUCCESS', () => {
beforeEach(() => {
mutations[types.RECEIVE_RELEASES_SUCCESS](stateCopy, releases);
});
it('sets is loading to false', () => {
expect(stateCopy.isLoading).toEqual(false);
});
it('sets hasError to false', () => {
expect(stateCopy.hasError).toEqual(false);
});
it('sets data', () => {
expect(stateCopy.releases).toEqual(releases);
});
});
describe('RECEIVE_RELEASES_ERROR', () => {
it('resets data', () => {
mutations[types.RECEIVE_RELEASES_ERROR](stateCopy);
expect(stateCopy.isLoading).toEqual(false);
expect(stateCopy.releases).toEqual([]);
});
});
});