Commit bec6504a authored by Phil Hughes's avatar Phil Hughes

Merge branch '41766-vuex-store' into 'master'

Adds Vuex Store for the releases page

See merge request gitlab-org/gitlab-ce!23796
parents 8bcd342a 5c9aec7c
......@@ -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) {
......
import initReleases from '~/releases';
document.addEventListener('DOMContentLoaded', initReleases);
<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>
......@@ -17,66 +17,36 @@ export default {
},
mixins: [timeagoMixin],
props: {
name: {
type: String,
required: true,
},
tag: {
type: String,
required: true,
},
commit: {
type: Object,
required: true,
},
description: {
type: String,
required: false,
default: '',
},
author: {
release: {
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>
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,
},
});
},
});
};
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 () => {};
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(),
});
export const REQUEST_RELEASES = 'REQUEST_RELEASES';
export const RECEIVE_RELEASES_SUCCESS = 'RECEIVE_RELEASES_SUCCESS';
export const RECEIVE_RELEASES_ERROR = 'RECEIVE_RELEASES_ERROR';
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;
},
};
export default () => ({
isLoading: false,
hasError: false,
releases: [],
});
- @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') } }
---
title: Creates frontend app for releases
merge_request: 23796
author:
type: added
# 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)
......@@ -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 ""
......
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);
});
});
});
......@@ -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(() => {
......
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,
},
],
},
},
];
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,
);
});
});
});
import state from '~/releases/store/state';
// eslint-disable-next-line import/prefer-default-export
export const resetStore = store => {
store.replaceState(state());
};
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([]);
});
});
});
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