Commit 14f183de authored by Douwe Maan's avatar Douwe Maan

Merge branch 'winh-41174-projects-groups-badges-ui' into 'master'

Projects and groups badges settings UI

Closes #41174 and #41176

See merge request gitlab-org/gitlab-ce!17114
parents dd552d06 31dd86b6
<script>
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import Tooltip from '~/vue_shared/directives/tooltip';
export default {
name: 'Badge',
components: {
Icon,
LoadingIcon,
Tooltip,
},
directives: {
Tooltip,
},
props: {
imageUrl: {
type: String,
required: true,
},
linkUrl: {
type: String,
required: true,
},
},
data() {
return {
hasError: false,
isLoading: true,
numRetries: 0,
};
},
computed: {
imageUrlWithRetries() {
if (this.numRetries === 0) {
return this.imageUrl;
}
return `${this.imageUrl}#retries=${this.numRetries}`;
},
},
watch: {
imageUrl() {
this.hasError = false;
this.isLoading = true;
this.numRetries = 0;
},
},
methods: {
onError() {
this.isLoading = false;
this.hasError = true;
},
onLoad() {
this.isLoading = false;
},
reloadImage() {
this.hasError = false;
this.isLoading = true;
this.numRetries += 1;
},
},
};
</script>
<template>
<div>
<a
v-show="!isLoading && !hasError"
:href="linkUrl"
target="_blank"
rel="noopener noreferrer"
>
<img
class="project-badge"
:src="imageUrlWithRetries"
@load="onLoad"
@error="onError"
aria-hidden="true"
/>
</a>
<loading-icon
v-show="isLoading"
:inline="true"
/>
<div
v-show="hasError"
class="btn-group"
>
<div class="btn btn-default btn-xs disabled">
<icon
class="prepend-left-8 append-right-8"
name="doc_image"
:size="16"
aria-hidden="true"
/>
</div>
<div
class="btn btn-default btn-xs disabled"
>
<span class="prepend-left-8 append-right-8">{{ s__('Badges|No badge image') }}</span>
</div>
</div>
<button
v-show="hasError"
class="btn btn-transparent btn-xs text-primary"
type="button"
v-tooltip
:title="s__('Badges|Reload badge image')"
@click="reloadImage"
>
<icon
name="retry"
:size="16"
/>
</button>
</div>
</template>
<script>
import _ from 'underscore';
import { mapActions, mapState } from 'vuex';
import createFlash from '~/flash';
import { s__, sprintf } from '~/locale';
import LoadingButton from '~/vue_shared/components/loading_button.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import createEmptyBadge from '../empty_badge';
import Badge from './badge.vue';
const badgePreviewDelayInMilliseconds = 1500;
export default {
name: 'BadgeForm',
components: {
Badge,
LoadingButton,
LoadingIcon,
},
props: {
isEditing: {
type: Boolean,
required: true,
},
},
computed: {
...mapState([
'badgeInAddForm',
'badgeInEditForm',
'docsUrl',
'isRendering',
'isSaving',
'renderedBadge',
]),
badge() {
if (this.isEditing) {
return this.badgeInEditForm;
}
return this.badgeInAddForm;
},
canSubmit() {
return (
this.badge !== null &&
this.badge.imageUrl &&
this.badge.imageUrl.trim() !== '' &&
this.badge.linkUrl &&
this.badge.linkUrl.trim() !== '' &&
!this.isSaving
);
},
helpText() {
const placeholders = ['project_path', 'project_id', 'default_branch', 'commit_sha']
.map(placeholder => `<code>%{${placeholder}}</code>`)
.join(', ');
return sprintf(
s__('Badges|The %{docsLinkStart}variables%{docsLinkEnd} GitLab supports: %{placeholders}'),
{
docsLinkEnd: '</a>',
docsLinkStart: `<a href="${_.escape(this.docsUrl)}">`,
placeholders,
},
false,
);
},
renderedImageUrl() {
return this.renderedBadge ? this.renderedBadge.renderedImageUrl : '';
},
renderedLinkUrl() {
return this.renderedBadge ? this.renderedBadge.renderedLinkUrl : '';
},
imageUrl: {
get() {
return this.badge ? this.badge.imageUrl : '';
},
set(imageUrl) {
const badge = this.badge || createEmptyBadge();
this.updateBadgeInForm({
...badge,
imageUrl,
});
},
},
linkUrl: {
get() {
return this.badge ? this.badge.linkUrl : '';
},
set(linkUrl) {
const badge = this.badge || createEmptyBadge();
this.updateBadgeInForm({
...badge,
linkUrl,
});
},
},
submitButtonLabel() {
if (this.isEditing) {
return s__('Badges|Save changes');
}
return s__('Badges|Add badge');
},
},
methods: {
...mapActions(['addBadge', 'renderBadge', 'saveBadge', 'stopEditing', 'updateBadgeInForm']),
debouncedPreview: _.debounce(function preview() {
this.renderBadge();
}, badgePreviewDelayInMilliseconds),
onCancel() {
this.stopEditing();
},
onSubmit() {
if (!this.canSubmit) {
return Promise.resolve();
}
if (this.isEditing) {
return this.saveBadge()
.then(() => {
createFlash(s__('Badges|The badge was saved.'), 'notice');
})
.catch(error => {
createFlash(
s__('Badges|Saving the badge failed, please check the entered URLs and try again.'),
);
throw error;
});
}
return this.addBadge()
.then(() => {
createFlash(s__('Badges|A new badge was added.'), 'notice');
})
.catch(error => {
createFlash(
s__('Badges|Adding the badge failed, please check the entered URLs and try again.'),
);
throw error;
});
},
},
badgeImageUrlPlaceholder:
'https://example.gitlab.com/%{project_path}/badges/%{default_branch}/<badge>.svg',
badgeLinkUrlPlaceholder: 'https://example.gitlab.com/%{project_path}',
};
</script>
<template>
<form
class="prepend-top-default append-bottom-default"
@submit.prevent.stop="onSubmit"
>
<div class="form-group">
<label for="badge-link-url">{{ s__('Badges|Link') }}</label>
<input
id="badge-link-url"
type="text"
class="form-control"
v-model="linkUrl"
:placeholder="$options.badgeLinkUrlPlaceholder"
@input="debouncedPreview"
/>
<span
class="help-block"
v-html="helpText"
></span>
</div>
<div class="form-group">
<label for="badge-image-url">{{ s__('Badges|Badge image URL') }}</label>
<input
id="badge-image-url"
type="text"
class="form-control"
v-model="imageUrl"
:placeholder="$options.badgeImageUrlPlaceholder"
@input="debouncedPreview"
/>
<span
class="help-block"
v-html="helpText"
></span>
</div>
<div class="form-group">
<label for="badge-preview">{{ s__('Badges|Badge image preview') }}</label>
<badge
id="badge-preview"
v-show="renderedBadge && !isRendering"
:image-url="renderedImageUrl"
:link-url="renderedLinkUrl"
/>
<p v-show="isRendering">
<loading-icon
:inline="true"
/>
</p>
<p
v-show="!renderedBadge && !isRendering"
class="disabled-content"
>{{ s__('Badges|No image to preview') }}</p>
</div>
<div class="row-content-block">
<loading-button
type="submit"
container-class="btn btn-success"
:disabled="!canSubmit"
:loading="isSaving"
:label="submitButtonLabel"
/>
<button
class="btn btn-cancel"
type="button"
v-if="isEditing"
@click="onCancel"
>{{ __('Cancel') }}</button>
</div>
</form>
</template>
<script>
import { mapState } from 'vuex';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import BadgeListRow from './badge_list_row.vue';
import { GROUP_BADGE } from '../constants';
export default {
name: 'BadgeList',
components: {
BadgeListRow,
LoadingIcon,
},
computed: {
...mapState(['badges', 'isLoading', 'kind']),
hasNoBadges() {
return !this.isLoading && (!this.badges || !this.badges.length);
},
isGroupBadge() {
return this.kind === GROUP_BADGE;
},
},
};
</script>
<template>
<div class="panel panel-default">
<div class="panel-heading">
{{ s__('Badges|Your badges') }}
<span
v-show="!isLoading"
class="badge"
>{{ badges.length }}</span>
</div>
<loading-icon
v-show="isLoading"
class="panel-body"
size="2"
/>
<div
v-if="hasNoBadges"
class="panel-body"
>
<span v-if="isGroupBadge">{{ s__('Badges|This group has no badges') }}</span>
<span v-else>{{ s__('Badges|This project has no badges') }}</span>
</div>
<div
v-else
class="panel-body"
>
<badge-list-row
v-for="badge in badges"
:key="badge.id"
:badge="badge"
/>
</div>
</div>
</template>
<script>
import { mapActions, mapState } from 'vuex';
import { s__ } from '~/locale';
import Icon from '~/vue_shared/components/icon.vue';
import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import { PROJECT_BADGE } from '../constants';
import Badge from './badge.vue';
export default {
name: 'BadgeListRow',
components: {
Badge,
Icon,
LoadingIcon,
},
props: {
badge: {
type: Object,
required: true,
},
},
computed: {
...mapState(['kind']),
badgeKindText() {
if (this.badge.kind === PROJECT_BADGE) {
return s__('Badges|Project Badge');
}
return s__('Badges|Group Badge');
},
canEditBadge() {
return this.badge.kind === this.kind;
},
},
methods: {
...mapActions(['editBadge', 'updateBadgeInModal']),
},
};
</script>
<template>
<div class="gl-responsive-table-row-layout gl-responsive-table-row">
<badge
class="table-section section-30"
:image-url="badge.renderedImageUrl"
:link-url="badge.renderedLinkUrl"
/>
<span class="table-section section-50 str-truncated">{{ badge.linkUrl }}</span>
<div class="table-section section-10">
<span class="badge">{{ badgeKindText }}</span>
</div>
<div class="table-section section-10 table-button-footer">
<div
v-if="canEditBadge"
class="table-action-buttons">
<button
class="btn btn-default append-right-8"
type="button"
:disabled="badge.isDeleting"
@click="editBadge(badge)"
>
<icon
name="pencil"
:size="16"
:aria-label="__('Edit')"
/>
</button>
<button
class="btn btn-danger"
type="button"
data-toggle="modal"
data-target="#delete-badge-modal"
:disabled="badge.isDeleting"
@click="updateBadgeInModal(badge)"
>
<icon
name="remove"
:size="16"
:aria-label="__('Delete')"
/>
</button>
<loading-icon
v-show="badge.isDeleting"
:inline="true"
/>
</div>
</div>
</div>
</template>
<script>
import { mapState, mapActions } from 'vuex';
import createFlash from '~/flash';
import { s__ } from '~/locale';
import GlModal from '~/vue_shared/components/gl_modal.vue';
import Badge from './badge.vue';
import BadgeForm from './badge_form.vue';
import BadgeList from './badge_list.vue';
export default {
name: 'BadgeSettings',
components: {
Badge,
BadgeForm,
BadgeList,
GlModal,
},
computed: {
...mapState(['badgeInModal', 'isEditing']),
deleteModalText() {
return s__(
'Badges|You are going to delete this badge. Deleted badges <strong>cannot</strong> be restored.',
);
},
},
methods: {
...mapActions(['deleteBadge']),
onSubmitModal() {
this.deleteBadge(this.badgeInModal)
.then(() => {
createFlash(s__('Badges|The badge was deleted.'), 'notice');
})
.catch(error => {
createFlash(s__('Badges|Deleting the badge failed, please try again.'));
throw error;
});
},
},
};
</script>
<template>
<div class="badge-settings">
<gl-modal
id="delete-badge-modal"
:header-title-text="s__('Badges|Delete badge?')"
footer-primary-button-variant="danger"
:footer-primary-button-text="s__('Badges|Delete badge')"
@submit="onSubmitModal">
<div class="well">
<badge
:image-url="badgeInModal ? badgeInModal.renderedImageUrl : ''"
:link-url="badgeInModal ? badgeInModal.renderedLinkUrl : ''"
/>
</div>
<p v-html="deleteModalText"></p>
</gl-modal>
<badge-form
v-show="isEditing"
:is-editing="true"
/>
<badge-form
v-show="!isEditing"
:is-editing="false"
/>
<badge-list v-show="!isEditing" />
</div>
</template>
export const GROUP_BADGE = 'group';
export const PROJECT_BADGE = 'project';
export default () => ({
imageUrl: '',
isDeleting: false,
linkUrl: '',
renderedImageUrl: '',
renderedLinkUrl: '',
});
import axios from '~/lib/utils/axios_utils';
import types from './mutation_types';
export const transformBackendBadge = badge => ({
id: badge.id,
imageUrl: badge.image_url,
kind: badge.kind,
linkUrl: badge.link_url,
renderedImageUrl: badge.rendered_image_url,
renderedLinkUrl: badge.rendered_link_url,
isDeleting: false,
});
export default {
requestNewBadge({ commit }) {
commit(types.REQUEST_NEW_BADGE);
},
receiveNewBadge({ commit }, newBadge) {
commit(types.RECEIVE_NEW_BADGE, newBadge);
},
receiveNewBadgeError({ commit }) {
commit(types.RECEIVE_NEW_BADGE_ERROR);
},
addBadge({ dispatch, state }) {
const newBadge = state.badgeInAddForm;
const endpoint = state.apiEndpointUrl;
dispatch('requestNewBadge');
return axios
.post(endpoint, {
image_url: newBadge.imageUrl,
link_url: newBadge.linkUrl,
})
.catch(error => {
dispatch('receiveNewBadgeError');
throw error;
})
.then(res => {
dispatch('receiveNewBadge', transformBackendBadge(res.data));
});
},
requestDeleteBadge({ commit }, badgeId) {
commit(types.REQUEST_DELETE_BADGE, badgeId);
},
receiveDeleteBadge({ commit }, badgeId) {
commit(types.RECEIVE_DELETE_BADGE, badgeId);
},
receiveDeleteBadgeError({ commit }, badgeId) {
commit(types.RECEIVE_DELETE_BADGE_ERROR, badgeId);
},
deleteBadge({ dispatch, state }, badge) {
const badgeId = badge.id;
dispatch('requestDeleteBadge', badgeId);
const endpoint = `${state.apiEndpointUrl}/${badgeId}`;
return axios
.delete(endpoint)
.catch(error => {
dispatch('receiveDeleteBadgeError', badgeId);
throw error;
})
.then(() => {
dispatch('receiveDeleteBadge', badgeId);
});
},
editBadge({ commit }, badge) {
commit(types.START_EDITING, badge);
},
requestLoadBadges({ commit }, data) {
commit(types.REQUEST_LOAD_BADGES, data);
},
receiveLoadBadges({ commit }, badges) {
commit(types.RECEIVE_LOAD_BADGES, badges);
},
receiveLoadBadgesError({ commit }) {
commit(types.RECEIVE_LOAD_BADGES_ERROR);
},
loadBadges({ dispatch, state }, data) {
dispatch('requestLoadBadges', data);
const endpoint = state.apiEndpointUrl;
return axios
.get(endpoint)
.catch(error => {
dispatch('receiveLoadBadgesError');
throw error;
})
.then(res => {
dispatch('receiveLoadBadges', res.data.map(transformBackendBadge));
});
},
requestRenderedBadge({ commit }) {
commit(types.REQUEST_RENDERED_BADGE);
},
receiveRenderedBadge({ commit }, renderedBadge) {
commit(types.RECEIVE_RENDERED_BADGE, renderedBadge);
},
receiveRenderedBadgeError({ commit }) {
commit(types.RECEIVE_RENDERED_BADGE_ERROR);
},
renderBadge({ dispatch, state }) {
const badge = state.isEditing ? state.badgeInEditForm : state.badgeInAddForm;
const { linkUrl, imageUrl } = badge;
if (!linkUrl || linkUrl.trim() === '' || !imageUrl || imageUrl.trim() === '') {
return Promise.resolve(badge);
}
dispatch('requestRenderedBadge');
const parameters = [
`link_url=${encodeURIComponent(linkUrl)}`,
`image_url=${encodeURIComponent(imageUrl)}`,
].join('&');
const renderEndpoint = `${state.apiEndpointUrl}/render?${parameters}`;
return axios
.get(renderEndpoint)
.catch(error => {
dispatch('receiveRenderedBadgeError');
throw error;
})
.then(res => {
dispatch('receiveRenderedBadge', transformBackendBadge(res.data));
});
},
requestUpdatedBadge({ commit }) {
commit(types.REQUEST_UPDATED_BADGE);
},
receiveUpdatedBadge({ commit }, updatedBadge) {
commit(types.RECEIVE_UPDATED_BADGE, updatedBadge);
},
receiveUpdatedBadgeError({ commit }) {
commit(types.RECEIVE_UPDATED_BADGE_ERROR);
},
saveBadge({ dispatch, state }) {
const badge = state.badgeInEditForm;
const endpoint = `${state.apiEndpointUrl}/${badge.id}`;
dispatch('requestUpdatedBadge');
return axios
.put(endpoint, {
image_url: badge.imageUrl,
link_url: badge.linkUrl,
})
.catch(error => {
dispatch('receiveUpdatedBadgeError');
throw error;
})
.then(res => {
dispatch('receiveUpdatedBadge', transformBackendBadge(res.data));
});
},
stopEditing({ commit }) {
commit(types.STOP_EDITING);
},
updateBadgeInForm({ commit }, badge) {
commit(types.UPDATE_BADGE_IN_FORM, badge);
},
updateBadgeInModal({ commit }, badge) {
commit(types.UPDATE_BADGE_IN_MODAL, badge);
},
};
import Vue from 'vue';
import Vuex from 'vuex';
import createState from './state';
import actions from './actions';
import mutations from './mutations';
Vue.use(Vuex);
export default new Vuex.Store({
state: createState(),
actions,
mutations,
});
export default {
RECEIVE_DELETE_BADGE: 'RECEIVE_DELETE_BADGE',
RECEIVE_DELETE_BADGE_ERROR: 'RECEIVE_DELETE_BADGE_ERROR',
RECEIVE_LOAD_BADGES: 'RECEIVE_LOAD_BADGES',
RECEIVE_LOAD_BADGES_ERROR: 'RECEIVE_LOAD_BADGES_ERROR',
RECEIVE_NEW_BADGE: 'RECEIVE_NEW_BADGE',
RECEIVE_NEW_BADGE_ERROR: 'RECEIVE_NEW_BADGE_ERROR',
RECEIVE_RENDERED_BADGE: 'RECEIVE_RENDERED_BADGE',
RECEIVE_RENDERED_BADGE_ERROR: 'RECEIVE_RENDERED_BADGE_ERROR',
RECEIVE_UPDATED_BADGE: 'RECEIVE_UPDATED_BADGE',
RECEIVE_UPDATED_BADGE_ERROR: 'RECEIVE_UPDATED_BADGE_ERROR',
REQUEST_DELETE_BADGE: 'REQUEST_DELETE_BADGE',
REQUEST_LOAD_BADGES: 'REQUEST_LOAD_BADGES',
REQUEST_NEW_BADGE: 'REQUEST_NEW_BADGE',
REQUEST_RENDERED_BADGE: 'REQUEST_RENDERED_BADGE',
REQUEST_UPDATED_BADGE: 'REQUEST_UPDATED_BADGE',
START_EDITING: 'START_EDITING',
STOP_EDITING: 'STOP_EDITING',
UPDATE_BADGE_IN_FORM: 'UPDATE_BADGE_IN_FORM',
UPDATE_BADGE_IN_MODAL: 'UPDATE_BADGE_IN_MODAL',
};
import types from './mutation_types';
import { PROJECT_BADGE } from '../constants';
const reorderBadges = badges =>
badges.sort((a, b) => {
if (a.kind !== b.kind) {
return a.kind === PROJECT_BADGE ? 1 : -1;
}
return a.id - b.id;
});
export default {
[types.RECEIVE_NEW_BADGE](state, newBadge) {
Object.assign(state, {
badgeInAddForm: null,
badges: reorderBadges(state.badges.concat(newBadge)),
isSaving: false,
renderedBadge: null,
});
},
[types.RECEIVE_NEW_BADGE_ERROR](state) {
Object.assign(state, {
isSaving: false,
});
},
[types.REQUEST_NEW_BADGE](state) {
Object.assign(state, {
isSaving: true,
});
},
[types.RECEIVE_UPDATED_BADGE](state, updatedBadge) {
const badges = state.badges.map(badge => {
if (badge.id === updatedBadge.id) {
return updatedBadge;
}
return badge;
});
Object.assign(state, {
badgeInEditForm: null,
badges,
isEditing: false,
isSaving: false,
renderedBadge: null,
});
},
[types.RECEIVE_UPDATED_BADGE_ERROR](state) {
Object.assign(state, {
isSaving: false,
});
},
[types.REQUEST_UPDATED_BADGE](state) {
Object.assign(state, {
isSaving: true,
});
},
[types.RECEIVE_LOAD_BADGES](state, badges) {
Object.assign(state, {
badges: reorderBadges(badges),
isLoading: false,
});
},
[types.RECEIVE_LOAD_BADGES_ERROR](state) {
Object.assign(state, {
isLoading: false,
});
},
[types.REQUEST_LOAD_BADGES](state, data) {
Object.assign(state, {
kind: data.kind, // project or group
apiEndpointUrl: data.apiEndpointUrl,
docsUrl: data.docsUrl,
isLoading: true,
});
},
[types.RECEIVE_DELETE_BADGE](state, badgeId) {
const badges = state.badges.filter(badge => badge.id !== badgeId);
Object.assign(state, {
badges,
});
},
[types.RECEIVE_DELETE_BADGE_ERROR](state, badgeId) {
const badges = state.badges.map(badge => {
if (badge.id === badgeId) {
return {
...badge,
isDeleting: false,
};
}
return badge;
});
Object.assign(state, {
badges,
});
},
[types.REQUEST_DELETE_BADGE](state, badgeId) {
const badges = state.badges.map(badge => {
if (badge.id === badgeId) {
return {
...badge,
isDeleting: true,
};
}
return badge;
});
Object.assign(state, {
badges,
});
},
[types.RECEIVE_RENDERED_BADGE](state, renderedBadge) {
Object.assign(state, { isRendering: false, renderedBadge });
},
[types.RECEIVE_RENDERED_BADGE_ERROR](state) {
Object.assign(state, { isRendering: false });
},
[types.REQUEST_RENDERED_BADGE](state) {
Object.assign(state, { isRendering: true });
},
[types.START_EDITING](state, badge) {
Object.assign(state, {
badgeInEditForm: { ...badge },
isEditing: true,
renderedBadge: { ...badge },
});
},
[types.STOP_EDITING](state) {
Object.assign(state, {
badgeInEditForm: null,
isEditing: false,
renderedBadge: null,
});
},
[types.UPDATE_BADGE_IN_FORM](state, badge) {
if (state.isEditing) {
Object.assign(state, {
badgeInEditForm: badge,
});
} else {
Object.assign(state, {
badgeInAddForm: badge,
});
}
},
[types.UPDATE_BADGE_IN_MODAL](state, badge) {
Object.assign(state, {
badgeInModal: badge,
});
},
};
export default () => ({
apiEndpointUrl: null,
badgeInAddForm: null,
badgeInEditForm: null,
badgeInModal: null,
badges: [],
docsUrl: null,
renderedBadge: null,
isEditing: false,
isLoading: false,
isRendering: false,
isSaving: false,
});
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { GROUP_BADGE } from '~/badges/constants';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
mountBadgeSettings(GROUP_BADGE);
});
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import { PROJECT_BADGE } from '~/badges/constants';
import mountBadgeSettings from '~/pages/shared/mount_badge_settings';
Vue.use(Translate);
document.addEventListener('DOMContentLoaded', () => {
mountBadgeSettings(PROJECT_BADGE);
});
import Vue from 'vue';
import BadgeSettings from '~/badges/components/badge_settings.vue';
import store from '~/badges/store';
export default kind => {
const badgeSettingsElement = document.getElementById('badge-settings');
store.dispatch('loadBadges', {
kind,
apiEndpointUrl: badgeSettingsElement.dataset.apiEndpointUrl,
docsUrl: badgeSettingsElement.dataset.docsUrl,
});
return new Vue({
el: badgeSettingsElement,
store,
components: {
BadgeSettings,
},
render(createElement) {
return createElement(BadgeSettings);
},
});
};
...@@ -39,7 +39,7 @@ ...@@ -39,7 +39,7 @@
.table-section { .table-section {
white-space: nowrap; white-space: nowrap;
$section-widths: 10 15 20 25 30 40 100; $section-widths: 10 15 20 25 30 40 50 100;
@each $width in $section-widths { @each $width in $section-widths {
&.section-#{$width} { &.section-#{$width} {
flex: 0 0 #{$width + '%'}; flex: 0 0 #{$width + '%'};
......
...@@ -1143,3 +1143,11 @@ pre.light-well { ...@@ -1143,3 +1143,11 @@ pre.light-well {
white-space: pre-wrap; white-space: pre-wrap;
} }
} }
.project-badge {
opacity: 0.9;
&:hover {
opacity: 1;
}
}
module Groups
module Settings
class BadgesController < Groups::ApplicationController
include GrapeRouteHelpers::NamedRouteMatcher
before_action :authorize_admin_group!
def index
@badge_api_endpoint = api_v4_groups_badges_path(id: @group.id)
end
end
end
end
module Projects
module Settings
class BadgesController < Projects::ApplicationController
include GrapeRouteHelpers::NamedRouteMatcher
before_action :authorize_admin_project!
def index
@badge_api_endpoint = api_v4_projects_badges_path(id: @project.id)
end
end
end
end
module GroupsHelper module GroupsHelper
def group_nav_link_paths def group_nav_link_paths
%w[groups#projects groups#edit ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index] %w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end end
def group_sidebar_links def group_sidebar_links
......
- breadcrumb_title _('Project Badges')
- page_title _('Project Badges')
= render 'shared/badges/badge_settings'
...@@ -112,7 +112,7 @@ ...@@ -112,7 +112,7 @@
%span.nav-item-name %span.nav-item-name
Settings Settings
%ul.sidebar-sub-level-items %ul.sidebar-sub-level-items
= nav_link(path: %w[groups#projects groups#edit ci_cd#show], html_options: { class: "fly-out-top-item" } ) do = nav_link(path: %w[groups#projects groups#edit badges#index ci_cd#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_group_path(@group) do = link_to edit_group_path(@group) do
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
#{ _('Settings') } #{ _('Settings') }
...@@ -122,6 +122,12 @@ ...@@ -122,6 +122,12 @@
%span %span
General General
= nav_link(controller: :badges) do
= link_to group_settings_badges_path(@group), title: _('Project Badges') do
%span
= _('Project Badges')
= nav_link(path: 'groups#projects') do = nav_link(path: 'groups#projects') do
= link_to projects_group_path(@group), title: 'Projects' do = link_to projects_group_path(@group), title: 'Projects' do
%span %span
......
...@@ -258,7 +258,7 @@ ...@@ -258,7 +258,7 @@
#{ _('Snippets') } #{ _('Snippets') }
- if project_nav_tab? :settings - if project_nav_tab? :settings
= nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show]) do = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show]) do
= link_to edit_project_path(@project), class: 'shortcuts-tree' do = link_to edit_project_path(@project), class: 'shortcuts-tree' do
.nav-icon-container .nav-icon-container
= sprite_icon('settings') = sprite_icon('settings')
...@@ -268,7 +268,7 @@ ...@@ -268,7 +268,7 @@
%ul.sidebar-sub-level-items %ul.sidebar-sub-level-items
- can_edit = can?(current_user, :admin_project, @project) - can_edit = can?(current_user, :admin_project, @project)
- if can_edit - if can_edit
= nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show pages#show], html_options: { class: "fly-out-top-item" } ) do = nav_link(path: %w[projects#edit project_members#index integrations#show services#edit repository#show ci_cd#show badges#index pages#show], html_options: { class: "fly-out-top-item" } ) do
= link_to edit_project_path(@project) do = link_to edit_project_path(@project) do
%strong.fly-out-top-item-name %strong.fly-out-top-item-name
#{ _('Settings') } #{ _('Settings') }
...@@ -281,6 +281,11 @@ ...@@ -281,6 +281,11 @@
= link_to project_project_members_path(@project), title: 'Members' do = link_to project_project_members_path(@project), title: 'Members' do
%span %span
Members Members
- if can_edit
= nav_link(controller: :badges) do
= link_to project_settings_badges_path(@project), title: _('Badges') do
%span
= _('Badges')
- if can_edit - if can_edit
= nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do = nav_link(controller: [:integrations, :services, :hooks, :hook_logs]) do
= link_to project_settings_integrations_path(@project), title: 'Integrations' do = link_to project_settings_integrations_path(@project), title: 'Integrations' do
......
...@@ -23,11 +23,14 @@ ...@@ -23,11 +23,14 @@
- deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)') - deleted_message = s_('ForkedFromProjectPath|Forked from %{project_name} (deleted)')
= deleted_message % { project_name: fork_source_name(@project) } = deleted_message % { project_name: fork_source_name(@project) }
.project-badges .project-badges.prepend-top-default.append-bottom-default
- @project.badges.each do |badge| - @project.badges.each do |badge|
- badge_link_url = badge.rendered_link_url(@project) %a.append-right-8{ href: badge.rendered_link_url(@project),
%a{ href: badge_link_url, target: '_blank', rel: 'noopener noreferrer' } target: '_blank',
%img{ src: badge.rendered_image_url(@project), alt: badge_link_url } rel: 'noopener noreferrer' }>
%img.project-badge{ src: badge.rendered_image_url(@project),
'aria-hidden': true,
alt: '' }>
.project-repo-buttons .project-repo-buttons
.count-buttons .count-buttons
......
- breadcrumb_title _('Badges')
- page_title _('Badges')
= render 'shared/badges/badge_settings'
#badge-settings{ data: { api_endpoint_url: @badge_api_endpoint,
docs_url: help_page_path('user/project/badges')} }
.text-center.prepend-top-default
= icon('spinner spin 2x')
---
title: Projects and groups badges settings UI
merge_request: 17114
author:
type: added
...@@ -39,7 +39,7 @@ module.exports = function(config) { ...@@ -39,7 +39,7 @@ module.exports = function(config) {
frameworks: ['jasmine'], frameworks: ['jasmine'],
files: [ files: [
{ pattern: 'spec/javascripts/test_bundle.js', watched: false }, { pattern: 'spec/javascripts/test_bundle.js', watched: false },
{ pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw)', included: false }, { pattern: 'spec/javascripts/fixtures/**/*@(.json|.html|.html.raw|.png)', included: false },
], ],
preprocessors: { preprocessors: {
'spec/javascripts/**/*.js': ['webpack', 'sourcemap'], 'spec/javascripts/**/*.js': ['webpack', 'sourcemap'],
......
...@@ -24,6 +24,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do ...@@ -24,6 +24,7 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do constraints: { group_id: Gitlab::PathRegex.full_namespace_route_regex }) do
namespace :settings do namespace :settings do
resource :ci_cd, only: [:show], controller: 'ci_cd' resource :ci_cd, only: [:show], controller: 'ci_cd'
resources :badges, only: [:index]
end end
resource :variables, only: [:show, :update] resource :variables, only: [:show, :update]
......
...@@ -435,6 +435,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do ...@@ -435,6 +435,7 @@ constraints(::Constraints::ProjectUrlConstrainer.new) do
resource :repository, only: [:show], controller: :repository do resource :repository, only: [:show], controller: :repository do
post :create_deploy_token, path: 'deploy_token/create' post :create_deploy_token, path: 'deploy_token/create'
end end
resources :badges, only: [:index]
end end
# Since both wiki and repository routing contains wildcard characters # Since both wiki and repository routing contains wildcard characters
......
...@@ -127,6 +127,7 @@ module API ...@@ -127,6 +127,7 @@ module API
end end
destroy_conditionally!(badge) destroy_conditionally!(badge)
body false
end end
end end
end end
......
require 'spec_helper'
feature 'Group Badges' do
include WaitForRequests
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
let!(:badge_1) { create(:group_badge, group: group) }
let!(:badge_2) { create(:group_badge, group: group) }
before do
group.add_owner(user)
sign_in(user)
visit(group_settings_badges_path(group))
end
it 'shows a list of badges', :js do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
expect(rows[0]).to have_content badge_1.link_url
expect(rows[1]).to have_content badge_2.link_url
end
end
context 'adding a badge', :js do
it 'user can preview a badge' do
page.within '.badge-settings form' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
within '#badge-preview' do
expect(find('a')[:href]).to eq badge_link_url
expect(find('a img')[:src]).to eq badge_image_url
end
end
end
it do
page.within '.badge-settings' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
click_button 'Add badge'
wait_for_requests
within '.panel-body' do
expect(find('a')[:href]).to eq badge_link_url
expect(find('a img')[:src]).to eq badge_image_url
end
end
end
end
context 'editing a badge', :js do
it 'form is shown when clicking edit button in list' do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
rows[1].find('[aria-label="Edit"]').click
within 'form' do
expect(find('#badge-link-url').value).to eq badge_2.link_url
expect(find('#badge-image-url').value).to eq badge_2.image_url
end
end
end
it 'updates a badge when submitting the edit form' do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
rows[1].find('[aria-label="Edit"]').click
within 'form' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
click_button 'Save changes'
wait_for_requests
end
rows = all('.panel-body > div')
expect(rows.length).to eq 2
expect(rows[1]).to have_content badge_link_url
end
end
end
context 'deleting a badge', :js do
def click_delete_button(badge_row)
badge_row.find('[aria-label="Delete"]').click
end
it 'shows a modal when deleting a badge' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
click_delete_button(rows[1])
expect(find('.modal .modal-title')).to have_content 'Delete badge?'
end
it 'deletes a badge when confirming the modal' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
click_delete_button(rows[1])
find('.modal .btn-danger').click
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 1
expect(rows[0]).to have_content badge_1.link_url
end
end
end
require 'spec_helper'
feature 'Project Badges' do
include WaitForRequests
let(:user) { create(:user) }
let(:group) { create(:group) }
let(:project) { create(:project, namespace: group) }
let(:badge_link_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/commits/master'}
let(:badge_image_url) { 'https://gitlab.com/gitlab-org/gitlab-ee/badges/master/build.svg'}
let!(:project_badge) { create(:project_badge, project: project) }
let!(:group_badge) { create(:group_badge, group: group) }
before do
group.add_master(user)
sign_in(user)
visit(project_settings_badges_path(project))
end
it 'shows a list of badges', :js do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
expect(rows[0]).to have_content group_badge.link_url
expect(rows[1]).to have_content project_badge.link_url
end
end
context 'adding a badge', :js do
it 'user can preview a badge' do
page.within '.badge-settings form' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
within '#badge-preview' do
expect(find('a')[:href]).to eq badge_link_url
expect(find('a img')[:src]).to eq badge_image_url
end
end
end
it do
page.within '.badge-settings' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
click_button 'Add badge'
wait_for_requests
within '.panel-body' do
expect(find('a')[:href]).to eq badge_link_url
expect(find('a img')[:src]).to eq badge_image_url
end
end
end
end
context 'editing a badge', :js do
it 'form is shown when clicking edit button in list' do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
rows[1].find('[aria-label="Edit"]').click
within 'form' do
expect(find('#badge-link-url').value).to eq project_badge.link_url
expect(find('#badge-image-url').value).to eq project_badge.image_url
end
end
end
it 'updates a badge when submitting the edit form' do
page.within '.badge-settings' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
rows[1].find('[aria-label="Edit"]').click
within 'form' do
fill_in 'badge-link-url', with: badge_link_url
fill_in 'badge-image-url', with: badge_image_url
click_button 'Save changes'
wait_for_requests
end
rows = all('.panel-body > div')
expect(rows.length).to eq 2
expect(rows[1]).to have_content badge_link_url
end
end
end
context 'deleting a badge', :js do
def click_delete_button(badge_row)
badge_row.find('[aria-label="Delete"]').click
end
it 'shows a modal when deleting a badge' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
click_delete_button(rows[1])
expect(find('.modal .modal-title')).to have_content 'Delete badge?'
end
it 'deletes a badge when confirming the modal' do
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 2
click_delete_button(rows[1])
find('.modal .btn-danger').click
wait_for_requests
rows = all('.panel-body > div')
expect(rows.length).to eq 1
expect(rows[0]).to have_content group_badge.link_url
end
end
end
import Vue from 'vue';
import store from '~/badges/store';
import BadgeForm from '~/badges/components/badge_form.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createDummyBadge } from '../dummy_badge';
describe('BadgeForm component', () => {
const Component = Vue.extend(BadgeForm);
let vm;
beforeEach(() => {
setFixtures(`
<div id="dummy-element"></div>
`);
});
afterEach(() => {
vm.$destroy();
});
describe('methods', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: {
isEditing: false,
},
});
});
describe('onCancel', () => {
it('calls stopEditing', () => {
spyOn(vm, 'stopEditing');
vm.onCancel();
expect(vm.stopEditing).toHaveBeenCalled();
});
});
describe('onSubmit', () => {
describe('if isEditing is true', () => {
beforeEach(() => {
spyOn(vm, 'saveBadge').and.returnValue(Promise.resolve());
store.replaceState({
...store.state,
isSaving: false,
badgeInEditForm: createDummyBadge(),
});
vm.isEditing = true;
});
it('returns immediately if imageUrl is empty', () => {
store.state.badgeInEditForm.imageUrl = '';
vm.onSubmit();
expect(vm.saveBadge).not.toHaveBeenCalled();
});
it('returns immediately if linkUrl is empty', () => {
store.state.badgeInEditForm.linkUrl = '';
vm.onSubmit();
expect(vm.saveBadge).not.toHaveBeenCalled();
});
it('returns immediately if isSaving is true', () => {
store.state.isSaving = true;
vm.onSubmit();
expect(vm.saveBadge).not.toHaveBeenCalled();
});
it('calls saveBadge', () => {
vm.onSubmit();
expect(vm.saveBadge).toHaveBeenCalled();
});
});
describe('if isEditing is false', () => {
beforeEach(() => {
spyOn(vm, 'addBadge').and.returnValue(Promise.resolve());
store.replaceState({
...store.state,
isSaving: false,
badgeInAddForm: createDummyBadge(),
});
vm.isEditing = false;
});
it('returns immediately if imageUrl is empty', () => {
store.state.badgeInAddForm.imageUrl = '';
vm.onSubmit();
expect(vm.addBadge).not.toHaveBeenCalled();
});
it('returns immediately if linkUrl is empty', () => {
store.state.badgeInAddForm.linkUrl = '';
vm.onSubmit();
expect(vm.addBadge).not.toHaveBeenCalled();
});
it('returns immediately if isSaving is true', () => {
store.state.isSaving = true;
vm.onSubmit();
expect(vm.addBadge).not.toHaveBeenCalled();
});
it('calls addBadge', () => {
vm.onSubmit();
expect(vm.addBadge).toHaveBeenCalled();
});
});
});
});
describe('if isEditing is false', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: {
isEditing: false,
},
});
});
it('renders one button', () => {
const buttons = vm.$el.querySelectorAll('.row-content-block button');
expect(buttons.length).toBe(1);
const buttonAddElement = buttons[0];
expect(buttonAddElement).toBeVisible();
expect(buttonAddElement).toHaveText('Add badge');
});
});
describe('if isEditing is true', () => {
beforeEach(() => {
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: {
isEditing: true,
},
});
});
it('renders two buttons', () => {
const buttons = vm.$el.querySelectorAll('.row-content-block button');
expect(buttons.length).toBe(2);
const buttonSaveElement = buttons[0];
expect(buttonSaveElement).toBeVisible();
expect(buttonSaveElement).toHaveText('Save changes');
const buttonCancelElement = buttons[1];
expect(buttonCancelElement).toBeVisible();
expect(buttonCancelElement).toHaveText('Cancel');
});
});
});
import $ from 'jquery';
import Vue from 'vue';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
import store from '~/badges/store';
import BadgeListRow from '~/badges/components/badge_list_row.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createDummyBadge } from '../dummy_badge';
describe('BadgeListRow component', () => {
const Component = Vue.extend(BadgeListRow);
let badge;
let vm;
beforeEach(() => {
setFixtures(`
<div id="delete-badge-modal" class="modal"></div>
<div id="dummy-element"></div>
`);
store.replaceState({
...store.state,
kind: PROJECT_BADGE,
});
badge = createDummyBadge();
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
props: { badge },
});
});
afterEach(() => {
vm.$destroy();
});
it('renders the badge', () => {
const badgeElement = vm.$el.querySelector('.project-badge');
expect(badgeElement).not.toBeNull();
expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
});
it('renders the badge link', () => {
expect(vm.$el).toContainText(badge.linkUrl);
});
it('renders the badge kind', () => {
expect(vm.$el).toContainText('Project Badge');
});
it('shows edit and delete buttons', () => {
const buttons = vm.$el.querySelectorAll('.table-button-footer button');
expect(buttons).toHaveLength(2);
const buttonEditElement = buttons[0];
expect(buttonEditElement).toBeVisible();
expect(buttonEditElement).toHaveSpriteIcon('pencil');
const buttonDeleteElement = buttons[1];
expect(buttonDeleteElement).toBeVisible();
expect(buttonDeleteElement).toHaveSpriteIcon('remove');
});
it('calls editBadge when clicking then edit button', () => {
spyOn(vm, 'editBadge');
const editButton = vm.$el.querySelector('.table-button-footer button:first-of-type');
editButton.click();
expect(vm.editBadge).toHaveBeenCalled();
});
it('calls updateBadgeInModal and shows modal when clicking then delete button', done => {
spyOn(vm, 'updateBadgeInModal');
$('#delete-badge-modal').on('shown.bs.modal', () => done());
const deleteButton = vm.$el.querySelector('.table-button-footer button:last-of-type');
deleteButton.click();
expect(vm.updateBadgeInModal).toHaveBeenCalled();
});
describe('for a group badge', () => {
beforeEach(done => {
badge.kind = GROUP_BADGE;
Vue.nextTick()
.then(done)
.catch(done.fail);
});
it('renders the badge kind', () => {
expect(vm.$el).toContainText('Group Badge');
});
it('hides edit and delete buttons', () => {
const buttons = vm.$el.querySelectorAll('.table-button-footer button');
expect(buttons).toHaveLength(0);
});
});
});
import Vue from 'vue';
import { GROUP_BADGE, PROJECT_BADGE } from '~/badges/constants';
import store from '~/badges/store';
import BadgeList from '~/badges/components/badge_list.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createDummyBadge } from '../dummy_badge';
describe('BadgeList component', () => {
const Component = Vue.extend(BadgeList);
const numberOfDummyBadges = 3;
let vm;
beforeEach(() => {
setFixtures('<div id="dummy-element"></div>');
const badges = [];
for (let id = 0; id < numberOfDummyBadges; id += 1) {
badges.push({ id, ...createDummyBadge() });
}
store.replaceState({
...store.state,
badges,
kind: PROJECT_BADGE,
isLoading: false,
});
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
});
});
afterEach(() => {
vm.$destroy();
});
it('renders a header with the badge count', () => {
const header = vm.$el.querySelector('.panel-heading');
expect(header).toHaveText(new RegExp(`Your badges\\s+${numberOfDummyBadges}`));
});
it('renders a row for each badge', () => {
const rows = vm.$el.querySelectorAll('.gl-responsive-table-row');
expect(rows).toHaveLength(numberOfDummyBadges);
});
it('renders a message if no badges exist', done => {
store.state.badges = [];
Vue.nextTick()
.then(() => {
expect(vm.$el).toContainText('This project has no badges');
})
.then(done)
.catch(done.fail);
});
it('shows a loading icon when loading', done => {
store.state.isLoading = true;
Vue.nextTick()
.then(() => {
const loadingIcon = vm.$el.querySelector('.fa-spinner');
expect(loadingIcon).toBeVisible();
})
.then(done)
.catch(done.fail);
});
describe('for group badges', () => {
beforeEach(done => {
store.state.kind = GROUP_BADGE;
Vue.nextTick()
.then(done)
.catch(done.fail);
});
it('renders a message if no badges exist', done => {
store.state.badges = [];
Vue.nextTick()
.then(() => {
expect(vm.$el).toContainText('This group has no badges');
})
.then(done)
.catch(done.fail);
});
});
});
import $ from 'jquery';
import Vue from 'vue';
import store from '~/badges/store';
import BadgeSettings from '~/badges/components/badge_settings.vue';
import { mountComponentWithStore } from 'spec/helpers/vue_mount_component_helper';
import { createDummyBadge } from '../dummy_badge';
describe('BadgeSettings component', () => {
const Component = Vue.extend(BadgeSettings);
let vm;
beforeEach(() => {
setFixtures(`
<div id="dummy-element"></div>
<button
id="dummy-modal-button"
type="button"
data-toggle="modal"
data-target="#delete-badge-modal"
>Show modal</button>
`);
vm = mountComponentWithStore(Component, {
el: '#dummy-element',
store,
});
});
afterEach(() => {
vm.$destroy();
});
it('displays modal if button is clicked', done => {
const badge = createDummyBadge();
store.state.badgeInModal = badge;
const modal = vm.$el.querySelector('#delete-badge-modal');
const button = document.getElementById('dummy-modal-button');
$(modal).on('shown.bs.modal', () => {
expect(modal).toContainText('Delete badge?');
const badgeElement = modal.querySelector('img.project-badge');
expect(badgeElement).not.toBe(null);
expect(badgeElement.getAttribute('src')).toBe(badge.renderedImageUrl);
done();
});
Vue.nextTick()
.then(() => {
button.click();
})
.catch(done.fail);
});
it('displays a form to add a badge', () => {
const form = vm.$el.querySelector('form:nth-of-type(2)');
expect(form).not.toBe(null);
const button = form.querySelector('.btn-success');
expect(button).not.toBe(null);
expect(button).toHaveText(/Add badge/);
});
it('displays badge list', () => {
const badgeListElement = vm.$el.querySelector('.panel');
expect(badgeListElement).not.toBe(null);
expect(badgeListElement).toBeVisible();
expect(badgeListElement).toContainText('Your badges');
});
describe('when editing', () => {
beforeEach(done => {
store.state.isEditing = true;
Vue.nextTick()
.then(done)
.catch(done.fail);
});
it('displays a form to edit a badge', () => {
const form = vm.$el.querySelector('form:nth-of-type(1)');
expect(form).not.toBe(null);
const submitButton = form.querySelector('.btn-success');
expect(submitButton).not.toBe(null);
expect(submitButton).toHaveText(/Save changes/);
const cancelButton = form.querySelector('.btn-cancel');
expect(cancelButton).not.toBe(null);
expect(cancelButton).toHaveText(/Cancel/);
});
it('displays no badge list', () => {
const badgeListElement = vm.$el.querySelector('.panel');
expect(badgeListElement).toBeHidden();
});
});
describe('methods', () => {
describe('onSubmitModal', () => {
it('triggers ', () => {
spyOn(vm, 'deleteBadge').and.callFake(() => Promise.resolve());
const modal = vm.$el.querySelector('#delete-badge-modal');
const deleteButton = modal.querySelector('.btn-danger');
deleteButton.click();
const badge = store.state.badgeInModal;
expect(vm.deleteBadge).toHaveBeenCalledWith(badge);
});
});
});
});
import Vue from 'vue';
import Badge from '~/badges/components/badge.vue';
import mountComponent from 'spec/helpers/vue_mount_component_helper';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
describe('Badge component', () => {
const Component = Vue.extend(Badge);
const dummyProps = {
imageUrl: DUMMY_IMAGE_URL,
linkUrl: `${TEST_HOST}/badge/link/url`,
};
let vm;
const findElements = () => {
const buttons = vm.$el.querySelectorAll('button');
return {
badgeImage: vm.$el.querySelector('img.project-badge'),
loadingIcon: vm.$el.querySelector('.fa-spinner'),
reloadButton: buttons[buttons.length - 1],
};
};
const createComponent = (props, el = null) => {
vm = mountComponent(Component, props, el);
const { badgeImage } = findElements();
return new Promise(resolve => badgeImage.addEventListener('load', resolve)).then(() =>
Vue.nextTick(),
);
};
afterEach(() => {
vm.$destroy();
});
describe('watchers', () => {
describe('imageUrl', () => {
it('sets isLoading and resets numRetries and hasError', done => {
const props = { ...dummyProps };
createComponent(props)
.then(() => {
expect(vm.isLoading).toBe(false);
vm.hasError = true;
vm.numRetries = 42;
vm.imageUrl = `${props.imageUrl}#something/else`;
return Vue.nextTick();
})
.then(() => {
expect(vm.isLoading).toBe(true);
expect(vm.numRetries).toBe(0);
expect(vm.hasError).toBe(false);
})
.then(done)
.catch(done.fail);
});
});
});
describe('methods', () => {
beforeEach(done => {
createComponent({ ...dummyProps })
.then(done)
.catch(done.fail);
});
it('onError resets isLoading and sets hasError', () => {
vm.hasError = false;
vm.isLoading = true;
vm.onError();
expect(vm.hasError).toBe(true);
expect(vm.isLoading).toBe(false);
});
it('onLoad sets isLoading', () => {
vm.isLoading = true;
vm.onLoad();
expect(vm.isLoading).toBe(false);
});
it('reloadImage resets isLoading and hasError and increases numRetries', () => {
vm.hasError = true;
vm.isLoading = false;
vm.numRetries = 0;
vm.reloadImage();
expect(vm.hasError).toBe(false);
expect(vm.isLoading).toBe(true);
expect(vm.numRetries).toBe(1);
});
});
describe('behavior', () => {
beforeEach(done => {
setFixtures('<div id="dummy-element"></div>');
createComponent({ ...dummyProps }, '#dummy-element')
.then(done)
.catch(done.fail);
});
it('shows a badge image after loading', () => {
expect(vm.isLoading).toBe(false);
expect(vm.hasError).toBe(false);
const { badgeImage, loadingIcon, reloadButton } = findElements();
expect(badgeImage).toBeVisible();
expect(loadingIcon).toBeHidden();
expect(reloadButton).toBeHidden();
expect(vm.$el.innerText).toBe('');
});
it('shows a loading icon when loading', done => {
vm.isLoading = true;
Vue.nextTick()
.then(() => {
const { badgeImage, loadingIcon, reloadButton } = findElements();
expect(badgeImage).toBeHidden();
expect(loadingIcon).toBeVisible();
expect(reloadButton).toBeHidden();
expect(vm.$el.innerText).toBe('');
})
.then(done)
.catch(done.fail);
});
it('shows an error and reload button if loading failed', done => {
vm.hasError = true;
Vue.nextTick()
.then(() => {
const { badgeImage, loadingIcon, reloadButton } = findElements();
expect(badgeImage).toBeHidden();
expect(loadingIcon).toBeHidden();
expect(reloadButton).toBeVisible();
expect(reloadButton).toHaveSpriteIcon('retry');
expect(vm.$el.innerText.trim()).toBe('No badge image');
})
.then(done)
.catch(done.fail);
});
});
});
import { PROJECT_BADGE } from '~/badges/constants';
import { DUMMY_IMAGE_URL, TEST_HOST } from 'spec/test_constants';
export const createDummyBadge = () => {
const id = Math.floor(1000 * Math.random());
return {
id,
imageUrl: `${TEST_HOST}/badges/${id}/image/url`,
isDeleting: false,
linkUrl: `${TEST_HOST}/badges/${id}/link/url`,
kind: PROJECT_BADGE,
renderedImageUrl: `${DUMMY_IMAGE_URL}?id=${id}`,
renderedLinkUrl: `${TEST_HOST}/badges/${id}/rendered/link/url`,
};
};
export const createDummyBadgeResponse = () => ({
image_url: `${TEST_HOST}/badge/image/url`,
link_url: `${TEST_HOST}/badge/link/url`,
kind: PROJECT_BADGE,
rendered_image_url: DUMMY_IMAGE_URL,
rendered_link_url: `${TEST_HOST}/rendered/badge/link/url`,
});
This diff is collapsed.
This diff is collapsed.
...@@ -3,6 +3,12 @@ export const createComponentWithStore = (Component, store, propsData = {}) => ne ...@@ -3,6 +3,12 @@ export const createComponentWithStore = (Component, store, propsData = {}) => ne
propsData, propsData,
}); });
export const mountComponentWithStore = (Component, { el, props, store }) =>
new Component({
store,
propsData: props || { },
}).$mount(el);
export default (Component, props = {}, el = null) => new Component({ export default (Component, props = {}, el = null) => new Component({
propsData: props, propsData: props,
}).$mount(el); }).$mount(el);
export default {
toHaveSpriteIcon: () => ({
compare(element, iconName) {
if (!iconName) {
throw new Error('toHaveSpriteIcon is missing iconName argument!');
}
if (!(element instanceof HTMLElement)) {
throw new Error(`${element} is not a DOM element!`);
}
const iconReferences = [].slice.apply(element.querySelectorAll('svg use'));
const matchingIcon = iconReferences.find(reference => reference.getAttribute('xlink:href').endsWith(`#${iconName}`));
const result = {
pass: !!matchingIcon,
};
if (result.pass) {
result.message = `${element.outerHTML} contains the sprite icon "${iconName}"!`;
} else {
result.message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`;
const existingIcons = iconReferences.map((reference) => {
const iconUrl = reference.getAttribute('xlink:href');
return `"${iconUrl.replace(/^.+#/, '')}"`;
});
if (existingIcons.length > 0) {
result.message += ` (only found ${existingIcons.join(',')})`;
}
}
return result;
},
}),
};
...@@ -7,6 +7,9 @@ import Vue from 'vue'; ...@@ -7,6 +7,9 @@ import Vue from 'vue';
import VueResource from 'vue-resource'; import VueResource from 'vue-resource';
import { getDefaultAdapter } from '~/lib/utils/axios_utils'; import { getDefaultAdapter } from '~/lib/utils/axios_utils';
import { FIXTURES_PATH, TEST_HOST } from './test_constants';
import customMatchers from './matchers';
const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent); const isHeadlessChrome = /\bHeadlessChrome\//.test(navigator.userAgent);
Vue.config.devtools = !isHeadlessChrome; Vue.config.devtools = !isHeadlessChrome;
...@@ -27,15 +30,17 @@ Vue.config.errorHandler = function (err) { ...@@ -27,15 +30,17 @@ Vue.config.errorHandler = function (err) {
Vue.use(VueResource); Vue.use(VueResource);
// enable test fixtures // enable test fixtures
jasmine.getFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; jasmine.getFixtures().fixturesPath = FIXTURES_PATH;
jasmine.getJSONFixtures().fixturesPath = '/base/spec/javascripts/fixtures'; jasmine.getJSONFixtures().fixturesPath = FIXTURES_PATH;
beforeAll(() => jasmine.addMatchers(customMatchers));
// globalize common libraries // globalize common libraries
window.$ = window.jQuery = $; window.$ = window.jQuery = $;
// stub expected globals // stub expected globals
window.gl = window.gl || {}; window.gl = window.gl || {};
window.gl.TEST_HOST = 'http://test.host'; window.gl.TEST_HOST = TEST_HOST;
window.gon = window.gon || {}; window.gon = window.gon || {};
window.gon.test_env = true; window.gon.test_env = true;
......
export const FIXTURES_PATH = '/base/spec/javascripts/fixtures';
export const TEST_HOST = 'http://test.host';
export const DUMMY_IMAGE_URL = `${FIXTURES_PATH}/one_white_pixel.png`;
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