Commit 5d95fa66 authored by Clement Ho's avatar Clement Ho

Merge branch '41082-make-deploykeys-table-more-clearly-structured' into 'master'

Make deploy keys table more clearly structured

Closes #41082

See merge request gitlab-org/gitlab-ce!18279
parents 924ea97a 5e436de6
<script> <script>
import eventHub from '../eventhub'; import loadingIcon from '~/vue_shared/components/loading_icon.vue';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import eventHub from '../eventhub';
export default { export default {
components: { components: {
loadingIcon, loadingIcon,
}, },
...@@ -26,11 +26,6 @@ ...@@ -26,11 +26,6 @@
isLoading: false, isLoading: false,
}; };
}, },
computed: {
text() {
return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`;
},
},
methods: { methods: {
doAction() { doAction() {
this.isLoading = true; this.isLoading = true;
...@@ -40,16 +35,16 @@ ...@@ -40,16 +35,16 @@
}); });
}, },
}, },
}; };
</script> </script>
<template> <template>
<button <button
class="btn btn-sm prepend-left-10" class="btn"
:class="[{ disabled: isLoading }, btnCssClass]" :class="[{ disabled: isLoading }, btnCssClass]"
:disabled="isLoading" :disabled="isLoading"
@click="doAction"> @click="doAction">
{{ text }} <slot></slot>
<loading-icon <loading-icon
v-if="isLoading" v-if="isLoading"
:inline="true" :inline="true"
......
<script> <script>
import Flash from '../../flash'; import { s__ } from '~/locale';
import eventHub from '../eventhub'; import Flash from '~/flash';
import DeployKeysService from '../service'; import LoadingIcon from '~/vue_shared/components/loading_icon.vue';
import DeployKeysStore from '../store'; import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue';
import keysPanel from './keys_panel.vue'; import eventHub from '../eventhub';
import loadingIcon from '../../vue_shared/components/loading_icon.vue'; import DeployKeysService from '../service';
import DeployKeysStore from '../store';
import KeysPanel from './keys_panel.vue';
export default { export default {
components: { components: {
keysPanel, KeysPanel,
loadingIcon, LoadingIcon,
NavigationTabs,
}, },
props: { props: {
endpoint: { endpoint: {
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
currentTab: 'enabled_keys',
isLoading: false, isLoading: false,
store: new DeployKeysStore(), store: new DeployKeysStore(),
}; };
}, },
scopes: {
enabled_keys: s__('DeployKeys|Enabled deploy keys'),
available_project_keys: s__('DeployKeys|Privately accessible deploy keys'),
public_keys: s__('DeployKeys|Publicly accessible deploy keys'),
},
computed: { computed: {
tabs() {
return Object.keys(this.$options.scopes).map(scope => {
const count = Array.isArray(this.keys[scope]) ? this.keys[scope].length : null;
return {
name: this.$options.scopes[scope],
scope,
isActive: scope === this.currentTab,
count,
};
});
},
hasKeys() { hasKeys() {
return Object.keys(this.keys).length; return Object.keys(this.keys).length;
}, },
...@@ -47,34 +72,44 @@ ...@@ -47,34 +72,44 @@
eventHub.$off('disable.key', this.disableKey); eventHub.$off('disable.key', this.disableKey);
}, },
methods: { methods: {
onChangeTab(tab) {
this.currentTab = tab;
},
fetchKeys() { fetchKeys() {
this.isLoading = true; this.isLoading = true;
this.service.getKeys() return this.service
.then((data) => { .getKeys()
.then(data => {
this.isLoading = false; this.isLoading = false;
this.store.keys = data; this.store.keys = data;
}) })
.catch(() => new Flash('Error getting deploy keys')); .catch(() => {
this.isLoading = false;
this.store.keys = {};
return new Flash(s__('DeployKeys|Error getting deploy keys'));
});
}, },
enableKey(deployKey) { enableKey(deployKey) {
this.service.enableKey(deployKey.id) this.service
.then(() => this.fetchKeys()) .enableKey(deployKey.id)
.catch(() => new Flash('Error enabling deploy key')); .then(this.fetchKeys)
.catch(() => new Flash(s__('DeployKeys|Error enabling deploy key')));
}, },
disableKey(deployKey, callback) { disableKey(deployKey, callback) {
// eslint-disable-next-line no-alert // eslint-disable-next-line no-alert
if (confirm('You are going to remove this deploy key. Are you sure?')) { if (confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))) {
this.service.disableKey(deployKey.id) this.service
.then(() => this.fetchKeys()) .disableKey(deployKey.id)
.then(this.fetchKeys)
.then(callback) .then(callback)
.catch(() => new Flash('Error removing deploy key')); .catch(() => new Flash(s__('DeployKeys|Error removing deploy key')));
} else { } else {
callback(); callback();
} }
}, },
}, },
}; };
</script> </script>
<template> <template>
...@@ -82,29 +117,38 @@ ...@@ -82,29 +117,38 @@
<loading-icon <loading-icon
v-if="isLoading && !hasKeys" v-if="isLoading && !hasKeys"
size="2" size="2"
label="Loading deploy keys" :label="s__('DeployKeys|Loading deploy keys')"
/> />
<div v-else-if="hasKeys"> <template v-else-if="hasKeys">
<keys-panel <div class="top-area scrolling-tabs-container inner-page-scroll-tabs">
title="Enabled deploy keys for this project" <div class="fade-left">
class="qa-project-deploy-keys" <i
:keys="keys.enabled_keys" class="fa fa-angle-left"
:store="store" aria-hidden="true"
:endpoint="endpoint" >
/> </i>
<keys-panel </div>
title="Deploy keys from projects you have access to" <div class="fade-right">
:keys="keys.available_project_keys" <i
:store="store" class="fa fa-angle-right"
:endpoint="endpoint" aria-hidden="true"
>
</i>
</div>
<navigation-tabs
:tabs="tabs"
@onChangeTab="onChangeTab"
scope="deployKeys"
/> />
</div>
<keys-panel <keys-panel
v-if="keys.public_keys.length" class="qa-project-deploy-keys"
title="Public deploy keys available to any project" :project-id="projectId"
:keys="keys.public_keys" :keys="keys[currentTab]"
:store="store" :store="store"
:endpoint="endpoint" :endpoint="endpoint"
/> />
</div> </template>
</div> </div>
</template> </template>
<script> <script>
import actionBtn from './action_btn.vue'; import _ from 'underscore';
import { getTimeago } from '../../lib/utils/datetime_utility'; import { s__, sprintf } from '~/locale';
import tooltip from '../../vue_shared/directives/tooltip'; import icon from '~/vue_shared/components/icon.vue';
import tooltip from '~/vue_shared/directives/tooltip';
import timeagoMixin from '~/vue_shared/mixins/timeago';
export default { import actionBtn from './action_btn.vue';
export default {
components: { components: {
actionBtn, actionBtn,
icon,
}, },
directives: { directives: {
tooltip, tooltip,
}, },
mixins: [timeagoMixin],
props: { props: {
deployKey: { deployKey: {
type: Object, type: Object,
...@@ -23,89 +29,207 @@ ...@@ -23,89 +29,207 @@
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: String,
required: false,
default: null,
}, },
computed: {
timeagoDate() {
return getTimeago().format(this.deployKey.created_at);
}, },
data() {
return {
projectsExpanded: false,
};
},
computed: {
editDeployKeyPath() { editDeployKeyPath() {
return `${this.endpoint}/${this.deployKey.id}/edit`; return `${this.endpoint}/${this.deployKey.id}/edit`;
}, },
projects() {
const projects = [...this.deployKey.deploy_keys_projects];
if (this.projectId !== null) {
const indexOfCurrentProject = _.findIndex(
projects,
project =>
project &&
project.project &&
project.project.id &&
project.project.id.toString() === this.projectId,
);
if (indexOfCurrentProject > -1) {
const currentProject = projects.splice(indexOfCurrentProject, 1);
currentProject[0].project.full_name = s__('DeployKeys|Current project');
return currentProject.concat(projects);
}
}
return projects;
},
firstProject() {
return _.head(this.projects);
},
restProjects() {
return _.tail(this.projects);
},
restProjectsTooltip() {
return sprintf(s__('DeployKeys|Expand %{count} other projects'), {
count: this.restProjects.length,
});
},
restProjectsLabel() {
return sprintf(s__('DeployKeys|+%{count} others'), { count: this.restProjects.length });
},
isEnabled() {
return this.store.isEnabled(this.deployKey.id);
},
isRemovable() {
return (
this.store.isEnabled(this.deployKey.id) &&
this.deployKey.destroyed_when_orphaned &&
this.deployKey.almost_orphaned
);
},
isExpandable() {
return !this.projectsExpanded && this.restProjects.length > 1;
},
isExpanded() {
return this.projectsExpanded || this.restProjects.length === 1;
},
}, },
methods: { methods: {
isEnabled(id) { projectTooltipTitle(project) {
return this.store.findEnabledKey(id) !== undefined; return project.can_push
? s__('DeployKeys|Write access allowed')
: s__('DeployKeys|Read access only');
}, },
tooltipTitle(project) { toggleExpanded() {
return project.can_push ? 'Write access allowed' : 'Read access only'; this.projectsExpanded = !this.projectsExpanded;
}, },
}, },
}; };
</script> </script>
<template> <template>
<div> <div class="gl-responsive-table-row deploy-key">
<div class="pull-left append-right-10 hidden-xs"> <div class="table-section section-40">
<i <div
aria-hidden="true" role="rowheader"
class="fa fa-key key-icon" class="table-mobile-header">
> {{ s__('DeployKeys|Deploy key') }}
</i>
</div> </div>
<div class="deploy-key-content key-list-item-info"> <div class="table-mobile-content">
<strong class="title qa-key-title"> <strong class="title qa-key-title">
{{ deployKey.title }} {{ deployKey.title }}
</strong> </strong>
<div class="description qa-key-fingerprint"> <div class="fingerprint qa-key-fingerprint">
{{ deployKey.fingerprint }} {{ deployKey.fingerprint }}
</div> </div>
</div> </div>
<div class="deploy-key-content prepend-left-default deploy-key-projects"> </div>
<div class="table-section section-30 section-wrap">
<div
role="rowheader"
class="table-mobile-header">
{{ s__('DeployKeys|Project usage') }}
</div>
<div class="table-mobile-content deploy-project-list">
<template v-if="projects.length > 0">
<a
class="label deploy-project-label"
:title="projectTooltipTitle(firstProject)"
v-tooltip
>
<span>
{{ firstProject.project.full_name }}
</span>
<icon :name="firstProject.can_push ? 'lock-open' : 'lock'"/>
</a>
<a <a
v-for="(deployKeysProject, i) in deployKey.deploy_keys_projects" v-if="isExpandable"
:key="i" class="label deploy-project-label"
@click="toggleExpanded"
:title="restProjectsTooltip"
v-tooltip
>
<span>{{ restProjectsLabel }}</span>
</a>
<a
v-else-if="isExpanded"
v-for="deployKeysProject in restProjects"
:key="deployKeysProject.project.full_path"
class="label deploy-project-label" class="label deploy-project-label"
:href="deployKeysProject.project.full_path" :href="deployKeysProject.project.full_path"
:title="tooltipTitle(deployKeysProject)" :title="projectTooltipTitle(deployKeysProject)"
v-tooltip v-tooltip
> >
<span>
{{ deployKeysProject.project.full_name }} {{ deployKeysProject.project.full_name }}
<i </span>
v-if="!deployKeysProject.can_push" <icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'"/>
aria-hidden="true"
class="fa fa-lock"
>
</i>
</a> </a>
</template>
<span
v-else
class="text-secondary">{{ __('None') }}</span>
</div>
</div> </div>
<div class="deploy-key-content"> <div class="table-section section-15 text-right">
<span class="key-created-at"> <div
created {{ timeagoDate }} role="rowheader"
class="table-mobile-header">
{{ __('Created') }}
</div>
<div class="table-mobile-content text-secondary key-created-at">
<span
:title="tooltipTitle(deployKey.created_at)"
v-tooltip>
<icon name="calendar"/>
<span>{{ timeFormated(deployKey.created_at) }}</span>
</span> </span>
</div>
</div>
<div class="table-section section-15 table-button-footer deploy-key-actions">
<div class="btn-group table-action-buttons">
<action-btn
v-if="!isEnabled"
:deploy-key="deployKey"
type="enable"
>
{{ __('Enable') }}
</action-btn>
<a <a
v-if="deployKey.can_edit" v-if="deployKey.can_edit"
class="btn btn-sm" class="btn btn-default text-secondary"
:href="editDeployKeyPath" :href="editDeployKeyPath"
:title="__('Edit')"
data-container="body"
v-tooltip
> >
Edit <icon name="pencil"/>
</a> </a>
<action-btn <action-btn
v-if="!isEnabled(deployKey.id)" v-if="isRemovable"
:deploy-key="deployKey" :deploy-key="deployKey"
type="enable" btn-css-class="btn-danger"
/>
<action-btn
v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned"
:deploy-key="deployKey"
btn-css-class="btn-warning"
type="remove" type="remove"
/> :title="__('Remove')"
data-container="body"
v-tooltip
>
<icon name="remove"/>
</action-btn>
<action-btn <action-btn
v-else v-else-if="isEnabled"
:deploy-key="deployKey" :deploy-key="deployKey"
btn-css-class="btn-warning" btn-css-class="btn-warning"
type="disable" type="disable"
/> :title="__('Disable')"
data-container="body"
v-tooltip
>
<icon name="cancel"/>
</action-btn>
</div>
</div> </div>
</div> </div>
</template> </template>
<script> <script>
import key from './key.vue'; import deployKey from './key.vue';
export default { export default {
components: { components: {
key, deployKey,
}, },
props: { props: {
title: {
type: String,
required: true,
},
keys: { keys: {
type: Array, type: Array,
required: true, required: true,
}, },
showHelpBox: {
type: Boolean,
required: false,
default: true,
},
store: { store: {
type: Object, type: Object,
required: true, required: true,
...@@ -27,36 +18,51 @@ ...@@ -27,36 +18,51 @@
type: String, type: String,
required: true, required: true,
}, },
projectId: {
type: String,
required: false,
default: null,
}, },
}; },
};
</script> </script>
<template> <template>
<div class="deploy-keys-panel"> <div class="deploy-keys-panel table-holder">
<h5> <template v-if="keys.length > 0">
{{ title }} <div
({{ keys.length }}) role="row"
</h5> class="gl-responsive-table-row table-row-header">
<ul <div
class="well-list" role="rowheader"
v-if="keys.length" class="table-section section-40">
> {{ s__('DeployKeys|Deploy key') }}
<li </div>
<div
role="rowheader"
class="table-section section-30">
{{ s__('DeployKeys|Project usage') }}
</div>
<div
role="rowheader"
class="table-section section-15 text-right">
{{ __('Created') }}
</div>
</div>
<deploy-key
v-for="deployKey in keys" v-for="deployKey in keys"
:key="deployKey.id" :key="deployKey.id"
>
<key
:deploy-key="deployKey" :deploy-key="deployKey"
:store="store" :store="store"
:endpoint="endpoint" :endpoint="endpoint"
:project-id="projectId"
/> />
</li> </template>
</ul>
<div <div
class="settings-message text-center" class="settings-message text-center"
v-else-if="showHelpBox" v-else
> >
No deploy keys found. Create one with the form above. {{ s__('DeployKeys|No deploy keys found. Create one with the form above.') }}
</div> </div>
</div> </div>
</template> </template>
import Vue from 'vue'; import Vue from 'vue';
import deployKeysApp from './components/app.vue'; import deployKeysApp from './components/app.vue';
export default () => new Vue({ export default () =>
new Vue({
el: document.getElementById('js-deploy-keys'), el: document.getElementById('js-deploy-keys'),
components: { components: {
deployKeysApp, deployKeysApp,
...@@ -9,13 +10,15 @@ export default () => new Vue({ ...@@ -9,13 +10,15 @@ export default () => new Vue({
data() { data() {
return { return {
endpoint: this.$options.el.dataset.endpoint, endpoint: this.$options.el.dataset.endpoint,
projectId: this.$options.el.dataset.projectId,
}; };
}, },
render(createElement) { render(createElement) {
return createElement('deploy-keys-app', { return createElement('deploy-keys-app', {
props: { props: {
endpoint: this.endpoint, endpoint: this.endpoint,
projectId: this.projectId,
}, },
}); });
}, },
}); });
...@@ -7,7 +7,10 @@ export default class DeployKeysService { ...@@ -7,7 +7,10 @@ export default class DeployKeysService {
constructor(endpoint) { constructor(endpoint) {
this.endpoint = endpoint; this.endpoint = endpoint;
this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { this.resource = Vue.resource(
`${this.endpoint}{/id}`,
{},
{
enable: { enable: {
method: 'PUT', method: 'PUT',
url: `${this.endpoint}{/id}/enable`, url: `${this.endpoint}{/id}/enable`,
...@@ -16,12 +19,12 @@ export default class DeployKeysService { ...@@ -16,12 +19,12 @@ export default class DeployKeysService {
method: 'PUT', method: 'PUT',
url: `${this.endpoint}{/id}/disable`, url: `${this.endpoint}{/id}/disable`,
}, },
}); },
);
} }
getKeys() { getKeys() {
return this.resource.get() return this.resource.get().then(response => response.json());
.then(response => response.json());
} }
enableKey(id) { enableKey(id) {
......
...@@ -3,7 +3,7 @@ export default class DeployKeysStore { ...@@ -3,7 +3,7 @@ export default class DeployKeysStore {
this.keys = {}; this.keys = {};
} }
findEnabledKey(id) { isEnabled(id) {
return this.keys.enabled_keys.find(key => key.id === id); return this.keys.enabled_keys.some(key => key.id === id);
} }
} }
...@@ -65,6 +65,9 @@ export default { ...@@ -65,6 +65,9 @@ export default {
spriteHref() { spriteHref() {
return `${gon.sprite_icons}#${this.name}`; return `${gon.sprite_icons}#${this.name}`;
}, },
iconTestClass() {
return `ic-${this.name}`;
},
iconSizeClass() { iconSizeClass() {
return this.size ? `s${this.size}` : ''; return this.size ? `s${this.size}` : '';
}, },
...@@ -74,7 +77,7 @@ export default { ...@@ -74,7 +77,7 @@ export default {
<template> <template>
<svg <svg
:class="[iconSizeClass, cssClasses]" :class="[iconSizeClass, iconTestClass, cssClasses]"
:width="width" :width="width"
:height="height" :height="height"
:x="x" :x="x"
......
<script> <script>
import $ from 'jquery'; import $ from 'jquery';
/** /**
* Given an array of tabs, renders non linked bootstrap tabs. * Given an array of tabs, renders non linked bootstrap tabs.
* When a tab is clicked it will trigger an event and provide the clicked scope. * When a tab is clicked it will trigger an event and provide the clicked scope.
* *
...@@ -14,14 +14,14 @@ ...@@ -14,14 +14,14 @@
* { * {
* name: String, * name: String,
* scope: String, * scope: String,
* count: Number || Undefined, * count: Number || Undefined || Null,
* isActive: Boolean, * isActive: Boolean,
* }, * },
* ]" * ]"
* @onChangeTab="onChangeTab" * @onChangeTab="onChangeTab"
* /> * />
*/ */
export default { export default {
name: 'NavigationTabs', name: 'NavigationTabs',
props: { props: {
tabs: { tabs: {
...@@ -39,15 +39,15 @@ ...@@ -39,15 +39,15 @@
}, },
methods: { methods: {
shouldRenderBadge(count) { shouldRenderBadge(count) {
// 0 is valid in a badge, but evaluates to false, we need to check for undefined // 0 is valid in a badge, but evaluates to false, we need to check for undefined or null
return count !== undefined; return !(count === undefined || count === null);
}, },
onTabClick(tab) { onTabClick(tab) {
this.$emit('onChangeTab', tab.scope); this.$emit('onChangeTab', tab.scope);
}, },
}, },
}; };
</script> </script>
<template> <template>
<ul class="nav-links scrolling-tabs separator"> <ul class="nav-links scrolling-tabs separator">
......
...@@ -354,31 +354,49 @@ ...@@ -354,31 +354,49 @@
min-width: 200px; min-width: 200px;
} }
.deploy-key-content { .deploy-keys {
@media (min-width: $screen-sm-min) { .scrolling-tabs-container {
float: left; position: relative;
}
}
&:last-child { .deploy-key {
float: right; // Ensure that the fingerprint does not overflow on small screens
.fingerprint {
word-break: break-all;
white-space: normal;
}
.deploy-project-label,
.key-created-at {
svg {
vertical-align: text-top;
} }
} }
}
.deploy-key-projects { .btn svg {
@media (min-width: $screen-sm-min) { vertical-align: top;
line-height: 42px; }
.key-created-at {
line-height: unset;
} }
} }
a.deploy-project-label { .deploy-project-list {
padding: 5px; margin-bottom: -$gl-padding-4;
margin-right: 5px;
color: $gl-text-color; a.deploy-project-label {
background-color: $row-hover; margin-right: $gl-padding-4;
margin-bottom: $gl-padding-4;
color: $gl-text-color-secondary;
background-color: $theme-gray-100;
line-height: $gl-btn-line-height;
&:hover { &:hover {
color: $gl-link-color; color: $gl-link-color;
} }
}
} }
.vs-public { .vs-public {
......
...@@ -12,4 +12,4 @@ ...@@ -12,4 +12,4 @@
Create a new deploy key for this project Create a new deploy key for this project
= render @deploy_keys.form_partial_path = render @deploy_keys.form_partial_path
%hr %hr
#js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project) } } #js-deploy-keys{ data: { endpoint: project_deploy_keys_path(@project), project_id: @project.id } }
---
title: Make project deploy keys table more clearly structured
merge_request: 18279
author:
type: changed
...@@ -9,18 +9,21 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -9,18 +9,21 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
step 'I should see project deploy key' do step 'I should see project deploy key' do
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
find('.js-deployKeys-tab-enabled_keys').click()
expect(page).to have_content deploy_key.title expect(page).to have_content deploy_key.title
end end
end end
step 'I should see other project deploy key' do step 'I should see other project deploy key' do
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
find('.js-deployKeys-tab-available_project_keys').click()
expect(page).to have_content other_deploy_key.title expect(page).to have_content other_deploy_key.title
end end
end end
step 'I should see public deploy key' do step 'I should see public deploy key' do
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
find('.js-deployKeys-tab-public_keys').click()
expect(page).to have_content public_deploy_key.title expect(page).to have_content public_deploy_key.title
end end
end end
...@@ -42,6 +45,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -42,6 +45,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
step 'I should see newly created deploy key' do step 'I should see newly created deploy key' do
@project.reload @project.reload
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
find('.js-deployKeys-tab-enabled_keys').click()
expect(page).to have_content(deploy_key.title) expect(page).to have_content(deploy_key.title)
end end
end end
...@@ -58,7 +62,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -58,7 +62,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
step 'I should only see the same deploy key once' do step 'I should only see the same deploy key once' do
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
expect(page).to have_selector('ul li', count: 1) expect(find('.js-deployKeys-tab-available_project_keys .badge')).to have_content('1')
end end
end end
...@@ -68,6 +72,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps ...@@ -68,6 +72,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps
step 'I click attach deploy key' do step 'I click attach deploy key' do
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
find('.badge', text: '1').click()
click_button 'Enable' click_button 'Enable'
expect(page).not_to have_selector('.fa-spinner') expect(page).not_to have_selector('.fa-spinner')
end end
......
...@@ -18,12 +18,12 @@ describe 'Project deploy keys', :js do ...@@ -18,12 +18,12 @@ describe 'Project deploy keys', :js do
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
page.within(find('.deploy-keys')) do page.within(find('.deploy-keys')) do
expect(page).to have_selector('.deploy-keys li', count: 1) expect(page).to have_selector('.deploy-key', count: 1)
accept_confirm { find(:button, text: 'Remove').send_keys(:return) } accept_confirm { find('.ic-remove').click() }
expect(page).not_to have_selector('.fa-spinner', count: 0) expect(page).not_to have_selector('.fa-spinner', count: 0)
expect(page).to have_selector('.deploy-keys li', count: 0) expect(page).to have_selector('.deploy-key', count: 0)
end end
end end
end end
......
...@@ -54,7 +54,7 @@ describe 'Projects > Settings > Repository settings' do ...@@ -54,7 +54,7 @@ describe 'Projects > Settings > Repository settings' do
project.deploy_keys << private_deploy_key project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
find('li', text: private_deploy_key.title).click_link('Edit') find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click()
fill_in 'deploy_key_title', with: 'updated_deploy_key' fill_in 'deploy_key_title', with: 'updated_deploy_key'
check 'deploy_key_deploy_keys_projects_attributes_0_can_push' check 'deploy_key_deploy_keys_projects_attributes_0_can_push'
...@@ -71,11 +71,15 @@ describe 'Projects > Settings > Repository settings' do ...@@ -71,11 +71,15 @@ describe 'Projects > Settings > Repository settings' do
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
find('li', text: private_deploy_key.title).click_link('Edit') find('.js-deployKeys-tab-available_project_keys').click()
find('.deploy-key', text: private_deploy_key.title).find('.ic-pencil').click()
fill_in 'deploy_key_title', with: 'updated_deploy_key' fill_in 'deploy_key_title', with: 'updated_deploy_key'
click_button 'Save changes' click_button 'Save changes'
find('.js-deployKeys-tab-available_project_keys').click()
expect(page).to have_content('updated_deploy_key') expect(page).to have_content('updated_deploy_key')
end end
...@@ -83,7 +87,7 @@ describe 'Projects > Settings > Repository settings' do ...@@ -83,7 +87,7 @@ describe 'Projects > Settings > Repository settings' do
project.deploy_keys << private_deploy_key project.deploy_keys << private_deploy_key
visit project_settings_repository_path(project) visit project_settings_repository_path(project)
accept_confirm { find('li', text: private_deploy_key.title).click_button('Remove') } accept_confirm { find('.deploy-key', text: private_deploy_key.title).find('.ic-remove').click() }
expect(page).not_to have_content(private_deploy_key.title) expect(page).not_to have_content(private_deploy_key.title)
end end
......
...@@ -7,62 +7,64 @@ describe('Deploy keys action btn', () => { ...@@ -7,62 +7,64 @@ describe('Deploy keys action btn', () => {
const deployKey = data.enabled_keys[0]; const deployKey = data.enabled_keys[0];
let vm; let vm;
beforeEach((done) => { beforeEach(done => {
const ActionBtnComponent = Vue.extend(actionBtn); const ActionBtnComponent = Vue.extend({
components: {
vm = new ActionBtnComponent({ actionBtn,
propsData: { },
data() {
return {
deployKey, deployKey,
type: 'enable', };
}, },
}).$mount(); template: `
<action-btn
:deploy-key="deployKey"
type="enable">
Enable
</action-btn>`,
});
vm = new ActionBtnComponent().$mount();
setTimeout(done); Vue.nextTick()
.then(done)
.catch(done.fail);
}); });
it('renders the type as uppercase', () => { it('renders the default slot', () => {
expect( expect(vm.$el.textContent.trim()).toBe('Enable');
vm.$el.textContent.trim(),
).toBe('Enable');
}); });
it('sends eventHub event with btn type', (done) => { it('sends eventHub event with btn type', done => {
spyOn(eventHub, '$emit'); spyOn(eventHub, '$emit');
vm.$el.click(); vm.$el.click();
setTimeout(() => { Vue.nextTick(() => {
expect( expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything());
eventHub.$emit,
).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything());
done(); done();
}); });
}); });
it('shows loading spinner after click', (done) => { it('shows loading spinner after click', done => {
vm.$el.click(); vm.$el.click();
setTimeout(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelector('.fa')).toBeDefined();
vm.$el.querySelector('.fa'),
).toBeDefined();
done(); done();
}); });
}); });
it('disables button after click', (done) => { it('disables button after click', done => {
vm.$el.click(); vm.$el.click();
setTimeout(() => { Vue.nextTick(() => {
expect( expect(vm.$el.classList.contains('disabled')).toBeTruthy();
vm.$el.classList.contains('disabled'),
).toBeTruthy();
expect( expect(vm.$el.getAttribute('disabled')).toBe('disabled');
vm.$el.getAttribute('disabled'),
).toBe('disabled');
done(); done();
}); });
......
...@@ -8,12 +8,14 @@ describe('Deploy keys app component', () => { ...@@ -8,12 +8,14 @@ describe('Deploy keys app component', () => {
let vm; let vm;
const deployKeysResponse = (request, next) => { const deployKeysResponse = (request, next) => {
next(request.respondWith(JSON.stringify(data), { next(
request.respondWith(JSON.stringify(data), {
status: 200, status: 200,
})); }),
);
}; };
beforeEach((done) => { beforeEach(done => {
const Component = Vue.extend(deployKeysApp); const Component = Vue.extend(deployKeysApp);
Vue.http.interceptors.push(deployKeysResponse); Vue.http.interceptors.push(deployKeysResponse);
...@@ -21,6 +23,7 @@ describe('Deploy keys app component', () => { ...@@ -21,6 +23,7 @@ describe('Deploy keys app component', () => {
vm = new Component({ vm = new Component({
propsData: { propsData: {
endpoint: '/test', endpoint: '/test',
projectId: '8',
}, },
}).$mount(); }).$mount();
...@@ -31,117 +34,112 @@ describe('Deploy keys app component', () => { ...@@ -31,117 +34,112 @@ describe('Deploy keys app component', () => {
Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse);
}); });
it('renders loading icon', (done) => { it('renders loading icon', done => {
vm.store.keys = {}; vm.store.keys = {};
vm.isLoading = false; vm.isLoading = false;
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0);
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(0);
expect( expect(vm.$el.querySelector('.fa-spinner')).toBeDefined();
vm.$el.querySelector('.fa-spinner'),
).toBeDefined();
done(); done();
}); });
}); });
it('renders keys panels', () => { it('renders keys panels', () => {
expect( expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(3);
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(3);
}); });
it('does not render key panels when keys object is empty', (done) => { it('renders the titles with keys count', () => {
vm.store.keys = {}; const textContent = selector => {
const element = vm.$el.querySelector(`${selector}`);
Vue.nextTick(() => {
expect(
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(0);
done(); expect(element).not.toBeNull();
}); return element.textContent.trim();
}); };
it('does not render public panel when empty', (done) => { expect(textContent('.js-deployKeys-tab-enabled_keys')).toContain('Enabled deploy keys');
vm.store.keys.public_keys = []; expect(textContent('.js-deployKeys-tab-available_project_keys')).toContain(
'Privately accessible deploy keys',
);
expect(textContent('.js-deployKeys-tab-public_keys')).toContain(
'Publicly accessible deploy keys',
);
expect(textContent('.js-deployKeys-tab-enabled_keys .badge')).toBe(
`${vm.store.keys.enabled_keys.length}`,
);
expect(textContent('.js-deployKeys-tab-available_project_keys .badge')).toBe(
`${vm.store.keys.available_project_keys.length}`,
);
expect(textContent('.js-deployKeys-tab-public_keys .badge')).toBe(
`${vm.store.keys.public_keys.length}`,
);
});
it('does not render key panels when keys object is empty', done => {
vm.store.keys = {};
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0);
vm.$el.querySelectorAll('.deploy-keys-panel').length,
).toBe(2);
done(); done();
}); });
}); });
it('re-fetches deploy keys when enabling a key', (done) => { it('re-fetches deploy keys when enabling a key', done => {
const key = data.public_keys[0]; const key = data.public_keys[0];
spyOn(vm.service, 'getKeys'); spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'enableKey').and.callFake(() => Promise.resolve());
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('enable.key', key); eventHub.$emit('enable.key', key);
Vue.nextTick(() => {
expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); expect(vm.service.enableKey).toHaveBeenCalledWith(key.id);
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}); });
it('re-fetches deploy keys when disabling a key', (done) => { it('re-fetches deploy keys when disabling a key', done => {
const key = data.public_keys[0]; const key = data.public_keys[0];
spyOn(window, 'confirm').and.returnValue(true); spyOn(window, 'confirm').and.returnValue(true);
spyOn(vm.service, 'getKeys'); spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve());
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('disable.key', key); eventHub.$emit('disable.key', key);
Vue.nextTick(() => {
expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}); });
it('calls disableKey when removing a key', (done) => { it('calls disableKey when removing a key', done => {
const key = data.public_keys[0]; const key = data.public_keys[0];
spyOn(window, 'confirm').and.returnValue(true); spyOn(window, 'confirm').and.returnValue(true);
spyOn(vm.service, 'getKeys'); spyOn(vm.service, 'getKeys');
spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve());
resolve();
setTimeout(() => {
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}));
eventHub.$emit('remove.key', key); eventHub.$emit('remove.key', key);
Vue.nextTick(() => {
expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); expect(vm.service.disableKey).toHaveBeenCalledWith(key.id);
expect(vm.service.getKeys).toHaveBeenCalled();
done();
});
}); });
it('hasKeys returns true when there are keys', () => { it('hasKeys returns true when there are keys', () => {
expect(vm.hasKeys).toEqual(3); expect(vm.hasKeys).toEqual(3);
}); });
it('resets remove button loading state', (done) => { it('resets disable button loading state', done => {
spyOn(window, 'confirm').and.returnValue(false); spyOn(window, 'confirm').and.returnValue(false);
const btn = vm.$el.querySelector('.btn-warning'); const btn = vm.$el.querySelector('.btn-warning');
...@@ -149,7 +147,7 @@ describe('Deploy keys app component', () => { ...@@ -149,7 +147,7 @@ describe('Deploy keys app component', () => {
btn.click(); btn.click();
Vue.nextTick(() => { Vue.nextTick(() => {
expect(btn.querySelector('.fa')).toBeNull(); expect(btn.querySelector('.btn-warning')).not.toExist();
done(); done();
}); });
......
...@@ -7,7 +7,7 @@ describe('Deploy keys key', () => { ...@@ -7,7 +7,7 @@ describe('Deploy keys key', () => {
let vm; let vm;
const KeyComponent = Vue.extend(key); const KeyComponent = Vue.extend(key);
const data = getJSONFixture('deploy_keys/keys.json'); const data = getJSONFixture('deploy_keys/keys.json');
const createComponent = (deployKey) => { const createComponent = deployKey => {
const store = new DeployKeysStore(); const store = new DeployKeysStore();
store.keys = data; store.keys = data;
...@@ -23,37 +23,42 @@ describe('Deploy keys key', () => { ...@@ -23,37 +23,42 @@ describe('Deploy keys key', () => {
describe('enabled key', () => { describe('enabled key', () => {
const deployKey = data.enabled_keys[0]; const deployKey = data.enabled_keys[0];
beforeEach((done) => { beforeEach(done => {
createComponent(deployKey); createComponent(deployKey);
setTimeout(done); setTimeout(done);
}); });
it('renders the keys title', () => { it('renders the keys title', () => {
expect( expect(vm.$el.querySelector('.title').textContent.trim()).toContain('My title');
vm.$el.querySelector('.title').textContent.trim(),
).toContain('My title');
}); });
it('renders human friendly formatted created date', () => { it('renders human friendly formatted created date', () => {
expect( expect(vm.$el.querySelector('.key-created-at').textContent.trim()).toBe(
vm.$el.querySelector('.key-created-at').textContent.trim(), `${getTimeago().format(deployKey.created_at)}`,
).toBe(`created ${getTimeago().format(deployKey.created_at)}`); );
}); });
it('shows edit button', () => { it('shows pencil button for editing', () => {
expect( expect(vm.$el.querySelector('.btn .ic-pencil')).toExist();
vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
).toBe('Edit');
}); });
it('shows remove button', () => { it('shows disable button when the project is not deletable', () => {
expect( expect(vm.$el.querySelector('.btn .ic-cancel')).toExist();
vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
).toBe('Remove');
}); });
it('shows write access title when key has write access', (done) => { it('shows remove button when the project is deletable', done => {
vm.deployKey.destroyed_when_orphaned = true;
vm.deployKey.almost_orphaned = true;
Vue.nextTick(() => {
expect(vm.$el.querySelector('.btn .ic-remove')).toExist();
done();
});
});
});
describe('deploy key labels', () => {
it('shows write access title when key has write access', done => {
vm.deployKey.deploy_keys_projects[0].can_push = true; vm.deployKey.deploy_keys_projects[0].can_push = true;
Vue.nextTick(() => { Vue.nextTick(() => {
...@@ -64,7 +69,7 @@ describe('Deploy keys key', () => { ...@@ -64,7 +69,7 @@ describe('Deploy keys key', () => {
}); });
}); });
it('does not show write access title when key has write access', (done) => { it('does not show write access title when key has write access', done => {
vm.deployKey.deploy_keys_projects[0].can_push = false; vm.deployKey.deploy_keys_projects[0].can_push = false;
Vue.nextTick(() => { Vue.nextTick(() => {
...@@ -74,36 +79,73 @@ describe('Deploy keys key', () => { ...@@ -74,36 +79,73 @@ describe('Deploy keys key', () => {
done(); done();
}); });
}); });
it('shows expandable button if more than two projects', () => {
const labels = vm.$el.querySelectorAll('.deploy-project-label');
expect(labels.length).toBe(2);
expect(labels[1].textContent).toContain('others');
expect(labels[1].getAttribute('data-original-title')).toContain('Expand');
});
it('expands all project labels after click', done => {
const length = vm.deployKey.deploy_keys_projects.length;
vm.$el.querySelectorAll('.deploy-project-label')[1].click();
Vue.nextTick(() => {
const labels = vm.$el.querySelectorAll('.deploy-project-label');
expect(labels.length).toBe(length);
expect(labels[1].textContent).not.toContain(`+${length} others`);
expect(labels[1].getAttribute('data-original-title')).not.toContain('Expand');
done();
});
});
it('shows two projects', done => {
vm.deployKey.deploy_keys_projects = [...vm.deployKey.deploy_keys_projects].slice(0, 2);
Vue.nextTick(() => {
const labels = vm.$el.querySelectorAll('.deploy-project-label');
expect(labels.length).toBe(2);
expect(labels[1].textContent).toContain(
vm.deployKey.deploy_keys_projects[1].project.full_name,
);
done();
});
});
}); });
describe('public keys', () => { describe('public keys', () => {
const deployKey = data.public_keys[0]; const deployKey = data.public_keys[0];
beforeEach((done) => { beforeEach(done => {
createComponent(deployKey); createComponent(deployKey);
setTimeout(done); setTimeout(done);
}); });
it('shows edit button', () => { it('renders deploy keys without any enabled projects', done => {
expect( vm.deployKey.deploy_keys_projects = [];
vm.$el.querySelectorAll('.btn')[0].textContent.trim(),
).toBe('Edit'); Vue.nextTick(() => {
expect(vm.$el.querySelector('.deploy-project-list').textContent.trim()).toBe('None');
done();
});
}); });
it('shows enable button', () => { it('shows enable button', () => {
expect( expect(vm.$el.querySelectorAll('.btn')[0].textContent.trim()).toBe('Enable');
vm.$el.querySelectorAll('.btn')[1].textContent.trim(), });
).toBe('Enable');
it('shows pencil button for editing', () => {
expect(vm.$el.querySelector('.btn .ic-pencil')).toExist();
}); });
it('shows disable button when key is enabled', (done) => { it('shows disable button when key is enabled', done => {
vm.store.keys.enabled_keys.push(deployKey); vm.store.keys.enabled_keys.push(deployKey);
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelector('.btn .ic-cancel')).toExist();
vm.$el.querySelectorAll('.btn')[1].textContent.trim(),
).toBe('Disable');
done(); done();
}); });
......
...@@ -6,7 +6,7 @@ describe('Deploy keys panel', () => { ...@@ -6,7 +6,7 @@ describe('Deploy keys panel', () => {
const data = getJSONFixture('deploy_keys/keys.json'); const data = getJSONFixture('deploy_keys/keys.json');
let vm; let vm;
beforeEach((done) => { beforeEach(done => {
const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); const DeployKeysPanelComponent = Vue.extend(deployKeysPanel);
const store = new DeployKeysStore(); const store = new DeployKeysStore();
store.keys = data; store.keys = data;
...@@ -24,46 +24,38 @@ describe('Deploy keys panel', () => { ...@@ -24,46 +24,38 @@ describe('Deploy keys panel', () => {
setTimeout(done); setTimeout(done);
}); });
it('renders the title with keys count', () => { it('renders list of keys', () => {
expect( expect(vm.$el.querySelectorAll('.deploy-key').length).toBe(vm.keys.length);
vm.$el.querySelector('h5').textContent.trim(),
).toContain('test');
expect(
vm.$el.querySelector('h5').textContent.trim(),
).toContain(`(${vm.keys.length})`);
}); });
it('renders list of keys', () => { it('renders table header', () => {
expect( const tableHeader = vm.$el.querySelector('.table-row-header');
vm.$el.querySelectorAll('li').length,
).toBe(vm.keys.length); expect(tableHeader).toExist();
expect(tableHeader.textContent).toContain('Deploy key');
expect(tableHeader.textContent).toContain('Project usage');
expect(tableHeader.textContent).toContain('Created');
}); });
it('renders help box if keys are empty', (done) => { it('renders help box if keys are empty', done => {
vm.keys = []; vm.keys = [];
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelector('.settings-message')).toBeDefined();
vm.$el.querySelector('.settings-message'),
).toBeDefined();
expect( expect(vm.$el.querySelector('.settings-message').textContent.trim()).toBe(
vm.$el.querySelector('.settings-message').textContent.trim(), 'No deploy keys found. Create one with the form above.',
).toBe('No deploy keys found. Create one with the form above.'); );
done(); done();
}); });
}); });
it('does not render help box if keys are empty & showHelpBox is false', (done) => { it('renders no table header if keys are empty', done => {
vm.keys = []; vm.keys = [];
vm.showHelpBox = false;
Vue.nextTick(() => { Vue.nextTick(() => {
expect( expect(vm.$el.querySelector('.table-row-header')).not.toExist();
vm.$el.querySelector('.settings-message'),
).toBeNull();
done(); done();
}); });
......
...@@ -7,6 +7,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control ...@@ -7,6 +7,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control
let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:namespace) { create(:namespace, name: 'frontend-fixtures' )}
let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') }
let(:project2) { create(:project, :internal)} let(:project2) { create(:project, :internal)}
let(:project3) { create(:project, :internal)}
let(:project4) { create(:project, :internal)}
before(:all) do before(:all) do
clean_frontend_fixtures('deploy_keys/') clean_frontend_fixtures('deploy_keys/')
...@@ -28,6 +30,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control ...@@ -28,6 +30,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control
internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com') internal_key = create(:deploy_key, key: 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAAAgQDNd/UJWhPrpb+b/G5oL109y57yKuCxE+WUGJGYaj7WQKsYRJmLYh1mgjrl+KVyfsWpq4ylOxIfFSnN9xBBFN8mlb0Fma5DC7YsSsibJr3MZ19ZNBprwNcdogET7aW9I0In7Wu5f2KqI6e5W/spJHCy4JVxzVMUvk6Myab0LnJ2iQ== dummy@gitlab.com')
create(:deploy_keys_project, project: project, deploy_key: project_key) create(:deploy_keys_project, project: project, deploy_key: project_key)
create(:deploy_keys_project, project: project2, deploy_key: internal_key) create(:deploy_keys_project, project: project2, deploy_key: internal_key)
create(:deploy_keys_project, project: project3, deploy_key: project_key)
create(:deploy_keys_project, project: project4, deploy_key: project_key)
get :index, get :index,
namespace_id: project.namespace.to_param, namespace_id: project.namespace.to_param,
......
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