diff options
20 files changed, 766 insertions, 518 deletions
diff --git a/app/assets/javascripts/deploy_keys/components/action_btn.vue b/app/assets/javascripts/deploy_keys/components/action_btn.vue index b839b9f286f..67dda0e29cb 100644 --- a/app/assets/javascripts/deploy_keys/components/action_btn.vue +++ b/app/assets/javascripts/deploy_keys/components/action_btn.vue @@ -1,55 +1,50 @@ <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 { - components: { - loadingIcon, +export default { + components: { + loadingIcon, + }, + props: { + deployKey: { + type: Object, + required: true, }, - props: { - deployKey: { - type: Object, - required: true, - }, - type: { - type: String, - required: true, - }, - btnCssClass: { - type: String, - required: false, - default: 'btn-default', - }, + type: { + type: String, + required: true, }, - data() { - return { - isLoading: false, - }; + btnCssClass: { + type: String, + required: false, + default: 'btn-default', }, - computed: { - text() { - return `${this.type.charAt(0).toUpperCase()}${this.type.slice(1)}`; - }, - }, - methods: { - doAction() { - this.isLoading = true; + }, + data() { + return { + isLoading: false, + }; + }, + methods: { + doAction() { + this.isLoading = true; - eventHub.$emit(`${this.type}.key`, this.deployKey, () => { - this.isLoading = false; - }); - }, + eventHub.$emit(`${this.type}.key`, this.deployKey, () => { + this.isLoading = false; + }); }, - }; + }, +}; </script> <template> <button - class="btn btn-sm prepend-left-10" + class="btn" :class="[{ disabled: isLoading }, btnCssClass]" :disabled="isLoading" @click="doAction"> - {{ text }} + <slot></slot> <loading-icon v-if="isLoading" :inline="true" diff --git a/app/assets/javascripts/deploy_keys/components/app.vue b/app/assets/javascripts/deploy_keys/components/app.vue index 5a782237b7d..c41fe55db63 100644 --- a/app/assets/javascripts/deploy_keys/components/app.vue +++ b/app/assets/javascripts/deploy_keys/components/app.vue @@ -1,80 +1,115 @@ <script> - import Flash from '../../flash'; - import eventHub from '../eventhub'; - import DeployKeysService from '../service'; - import DeployKeysStore from '../store'; - import keysPanel from './keys_panel.vue'; - import loadingIcon from '../../vue_shared/components/loading_icon.vue'; +import { s__ } from '~/locale'; +import Flash from '~/flash'; +import LoadingIcon from '~/vue_shared/components/loading_icon.vue'; +import NavigationTabs from '~/vue_shared/components/navigation_tabs.vue'; +import eventHub from '../eventhub'; +import DeployKeysService from '../service'; +import DeployKeysStore from '../store'; +import KeysPanel from './keys_panel.vue'; - export default { - components: { - keysPanel, - loadingIcon, +export default { + components: { + KeysPanel, + LoadingIcon, + NavigationTabs, + }, + props: { + endpoint: { + type: String, + required: true, }, - props: { - endpoint: { - type: String, - required: true, - }, + projectId: { + type: String, + required: true, }, - data() { - return { - isLoading: false, - store: new DeployKeysStore(), - }; + }, + data() { + return { + currentTab: 'enabled_keys', + isLoading: false, + 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: { + 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() { + return Object.keys(this.keys).length; }, - computed: { - hasKeys() { - return Object.keys(this.keys).length; - }, - keys() { - return this.store.keys; - }, + keys() { + return this.store.keys; }, - created() { - this.service = new DeployKeysService(this.endpoint); + }, + created() { + this.service = new DeployKeysService(this.endpoint); - eventHub.$on('enable.key', this.enableKey); - eventHub.$on('remove.key', this.disableKey); - eventHub.$on('disable.key', this.disableKey); + eventHub.$on('enable.key', this.enableKey); + eventHub.$on('remove.key', this.disableKey); + eventHub.$on('disable.key', this.disableKey); + }, + mounted() { + this.fetchKeys(); + }, + beforeDestroy() { + eventHub.$off('enable.key', this.enableKey); + eventHub.$off('remove.key', this.disableKey); + eventHub.$off('disable.key', this.disableKey); + }, + methods: { + onChangeTab(tab) { + this.currentTab = tab; }, - mounted() { - this.fetchKeys(); + fetchKeys() { + this.isLoading = true; + + return this.service + .getKeys() + .then(data => { + this.isLoading = false; + this.store.keys = data; + }) + .catch(() => { + this.isLoading = false; + this.store.keys = {}; + return new Flash(s__('DeployKeys|Error getting deploy keys')); + }); }, - beforeDestroy() { - eventHub.$off('enable.key', this.enableKey); - eventHub.$off('remove.key', this.disableKey); - eventHub.$off('disable.key', this.disableKey); + enableKey(deployKey) { + this.service + .enableKey(deployKey.id) + .then(this.fetchKeys) + .catch(() => new Flash(s__('DeployKeys|Error enabling deploy key'))); }, - methods: { - fetchKeys() { - this.isLoading = true; - - this.service.getKeys() - .then((data) => { - this.isLoading = false; - this.store.keys = data; - }) - .catch(() => new Flash('Error getting deploy keys')); - }, - enableKey(deployKey) { - this.service.enableKey(deployKey.id) - .then(() => this.fetchKeys()) - .catch(() => new Flash('Error enabling deploy key')); - }, - disableKey(deployKey, callback) { - // eslint-disable-next-line no-alert - if (confirm('You are going to remove this deploy key. Are you sure?')) { - this.service.disableKey(deployKey.id) - .then(() => this.fetchKeys()) - .then(callback) - .catch(() => new Flash('Error removing deploy key')); - } else { - callback(); - } - }, + disableKey(deployKey, callback) { + // eslint-disable-next-line no-alert + if (confirm(s__('DeployKeys|You are going to remove this deploy key. Are you sure?'))) { + this.service + .disableKey(deployKey.id) + .then(this.fetchKeys) + .then(callback) + .catch(() => new Flash(s__('DeployKeys|Error removing deploy key'))); + } else { + callback(); + } }, - }; + }, +}; </script> <template> @@ -82,29 +117,38 @@ <loading-icon v-if="isLoading && !hasKeys" size="2" - label="Loading deploy keys" + :label="s__('DeployKeys|Loading deploy keys')" /> - <div v-else-if="hasKeys"> + <template v-else-if="hasKeys"> + <div class="top-area scrolling-tabs-container inner-page-scroll-tabs"> + <div class="fade-left"> + <i + class="fa fa-angle-left" + aria-hidden="true" + > + </i> + </div> + <div class="fade-right"> + <i + class="fa fa-angle-right" + aria-hidden="true" + > + </i> + </div> + + <navigation-tabs + :tabs="tabs" + @onChangeTab="onChangeTab" + scope="deployKeys" + /> + </div> <keys-panel - title="Enabled deploy keys for this project" class="qa-project-deploy-keys" - :keys="keys.enabled_keys" - :store="store" - :endpoint="endpoint" - /> - <keys-panel - title="Deploy keys from projects you have access to" - :keys="keys.available_project_keys" - :store="store" - :endpoint="endpoint" - /> - <keys-panel - v-if="keys.public_keys.length" - title="Public deploy keys available to any project" - :keys="keys.public_keys" + :project-id="projectId" + :keys="keys[currentTab]" :store="store" :endpoint="endpoint" /> - </div> + </template> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/key.vue b/app/assets/javascripts/deploy_keys/components/key.vue index c6091efd62f..6c2af7fa768 100644 --- a/app/assets/javascripts/deploy_keys/components/key.vue +++ b/app/assets/javascripts/deploy_keys/components/key.vue @@ -1,111 +1,235 @@ <script> - import actionBtn from './action_btn.vue'; - import { getTimeago } from '../../lib/utils/datetime_utility'; - import tooltip from '../../vue_shared/directives/tooltip'; +import _ from 'underscore'; +import { s__, sprintf } from '~/locale'; +import icon from '~/vue_shared/components/icon.vue'; +import tooltip from '~/vue_shared/directives/tooltip'; +import timeagoMixin from '~/vue_shared/mixins/timeago'; - export default { - components: { - actionBtn, - }, - directives: { - tooltip, - }, - props: { - deployKey: { - type: Object, - required: true, - }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, - }, - computed: { - timeagoDate() { - return getTimeago().format(this.deployKey.created_at); - }, - editDeployKeyPath() { - return `${this.endpoint}/${this.deployKey.id}/edit`; - }, - }, - methods: { - isEnabled(id) { - return this.store.findEnabledKey(id) !== undefined; - }, - tooltipTitle(project) { - return project.can_push ? 'Write access allowed' : 'Read access only'; - }, - }, - }; +import actionBtn from './action_btn.vue'; + +export default { + components: { + actionBtn, + icon, + }, + directives: { + tooltip, + }, + mixins: [timeagoMixin], + props: { + deployKey: { + type: Object, + required: true, + }, + store: { + type: Object, + required: true, + }, + endpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + projectsExpanded: false, + }; + }, + computed: { + editDeployKeyPath() { + 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: { + projectTooltipTitle(project) { + return project.can_push + ? s__('DeployKeys|Write access allowed') + : s__('DeployKeys|Read access only'); + }, + toggleExpanded() { + this.projectsExpanded = !this.projectsExpanded; + }, + }, +}; </script> <template> - <div> - <div class="pull-left append-right-10 hidden-xs"> - <i - aria-hidden="true" - class="fa fa-key key-icon" - > - </i> + <div class="gl-responsive-table-row deploy-key"> + <div class="table-section section-40"> + <div + role="rowheader" + class="table-mobile-header"> + {{ s__('DeployKeys|Deploy key') }} + </div> + <div class="table-mobile-content"> + <strong class="title qa-key-title"> + {{ deployKey.title }} + </strong> + <div class="fingerprint qa-key-fingerprint"> + {{ deployKey.fingerprint }} + </div> + </div> </div> - <div class="deploy-key-content key-list-item-info"> - <strong class="title qa-key-title"> - {{ deployKey.title }} - </strong> - <div class="description qa-key-fingerprint"> - {{ deployKey.fingerprint }} + <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 + v-if="isExpandable" + 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" + :href="deployKeysProject.project.full_path" + :title="projectTooltipTitle(deployKeysProject)" + v-tooltip + > + <span> + {{ deployKeysProject.project.full_name }} + </span> + <icon :name="deployKeysProject.can_push ? 'lock-open' : 'lock'"/> + </a> + </template> + <span + v-else + class="text-secondary">{{ __('None') }}</span> </div> </div> - <div class="deploy-key-content prepend-left-default deploy-key-projects"> - <a - v-for="(deployKeysProject, i) in deployKey.deploy_keys_projects" - :key="i" - class="label deploy-project-label" - :href="deployKeysProject.project.full_path" - :title="tooltipTitle(deployKeysProject)" - v-tooltip - > - {{ deployKeysProject.project.full_name }} - <i - v-if="!deployKeysProject.can_push" - aria-hidden="true" - class="fa fa-lock" - > - </i> - </a> + <div class="table-section section-15 text-right"> + <div + 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> + </div> </div> - <div class="deploy-key-content"> - <span class="key-created-at"> - created {{ timeagoDate }} - </span> - <a - v-if="deployKey.can_edit" - class="btn btn-sm" - :href="editDeployKeyPath" - > - Edit - </a> - <action-btn - v-if="!isEnabled(deployKey.id)" - :deploy-key="deployKey" - type="enable" - /> - <action-btn - v-else-if="deployKey.destroyed_when_orphaned && deployKey.almost_orphaned" - :deploy-key="deployKey" - btn-css-class="btn-warning" - type="remove" - /> - <action-btn - v-else - :deploy-key="deployKey" - btn-css-class="btn-warning" - type="disable" - /> + <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 + v-if="deployKey.can_edit" + class="btn btn-default text-secondary" + :href="editDeployKeyPath" + :title="__('Edit')" + data-container="body" + v-tooltip + > + <icon name="pencil"/> + </a> + <action-btn + v-if="isRemovable" + :deploy-key="deployKey" + btn-css-class="btn-danger" + type="remove" + :title="__('Remove')" + data-container="body" + v-tooltip + > + <icon name="remove"/> + </action-btn> + <action-btn + v-else-if="isEnabled" + :deploy-key="deployKey" + btn-css-class="btn-warning" + type="disable" + :title="__('Disable')" + data-container="body" + v-tooltip + > + <icon name="cancel"/> + </action-btn> + </div> </div> </div> </template> diff --git a/app/assets/javascripts/deploy_keys/components/keys_panel.vue b/app/assets/javascripts/deploy_keys/components/keys_panel.vue index 822b0323156..3b146c7389a 100644 --- a/app/assets/javascripts/deploy_keys/components/keys_panel.vue +++ b/app/assets/javascripts/deploy_keys/components/keys_panel.vue @@ -1,62 +1,68 @@ <script> - import key from './key.vue'; +import deployKey from './key.vue'; - export default { - components: { - key, +export default { + components: { + deployKey, + }, + props: { + keys: { + type: Array, + required: true, }, - props: { - title: { - type: String, - required: true, - }, - keys: { - type: Array, - required: true, - }, - showHelpBox: { - type: Boolean, - required: false, - default: true, - }, - store: { - type: Object, - required: true, - }, - endpoint: { - type: String, - required: true, - }, + store: { + type: Object, + required: true, }, - }; + endpoint: { + type: String, + required: true, + }, + projectId: { + type: String, + required: false, + default: null, + }, + }, +}; </script> <template> - <div class="deploy-keys-panel"> - <h5> - {{ title }} - ({{ keys.length }}) - </h5> - <ul - class="well-list" - v-if="keys.length" - > - <li + <div class="deploy-keys-panel table-holder"> + <template v-if="keys.length > 0"> + <div + role="row" + class="gl-responsive-table-row table-row-header"> + <div + role="rowheader" + class="table-section section-40"> + {{ s__('DeployKeys|Deploy key') }} + </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" :key="deployKey.id" - > - <key - :deploy-key="deployKey" - :store="store" - :endpoint="endpoint" - /> - </li> - </ul> + :deploy-key="deployKey" + :store="store" + :endpoint="endpoint" + :project-id="projectId" + /> + </template> <div 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> </template> diff --git a/app/assets/javascripts/deploy_keys/index.js b/app/assets/javascripts/deploy_keys/index.js index b727261648c..6e439be42ae 100644 --- a/app/assets/javascripts/deploy_keys/index.js +++ b/app/assets/javascripts/deploy_keys/index.js @@ -1,21 +1,24 @@ import Vue from 'vue'; import deployKeysApp from './components/app.vue'; -export default () => new Vue({ - el: document.getElementById('js-deploy-keys'), - components: { - deployKeysApp, - }, - data() { - return { - endpoint: this.$options.el.dataset.endpoint, - }; - }, - render(createElement) { - return createElement('deploy-keys-app', { - props: { - endpoint: this.endpoint, - }, - }); - }, -}); +export default () => + new Vue({ + el: document.getElementById('js-deploy-keys'), + components: { + deployKeysApp, + }, + data() { + return { + endpoint: this.$options.el.dataset.endpoint, + projectId: this.$options.el.dataset.projectId, + }; + }, + render(createElement) { + return createElement('deploy-keys-app', { + props: { + endpoint: this.endpoint, + projectId: this.projectId, + }, + }); + }, + }); diff --git a/app/assets/javascripts/deploy_keys/service/index.js b/app/assets/javascripts/deploy_keys/service/index.js index fe6dbaa9498..194e95e4fca 100644 --- a/app/assets/javascripts/deploy_keys/service/index.js +++ b/app/assets/javascripts/deploy_keys/service/index.js @@ -7,21 +7,24 @@ export default class DeployKeysService { constructor(endpoint) { this.endpoint = endpoint; - this.resource = Vue.resource(`${this.endpoint}{/id}`, {}, { - enable: { - method: 'PUT', - url: `${this.endpoint}{/id}/enable`, + this.resource = Vue.resource( + `${this.endpoint}{/id}`, + {}, + { + enable: { + method: 'PUT', + url: `${this.endpoint}{/id}/enable`, + }, + disable: { + method: 'PUT', + url: `${this.endpoint}{/id}/disable`, + }, }, - disable: { - method: 'PUT', - url: `${this.endpoint}{/id}/disable`, - }, - }); + ); } getKeys() { - return this.resource.get() - .then(response => response.json()); + return this.resource.get().then(response => response.json()); } enableKey(id) { diff --git a/app/assets/javascripts/deploy_keys/store/index.js b/app/assets/javascripts/deploy_keys/store/index.js index 6210361af26..a350bc99a70 100644 --- a/app/assets/javascripts/deploy_keys/store/index.js +++ b/app/assets/javascripts/deploy_keys/store/index.js @@ -3,7 +3,7 @@ export default class DeployKeysStore { this.keys = {}; } - findEnabledKey(id) { - return this.keys.enabled_keys.find(key => key.id === id); + isEnabled(id) { + return this.keys.enabled_keys.some(key => key.id === id); } } diff --git a/app/assets/javascripts/vue_shared/components/icon.vue b/app/assets/javascripts/vue_shared/components/icon.vue index 1a0df49bc29..c42c4a1fbe7 100644 --- a/app/assets/javascripts/vue_shared/components/icon.vue +++ b/app/assets/javascripts/vue_shared/components/icon.vue @@ -65,6 +65,9 @@ export default { spriteHref() { return `${gon.sprite_icons}#${this.name}`; }, + iconTestClass() { + return `ic-${this.name}`; + }, iconSizeClass() { return this.size ? `s${this.size}` : ''; }, @@ -74,7 +77,7 @@ export default { <template> <svg - :class="[iconSizeClass, cssClasses]" + :class="[iconSizeClass, iconTestClass, cssClasses]" :width="width" :height="height" :x="x" diff --git a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue index b33a0101dbf..92d187e24bf 100644 --- a/app/assets/javascripts/vue_shared/components/navigation_tabs.vue +++ b/app/assets/javascripts/vue_shared/components/navigation_tabs.vue @@ -1,53 +1,53 @@ <script> - import $ from 'jquery'; +import $ from 'jquery'; - /** - * 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. - * - * This component is used in apps that handle the API call. - * If you only need to change the URL this component should not be used. - * - * @example - * <navigation-tabs - * :tabs="[ - * { - * name: String, - * scope: String, - * count: Number || Undefined, - * isActive: Boolean, - * }, - * ]" - * @onChangeTab="onChangeTab" - * /> - */ - export default { - name: 'NavigationTabs', - props: { - tabs: { - type: Array, - required: true, - }, - scope: { - type: String, - required: false, - default: '', - }, +/** + * 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. + * + * This component is used in apps that handle the API call. + * If you only need to change the URL this component should not be used. + * + * @example + * <navigation-tabs + * :tabs="[ + * { + * name: String, + * scope: String, + * count: Number || Undefined || Null, + * isActive: Boolean, + * }, + * ]" + * @onChangeTab="onChangeTab" + * /> + */ +export default { + name: 'NavigationTabs', + props: { + tabs: { + type: Array, + required: true, }, - mounted() { - $(document).trigger('init.scrolling-tabs'); + scope: { + type: String, + required: false, + default: '', + }, + }, + mounted() { + $(document).trigger('init.scrolling-tabs'); + }, + methods: { + shouldRenderBadge(count) { + // 0 is valid in a badge, but evaluates to false, we need to check for undefined or null + return !(count === undefined || count === null); }, - methods: { - shouldRenderBadge(count) { - // 0 is valid in a badge, but evaluates to false, we need to check for undefined - return count !== undefined; - }, - onTabClick(tab) { - this.$emit('onChangeTab', tab.scope); - }, + onTabClick(tab) { + this.$emit('onChangeTab', tab.scope); }, - }; + }, +}; </script> <template> <ul class="nav-links scrolling-tabs separator"> diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss index d7d343b088a..ea6467f0f11 100644 --- a/app/assets/stylesheets/pages/projects.scss +++ b/app/assets/stylesheets/pages/projects.scss @@ -354,30 +354,48 @@ min-width: 200px; } -.deploy-key-content { - @media (min-width: $screen-sm-min) { - float: left; +.deploy-keys { + .scrolling-tabs-container { + position: relative; + } +} - &:last-child { - float: right; +.deploy-key { + // 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 { - @media (min-width: $screen-sm-min) { - line-height: 42px; + .btn svg { + vertical-align: top; + } + + .key-created-at { + line-height: unset; } } -a.deploy-project-label { - padding: 5px; - margin-right: 5px; - color: $gl-text-color; - background-color: $row-hover; +.deploy-project-list { + margin-bottom: -$gl-padding-4; - &:hover { - color: $gl-link-color; + a.deploy-project-label { + 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 { + color: $gl-link-color; + } } } diff --git a/app/views/projects/deploy_keys/_index.html.haml b/app/views/projects/deploy_keys/_index.html.haml index 7dd8dc28e5b..6af57d3ab26 100644 --- a/app/views/projects/deploy_keys/_index.html.haml +++ b/app/views/projects/deploy_keys/_index.html.haml @@ -12,4 +12,4 @@ Create a new deploy key for this project = render @deploy_keys.form_partial_path %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 } } diff --git a/changelogs/unreleased/41082-make-deploykeys-table-more-clearly-structured.yml b/changelogs/unreleased/41082-make-deploykeys-table-more-clearly-structured.yml new file mode 100644 index 00000000000..23704c2b37b --- /dev/null +++ b/changelogs/unreleased/41082-make-deploykeys-table-more-clearly-structured.yml @@ -0,0 +1,5 @@ +--- +title: Make project deploy keys table more clearly structured +merge_request: 18279 +author: +type: changed diff --git a/features/steps/project/deploy_keys.rb b/features/steps/project/deploy_keys.rb index 9db31522c5c..8e2f594328d 100644 --- a/features/steps/project/deploy_keys.rb +++ b/features/steps/project/deploy_keys.rb @@ -9,18 +9,21 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps step 'I should see project deploy key' do page.within(find('.deploy-keys')) do + find('.js-deployKeys-tab-enabled_keys').click() expect(page).to have_content deploy_key.title end end step 'I should see other project deploy key' do page.within(find('.deploy-keys')) do + find('.js-deployKeys-tab-available_project_keys').click() expect(page).to have_content other_deploy_key.title end end step 'I should see public deploy key' do page.within(find('.deploy-keys')) do + find('.js-deployKeys-tab-public_keys').click() expect(page).to have_content public_deploy_key.title end end @@ -42,6 +45,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps step 'I should see newly created deploy key' do @project.reload page.within(find('.deploy-keys')) do + find('.js-deployKeys-tab-enabled_keys').click() expect(page).to have_content(deploy_key.title) end end @@ -58,7 +62,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps step 'I should only see the same deploy key once' 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 @@ -68,6 +72,7 @@ class Spinach::Features::ProjectDeployKeys < Spinach::FeatureSteps step 'I click attach deploy key' do page.within(find('.deploy-keys')) do + find('.badge', text: '1').click() click_button 'Enable' expect(page).not_to have_selector('.fa-spinner') end diff --git a/spec/features/projects/deploy_keys_spec.rb b/spec/features/projects/deploy_keys_spec.rb index 886c56e7163..43a23c42f83 100644 --- a/spec/features/projects/deploy_keys_spec.rb +++ b/spec/features/projects/deploy_keys_spec.rb @@ -18,12 +18,12 @@ describe 'Project deploy keys', :js do visit project_settings_repository_path(project) 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).to have_selector('.deploy-keys li', count: 0) + expect(page).to have_selector('.deploy-key', count: 0) end end end diff --git a/spec/features/projects/settings/repository_settings_spec.rb b/spec/features/projects/settings/repository_settings_spec.rb index 162aee63942..08b40653764 100644 --- a/spec/features/projects/settings/repository_settings_spec.rb +++ b/spec/features/projects/settings/repository_settings_spec.rb @@ -54,7 +54,7 @@ describe 'Projects > Settings > Repository settings' do project.deploy_keys << private_deploy_key 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' check 'deploy_key_deploy_keys_projects_attributes_0_can_push' @@ -71,11 +71,15 @@ describe 'Projects > Settings > Repository settings' do 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' click_button 'Save changes' + find('.js-deployKeys-tab-available_project_keys').click() + expect(page).to have_content('updated_deploy_key') end @@ -83,7 +87,7 @@ describe 'Projects > Settings > Repository settings' do project.deploy_keys << private_deploy_key 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) end diff --git a/spec/javascripts/deploy_keys/components/action_btn_spec.js b/spec/javascripts/deploy_keys/components/action_btn_spec.js index 7025c3d836c..5bf72cc0018 100644 --- a/spec/javascripts/deploy_keys/components/action_btn_spec.js +++ b/spec/javascripts/deploy_keys/components/action_btn_spec.js @@ -7,62 +7,64 @@ describe('Deploy keys action btn', () => { const deployKey = data.enabled_keys[0]; let vm; - beforeEach((done) => { - const ActionBtnComponent = Vue.extend(actionBtn); - - vm = new ActionBtnComponent({ - propsData: { - deployKey, - type: 'enable', + beforeEach(done => { + const ActionBtnComponent = Vue.extend({ + components: { + actionBtn, + }, + data() { + return { + deployKey, + }; }, - }).$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', () => { - expect( - vm.$el.textContent.trim(), - ).toBe('Enable'); + it('renders the default slot', () => { + expect(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'); vm.$el.click(); - setTimeout(() => { - expect( - eventHub.$emit, - ).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything()); + Vue.nextTick(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('enable.key', deployKey, jasmine.anything()); done(); }); }); - it('shows loading spinner after click', (done) => { + it('shows loading spinner after click', done => { vm.$el.click(); - setTimeout(() => { - expect( - vm.$el.querySelector('.fa'), - ).toBeDefined(); + Vue.nextTick(() => { + expect(vm.$el.querySelector('.fa')).toBeDefined(); done(); }); }); - it('disables button after click', (done) => { + it('disables button after click', done => { vm.$el.click(); - setTimeout(() => { - expect( - vm.$el.classList.contains('disabled'), - ).toBeTruthy(); + Vue.nextTick(() => { + expect(vm.$el.classList.contains('disabled')).toBeTruthy(); - expect( - vm.$el.getAttribute('disabled'), - ).toBe('disabled'); + expect(vm.$el.getAttribute('disabled')).toBe('disabled'); done(); }); diff --git a/spec/javascripts/deploy_keys/components/app_spec.js b/spec/javascripts/deploy_keys/components/app_spec.js index b870f87eab9..3f9e25a8862 100644 --- a/spec/javascripts/deploy_keys/components/app_spec.js +++ b/spec/javascripts/deploy_keys/components/app_spec.js @@ -8,12 +8,14 @@ describe('Deploy keys app component', () => { let vm; const deployKeysResponse = (request, next) => { - next(request.respondWith(JSON.stringify(data), { - status: 200, - })); + next( + request.respondWith(JSON.stringify(data), { + status: 200, + }), + ); }; - beforeEach((done) => { + beforeEach(done => { const Component = Vue.extend(deployKeysApp); Vue.http.interceptors.push(deployKeysResponse); @@ -21,6 +23,7 @@ describe('Deploy keys app component', () => { vm = new Component({ propsData: { endpoint: '/test', + projectId: '8', }, }).$mount(); @@ -31,117 +34,112 @@ describe('Deploy keys app component', () => { Vue.http.interceptors = _.without(Vue.http.interceptors, deployKeysResponse); }); - it('renders loading icon', (done) => { + it('renders loading icon', done => { vm.store.keys = {}; vm.isLoading = false; Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(0); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0); - expect( - vm.$el.querySelector('.fa-spinner'), - ).toBeDefined(); + expect(vm.$el.querySelector('.fa-spinner')).toBeDefined(); done(); }); }); it('renders keys panels', () => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(3); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(3); }); - it('does not render key panels when keys object is empty', (done) => { - vm.store.keys = {}; - - Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(0); - - done(); - }); + it('renders the titles with keys count', () => { + const textContent = selector => { + const element = vm.$el.querySelector(`${selector}`); + + expect(element).not.toBeNull(); + return element.textContent.trim(); + }; + + expect(textContent('.js-deployKeys-tab-enabled_keys')).toContain('Enabled deploy 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 public panel when empty', (done) => { - vm.store.keys.public_keys = []; + it('does not render key panels when keys object is empty', done => { + vm.store.keys = {}; Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.deploy-keys-panel').length, - ).toBe(2); + expect(vm.$el.querySelectorAll('.deploy-keys .nav-links li').length).toBe(0); 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]; spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'enableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'enableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('enable.key', key); - expect(vm.service.enableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + 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]; spyOn(window, 'confirm').and.returnValue(true); spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('disable.key', key); - expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + 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]; spyOn(window, 'confirm').and.returnValue(true); spyOn(vm.service, 'getKeys'); - spyOn(vm.service, 'disableKey').and.callFake(() => new Promise((resolve) => { - resolve(); - - setTimeout(() => { - expect(vm.service.getKeys).toHaveBeenCalled(); - - done(); - }); - })); + spyOn(vm.service, 'disableKey').and.callFake(() => Promise.resolve()); eventHub.$emit('remove.key', key); - expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + Vue.nextTick(() => { + expect(vm.service.disableKey).toHaveBeenCalledWith(key.id); + expect(vm.service.getKeys).toHaveBeenCalled(); + done(); + }); }); it('hasKeys returns true when there are keys', () => { 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); const btn = vm.$el.querySelector('.btn-warning'); @@ -149,7 +147,7 @@ describe('Deploy keys app component', () => { btn.click(); Vue.nextTick(() => { - expect(btn.querySelector('.fa')).toBeNull(); + expect(btn.querySelector('.btn-warning')).not.toExist(); done(); }); diff --git a/spec/javascripts/deploy_keys/components/key_spec.js b/spec/javascripts/deploy_keys/components/key_spec.js index b7aadf604a4..4279add21d1 100644 --- a/spec/javascripts/deploy_keys/components/key_spec.js +++ b/spec/javascripts/deploy_keys/components/key_spec.js @@ -7,7 +7,7 @@ describe('Deploy keys key', () => { let vm; const KeyComponent = Vue.extend(key); const data = getJSONFixture('deploy_keys/keys.json'); - const createComponent = (deployKey) => { + const createComponent = deployKey => { const store = new DeployKeysStore(); store.keys = data; @@ -23,37 +23,42 @@ describe('Deploy keys key', () => { describe('enabled key', () => { const deployKey = data.enabled_keys[0]; - beforeEach((done) => { + beforeEach(done => { createComponent(deployKey); setTimeout(done); }); it('renders the keys title', () => { - expect( - vm.$el.querySelector('.title').textContent.trim(), - ).toContain('My title'); + expect(vm.$el.querySelector('.title').textContent.trim()).toContain('My title'); }); it('renders human friendly formatted created date', () => { - expect( - vm.$el.querySelector('.key-created-at').textContent.trim(), - ).toBe(`created ${getTimeago().format(deployKey.created_at)}`); + expect(vm.$el.querySelector('.key-created-at').textContent.trim()).toBe( + `${getTimeago().format(deployKey.created_at)}`, + ); }); - it('shows edit button', () => { - expect( - vm.$el.querySelectorAll('.btn')[0].textContent.trim(), - ).toBe('Edit'); + it('shows pencil button for editing', () => { + expect(vm.$el.querySelector('.btn .ic-pencil')).toExist(); }); - it('shows remove button', () => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Remove'); + it('shows disable button when the project is not deletable', () => { + expect(vm.$el.querySelector('.btn .ic-cancel')).toExist(); }); - 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; Vue.nextTick(() => { @@ -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; Vue.nextTick(() => { @@ -74,36 +79,73 @@ describe('Deploy keys key', () => { 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', () => { const deployKey = data.public_keys[0]; - beforeEach((done) => { + beforeEach(done => { createComponent(deployKey); setTimeout(done); }); - it('shows edit button', () => { - expect( - vm.$el.querySelectorAll('.btn')[0].textContent.trim(), - ).toBe('Edit'); + it('renders deploy keys without any enabled projects', done => { + vm.deployKey.deploy_keys_projects = []; + + Vue.nextTick(() => { + expect(vm.$el.querySelector('.deploy-project-list').textContent.trim()).toBe('None'); + + done(); + }); }); it('shows enable button', () => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Enable'); + expect(vm.$el.querySelectorAll('.btn')[0].textContent.trim()).toBe('Enable'); }); - it('shows disable button when key is enabled', (done) => { + it('shows pencil button for editing', () => { + expect(vm.$el.querySelector('.btn .ic-pencil')).toExist(); + }); + + it('shows disable button when key is enabled', done => { vm.store.keys.enabled_keys.push(deployKey); Vue.nextTick(() => { - expect( - vm.$el.querySelectorAll('.btn')[1].textContent.trim(), - ).toBe('Disable'); + expect(vm.$el.querySelector('.btn .ic-cancel')).toExist(); done(); }); diff --git a/spec/javascripts/deploy_keys/components/keys_panel_spec.js b/spec/javascripts/deploy_keys/components/keys_panel_spec.js index 08357d2b547..f71f5ccf082 100644 --- a/spec/javascripts/deploy_keys/components/keys_panel_spec.js +++ b/spec/javascripts/deploy_keys/components/keys_panel_spec.js @@ -6,7 +6,7 @@ describe('Deploy keys panel', () => { const data = getJSONFixture('deploy_keys/keys.json'); let vm; - beforeEach((done) => { + beforeEach(done => { const DeployKeysPanelComponent = Vue.extend(deployKeysPanel); const store = new DeployKeysStore(); store.keys = data; @@ -24,46 +24,38 @@ describe('Deploy keys panel', () => { setTimeout(done); }); - it('renders the title with keys count', () => { - expect( - vm.$el.querySelector('h5').textContent.trim(), - ).toContain('test'); - - expect( - vm.$el.querySelector('h5').textContent.trim(), - ).toContain(`(${vm.keys.length})`); + it('renders list of keys', () => { + expect(vm.$el.querySelectorAll('.deploy-key').length).toBe(vm.keys.length); }); - it('renders list of keys', () => { - expect( - vm.$el.querySelectorAll('li').length, - ).toBe(vm.keys.length); + it('renders table header', () => { + const tableHeader = vm.$el.querySelector('.table-row-header'); + + 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 = []; Vue.nextTick(() => { - expect( - vm.$el.querySelector('.settings-message'), - ).toBeDefined(); + expect(vm.$el.querySelector('.settings-message')).toBeDefined(); - expect( - vm.$el.querySelector('.settings-message').textContent.trim(), - ).toBe('No deploy keys found. Create one with the form above.'); + expect(vm.$el.querySelector('.settings-message').textContent.trim()).toBe( + 'No deploy keys found. Create one with the form above.', + ); 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.showHelpBox = false; Vue.nextTick(() => { - expect( - vm.$el.querySelector('.settings-message'), - ).toBeNull(); + expect(vm.$el.querySelector('.table-row-header')).not.toExist(); done(); }); diff --git a/spec/javascripts/fixtures/deploy_keys.rb b/spec/javascripts/fixtures/deploy_keys.rb index 580894ceaf9..24699c3043a 100644 --- a/spec/javascripts/fixtures/deploy_keys.rb +++ b/spec/javascripts/fixtures/deploy_keys.rb @@ -7,6 +7,8 @@ describe Projects::DeployKeysController, '(JavaScript fixtures)', type: :control let(:namespace) { create(:namespace, name: 'frontend-fixtures' )} let(:project) { create(:project_empty_repo, namespace: namespace, path: 'todos-project') } let(:project2) { create(:project, :internal)} + let(:project3) { create(:project, :internal)} + let(:project4) { create(:project, :internal)} before(:all) do clean_frontend_fixtures('deploy_keys/') @@ -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') 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: project3, deploy_key: project_key) + create(:deploy_keys_project, project: project4, deploy_key: project_key) get :index, namespace_id: project.namespace.to_param, |