Commit 813a7303 authored by Filipa Lacerda's avatar Filipa Lacerda

Creates frontend app for releases

Adds Vuex store and main Vue app for releases
parent 853a365c
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: {
endpoint: {
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;
},
shouldRenderSuccessState() {
return this.releases.length && !this.isLoading && !this.hasError;
},
},
created() {
this.setEndpoint(this.endpoint);
this.fetchReleases();
},
methods: {
...mapActions(['setEndpoint', 'fetchReleases']),
},
};
</script>
<template>
<div class="prepend-top-default">
<gl-loading-icon
v-if="isLoading"
:size="2"
class="js-loading qa-loading-animation 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"
:name="release.name"
:tag="release.tag_name"
:commit="release.commit"
:description="release.description_html"
:author="release.commit.author"
:created-at="release.created_at"
:assets-count="release.assets.count"
:sources="release.assets.sources"
:links="release.assets.links"
: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>
......@@ -36,7 +36,8 @@ export default {
},
author: {
type: Object,
required: true,
required: false,
default: null,
},
createdAt: {
type: String,
......@@ -94,7 +95,7 @@ export default {
<span v-gl-tooltip.bottom :title="tooltipTitle(createdAt)">{{ releasedTimeAgo }}</span>
</div>
<div class="d-flex">
<div v-if="author" class="d-flex">
by
<user-avatar-link
class="prepend-left-4"
......@@ -114,7 +115,8 @@ export default {
<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">
<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>
......
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 axios from '~/lib/utils/axios_utils';
import createFlash from '~/flash';
import { __ } from '~/locale';
/**
* Commits a mutation to store the main endpoint.
*
* @param {String} endpoint
*/
export const setEndpoint = ({ commit }, endpoint) => commit(types.SET_ENDPOINT, endpoint);
/**
* 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
*/
export const fetchReleases = ({ state, dispatch }) => {
dispatch('requestReleases');
axios
.get(state.endpoint)
.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 SET_ENDPOINT = 'SET_ENDPOINT';
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 the main endpoint
* @param {Object} state
* @param {String} endpoint
*/
[types.SET_ENDPOINT](state, endpoint) {
state.endpoint = endpoint;
},
/**
* 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 () => ({
endpoint: null,
isLoading: false,
hasError: false,
releases: [],
});
......@@ -2,4 +2,4 @@
- page_title _('Releases')
%div{ 'class' => container_class }
#js-releases-page
#js-releases-page{ data: { endpoint: project_releases_path(@project, format: :json), illustration_path: image_path('illustrations/releases.svg'), documentation_path: help_page_path('todo') } }
---
title: Creates frontend app for releases
merge_request: 23796
author:
type: added
......@@ -647,6 +647,9 @@ msgstr ""
msgid "An error occurred adding a new draft."
msgstr ""
msgid "An error occured while fetching the releases. Please try again."
msgstr ""
msgid "An error occurred creating the new branch."
msgstr ""
......@@ -3855,6 +3858,12 @@ msgstr ""
msgid "Geo Nodes"
msgstr ""
msgid "Getting started with releases"
msgstr ""
msgid "Git"
msgstr ""
msgid "Geo allows you to replicate your GitLab instance to other geographical locations."
msgstr ""
......@@ -6078,6 +6087,9 @@ msgstr ""
msgid "Open"
msgstr ""
msgid "Open Documentation"
msgstr ""
msgid "Open in Xcode"
msgstr ""
......@@ -7193,6 +7205,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 MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import app from '~/releases/components/app.vue';
import createStore from '~/releases/store';
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;
let mock;
const props = {
endpoint: 'endpoint.json',
documentationLink: 'help/releases',
illustrationPath: 'illustration/path',
};
beforeEach(() => {
mock = new MockAdapter(axios);
store = createStore();
});
afterEach(() => {
resetStore(store);
vm.$destroy();
mock.restore();
});
describe('while loading', () => {
beforeEach(() => {
mock.onGet(props.endpoint).replyOnce(200, [], {});
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(() => {
mock.onGet(props.endpoint).reply(200, 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(() => {
mock.onGet(props.endpoint).reply(200, []);
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);
});
});
});
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 MockAdapter from 'axios-mock-adapter';
import axios from '~/lib/utils/axios_utils';
import {
setEndpoint,
requestReleases,
fetchReleases,
receiveReleasesSuccess,
receiveReleasesError,
} from '~/releases/store/actions';
import state from '~/releases/store/state';
import * as types from '~/releases/store/mutation_types';
import testAction from 'spec/helpers/vuex_action_helper';
import { releases } from '../mock_data';
describe('Releases State actions', () => {
let mockedState;
beforeEach(() => {
mockedState = state();
});
describe('setEndpoint', () => {
it('should commit SET_ENDPOINT mutation', done => {
testAction(
setEndpoint,
'endpoint.json',
mockedState,
[{ type: types.SET_ENDPOINT, payload: 'endpoint.json' }],
[],
done,
);
});
});
describe('requestReleases', () => {
it('should commit REQUEST_RELEASES mutation', done => {
testAction(requestReleases, null, mockedState, [{ type: types.REQUEST_RELEASES }], [], done);
});
});
describe('fetchReleases', () => {
let mock;
beforeEach(() => {
mockedState.endpoint = 'endpoint.json';
mock = new MockAdapter(axios);
});
afterEach(() => {
mock.restore();
});
describe('success', () => {
it('dispatches requestReleases and receiveReleasesSuccess ', done => {
mock.onGet('endpoint.json').replyOnce(200, releases);
testAction(
fetchReleases,
releases,
mockedState,
[],
[
{
type: 'requestReleases',
},
{
payload: releases,
type: 'receiveReleasesSuccess',
},
],
done,
);
});
});
describe('error', () => {
beforeEach(() => {
mock.onGet('endpoint.json').replyOnce(500);
});
it('dispatches requestReleases and receiveReleasesError ', done => {
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('SET_ENDPOINT', () => {
it('should set endpoint', () => {
mutations[types.SET_ENDPOINT](stateCopy, 'endpoint.json');
expect(stateCopy.endpoint).toEqual('endpoint.json');
});
});
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