Commit 51771cb7 authored by Filipa Lacerda's avatar Filipa Lacerda

Merge branch '23315-group-level-container-registry-browser' into 'master'

Resolve "Group level Container Registry browser"

Closes #23315

See merge request gitlab-org/gitlab!17615
parents 16d1d0aa cc2f3c4d
import initRegistryImages from '~/registry';
document.addEventListener('DOMContentLoaded', initRegistryImages);
......@@ -2,17 +2,19 @@
import { mapGetters, mapActions } from 'vuex';
import { GlLoadingIcon, GlEmptyState } from '@gitlab/ui';
import store from '../stores';
import clipboardButton from '../../vue_shared/components/clipboard_button.vue';
import CollapsibleContainer from './collapsible_container.vue';
import ProjectEmptyState from './project_empty_state.vue';
import GroupEmptyState from './group_empty_state.vue';
import { s__, sprintf } from '../../locale';
export default {
name: 'RegistryListApp',
components: {
clipboardButton,
CollapsibleContainer,
GlEmptyState,
GlLoadingIcon,
ProjectEmptyState,
GroupEmptyState,
},
props: {
characterError: {
......@@ -38,19 +40,27 @@ export default {
},
personalAccessTokensHelpLink: {
type: String,
required: true,
required: false,
default: null,
},
registryHostUrlWithPort: {
type: String,
required: true,
required: false,
default: null,
},
repositoryUrl: {
type: String,
required: true,
},
isGroupPage: {
type: Boolean,
default: false,
required: false,
},
twoFactorAuthHelpLink: {
type: String,
required: true,
required: false,
default: null,
},
},
store,
......@@ -91,37 +101,10 @@ export default {
false,
);
},
notLoggedInToRegistryText() {
return sprintf(
s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
the Container Registry by using your GitLab username and password. If you have
%{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
%{personalAccessTokensDocLinkStart}Personal Access Token
%{personalAccessTokensDocLinkEnd}instead of a password.`),
{
twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
twofaDocLinkEnd: '</a>',
personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
personalAccessTokensDocLinkEnd: '</a>',
},
false,
);
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker login ${this.registryHostUrlWithPort}`;
},
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker build -t ${this.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker push ${this.repositoryUrl}`;
},
},
created() {
this.setMainEndpoint(this.endpoint);
this.setIsDeleteDisabled(this.isGroupPage);
},
mounted() {
if (!this.characterError) {
......@@ -129,7 +112,7 @@ export default {
}
},
methods: {
...mapActions(['setMainEndpoint', 'fetchRepos']),
...mapActions(['setMainEndpoint', 'fetchRepos', 'setIsDeleteDisabled']),
},
};
</script>
......@@ -152,57 +135,19 @@ export default {
<p v-html="introText"></p>
<collapsible-container v-for="item in repos" :key="item.id" :repo="item" />
</div>
<gl-empty-state
v-else
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
<project-empty-state
v-else-if="!isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
:repository-url="repositoryUrl"
:two-factor-auth-help-link="twoFactorAuthHelpLink"
:personal-access-tokens-help-link="personalAccessTokensHelpLink"
:registry-host-url-with-port="registryHostUrlWithPort"
/>
<group-empty-state
v-else-if="isGroupPage"
:no-containers-image="noContainersImage"
:help-page-path="helpPagePath"
/>
</div>
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import { GlLoadingIcon, GlButton, GlTooltipDirective, GlModal, GlModalDirective } from '@gitlab/ui';
import createFlash from '../../flash';
import ClipboardButton from '../../vue_shared/components/clipboard_button.vue';
......@@ -35,9 +35,13 @@ export default {
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
iconName() {
return this.isOpen ? 'angle-up' : 'angle-right';
},
canDeleteRepo() {
return this.repo.canDelete && !this.isDeleteDisabled;
},
},
methods: {
...mapActions(['fetchRepos', 'fetchList', 'deleteItem']),
......@@ -80,7 +84,7 @@ export default {
<div class="controls d-none d-sm-block float-right">
<gl-button
v-if="repo.canDelete"
v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove repository')"
......@@ -98,7 +102,7 @@ export default {
<gl-loading-icon v-if="repo.isLoading" size="md" class="append-bottom-20" />
<div v-else-if="!repo.isLoading && isOpen" class="container-image-tags">
<table-registry v-if="repo.list.length" :repo="repo" />
<table-registry v-if="repo.list.length" :repo="repo" :can-delete-repo="canDeleteRepo" />
<div v-else class="nothing-here-block">
{{ s__('ContainerRegistry|No tags in Container Registry for this container image.') }}
......
<script>
import { GlEmptyState } from '@gitlab/ui';
import { s__, sprintf } from '~/locale';
export default {
name: 'GroupEmptyState',
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
},
computed: {
noContainerImagesText() {
return sprintf(
s__(
`ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images available in this group')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
</template>
</gl-empty-state>
</template>
<script>
import { GlEmptyState } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { s__, sprintf } from '~/locale';
export default {
name: 'ProjectEmptyState',
components: {
ClipboardButton,
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: true,
},
repositoryUrl: {
type: String,
required: true,
},
helpPagePath: {
type: String,
required: true,
},
twoFactorAuthHelpLink: {
type: String,
required: true,
},
personalAccessTokensHelpLink: {
type: String,
required: true,
},
registryHostUrlWithPort: {
type: String,
required: true,
},
},
computed: {
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker build -t ${this.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker push ${this.repositoryUrl}`;
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker login ${this.registryHostUrlWithPort}`;
},
noContainerImagesText() {
return sprintf(
s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`),
{
docLinkStart: `<a href="${this.helpPagePath}" target="_blank">`,
docLinkEnd: '</a>',
},
false,
);
},
notLoggedInToRegistryText() {
return sprintf(
s__(`ContainerRegistry|If you are not already logged in, you need to authenticate to
the Container Registry by using your GitLab username and password. If you have
%{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a
%{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd}
instead of a password.`),
{
twofaDocLinkStart: `<a href="${this.twoFactorAuthHelpLink}" target="_blank">`,
twofaDocLinkEnd: '</a>',
personalAccessTokensDocLinkStart: `<a href="${this.personalAccessTokensHelpLink}" target="_blank">`,
personalAccessTokensDocLinkEnd: '</a>',
},
false,
);
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text" v-html="noContainerImagesText"></p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text" v-html="notLoggedInToRegistryText"></p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p></p>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
</template>
<script>
import { mapActions } from 'vuex';
import { mapActions, mapGetters } from 'vuex';
import {
GlButton,
GlFormCheckbox,
......@@ -35,6 +35,11 @@ export default {
type: Object,
required: true,
},
canDeleteRepo: {
type: Boolean,
default: false,
required: false,
},
},
data() {
return {
......@@ -45,6 +50,7 @@ export default {
};
},
computed: {
...mapGetters(['isDeleteDisabled']),
bulkDeletePath() {
return this.repo.tagsPath ? this.repo.tagsPath.replace('?format=json', '/bulk_destroy') : '';
},
......@@ -165,6 +171,9 @@ export default {
}
}
},
canDeleteRow(item) {
return item && item.canDelete && !this.isDeleteDisabled;
},
},
};
</script>
......@@ -175,7 +184,7 @@ export default {
<tr>
<th>
<gl-form-checkbox
v-if="repo.canDelete"
v-if="canDeleteRepo"
class="js-select-all-checkbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
......@@ -187,7 +196,7 @@ export default {
<th>{{ s__('ContainerRegistry|Last Updated') }}</th>
<th>
<gl-button
v-if="repo.canDelete"
v-if="canDeleteRepo"
v-gl-tooltip
v-gl-modal="modalId"
:disabled="!itemsToBeDeleted || itemsToBeDeleted.length === 0"
......@@ -208,7 +217,7 @@ export default {
<tr v-for="(item, index) in repo.list" :key="item.tag" class="registry-image-row">
<td class="check">
<gl-form-checkbox
v-if="item.canDelete"
v-if="canDeleteRow(item)"
class="js-select-checkbox"
:checked="itemsToBeDeleted && itemsToBeDeleted.includes(index)"
@change="updateItemsToBeDeleted(index)"
......@@ -244,7 +253,7 @@ export default {
<td class="content action-buttons">
<gl-button
v-if="item.canDelete"
v-if="canDeleteRow(item)"
v-gl-modal="modalId"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
......
......@@ -13,29 +13,24 @@ export default () =>
data() {
const { dataset } = document.querySelector(this.$options.el);
return {
characterError: Boolean(dataset.characterError),
containersErrorImage: dataset.containersErrorImage,
endpoint: dataset.endpoint,
helpPagePath: dataset.helpPagePath,
noContainersImage: dataset.noContainersImage,
personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
registryHostUrlWithPort: dataset.registryHostUrlWithPort,
repositoryUrl: dataset.repositoryUrl,
twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
registryData: {
endpoint: dataset.endpoint,
characterError: Boolean(dataset.characterError),
helpPagePath: dataset.helpPagePath,
noContainersImage: dataset.noContainersImage,
containersErrorImage: dataset.containersErrorImage,
repositoryUrl: dataset.repositoryUrl,
isGroupPage: dataset.isGroupPage,
personalAccessTokensHelpLink: dataset.personalAccessTokensHelpLink,
registryHostUrlWithPort: dataset.registryHostUrlWithPort,
twoFactorAuthHelpLink: dataset.twoFactorAuthHelpLink,
},
};
},
render(createElement) {
return createElement('registry-app', {
props: {
characterError: this.characterError,
containersErrorImage: this.containersErrorImage,
endpoint: this.endpoint,
helpPagePath: this.helpPagePath,
noContainersImage: this.noContainersImage,
personalAccessTokensHelpLink: this.personalAccessTokensHelpLink,
registryHostUrlWithPort: this.registryHostUrlWithPort,
repositoryUrl: this.repositoryUrl,
twoFactorAuthHelpLink: this.twoFactorAuthHelpLink,
...this.registryData,
},
});
},
......
......@@ -20,7 +20,6 @@ export const fetchRepos = ({ commit, state }) => {
export const fetchList = ({ commit }, { repo, page }) => {
commit(types.TOGGLE_REGISTRY_LIST_LOADING, repo);
return axios
.get(repo.tagsPath, { params: { page } })
.then(response => {
......@@ -40,6 +39,7 @@ export const multiDeleteItems = (_, { path, items }) =>
axios.delete(path, { params: { ids: items } });
export const setMainEndpoint = ({ commit }, data) => commit(types.SET_MAIN_ENDPOINT, data);
export const setIsDeleteDisabled = ({ commit }, data) => commit(types.SET_IS_DELETE_DISABLED, data);
export const toggleLoading = ({ commit }) => commit(types.TOGGLE_MAIN_LOADING);
// prevent babel-plugin-rewire from generating an invalid default during karma tests
......
export const isLoading = state => state.isLoading;
export const repos = state => state.repos;
export const isDeleteDisabled = state => state.isDeleteDisabled;
// prevent babel-plugin-rewire from generating an invalid default during karma tests
export default () => {};
export const SET_MAIN_ENDPOINT = 'SET_MAIN_ENDPOINT';
export const SET_IS_DELETE_DISABLED = 'SET_IS_DELETE_DISABLED';
export const SET_REPOS_LIST = 'SET_REPOS_LIST';
export const TOGGLE_MAIN_LOADING = 'TOGGLE_MAIN_LOADING';
......
......@@ -6,6 +6,10 @@ export default {
Object.assign(state, { endpoint });
},
[types.SET_IS_DELETE_DISABLED](state, isDeleteDisabled) {
Object.assign(state, { isDeleteDisabled });
},
[types.SET_REPOS_LIST](state, list) {
Object.assign(state, {
repos: list.map(el => ({
......@@ -17,6 +21,7 @@ export default {
location: el.location,
name: el.path,
tagsPath: el.tags_path,
projectId: el.project_id,
})),
});
},
......
export default () => ({
isLoading: false,
endpoint: '', // initial endpoint to fetch the repos list
isDeleteDisabled: false, // controls the delete buttons in the registry
/**
* Each object in `repos` has the following strucure:
* {
......
# frozen_string_literal: true
module Groups
module Registry
class RepositoriesController < Groups::ApplicationController
before_action :verify_container_registry_enabled!
before_action :authorize_read_container_image!
def index
track_event(:list_repositories)
respond_to do |format|
format.html
format.json do
@images = group.container_repositories.with_api_entity_associations
render json: ContainerRepositoriesSerializer
.new(current_user: current_user)
.represent(@images)
end
end
end
private
def verify_container_registry_enabled!
render_404 unless Gitlab.config.registry.enabled
end
def authorize_read_container_image!
return render_404 unless can?(current_user, :read_container_image, group)
end
end
end
end
......@@ -15,6 +15,16 @@ module GroupsHelper
%w[groups#projects groups#edit badges#index ci_cd#show ldap_group_links#index hooks#index audit_events#index pipeline_quota#index]
end
def group_packages_nav_link_paths
%w[
groups/container_registries#index
]
end
def group_container_registry_nav?
Gitlab.config.registry.enabled && can?(current_user, :read_container_image, @group)
end
def group_sidebar_links
@group_sidebar_links ||= get_group_sidebar_links
end
......
......@@ -11,6 +11,7 @@ class ContainerRepository < ApplicationRecord
delegate :client, to: :registry
scope :ordered, -> { order(:name) }
scope :with_api_entity_associations, -> { preload(:project) }
# rubocop: disable CodeReuse/ServiceClass
def registry
......
......@@ -18,7 +18,7 @@ class ContainerRepositoryEntity < Grape::Entity
alias_method :repository, :object
def project
request.project
request.respond_to?(:project) ? request.project : object.project
end
def can_destroy?
......
- page_title _("Container Registry")
%section
.row.registry-placeholder.prepend-bottom-10
.col-12
#js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json),
"help_page_path" => help_page_path('user/packages/container_registry/index'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"repository_url" => "",
is_group_page: true,
character_error: @character_error.to_s } }
- if group_container_registry_nav?
= nav_link(path: group_packages_nav_link_paths) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages')
%ul.sidebar-sub-level-items
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%strong.fly-out-top-item-name
= _('Packages')
%li.divider.fly-out-top-item
= nav_link(controller: 'groups/container_registries') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
......@@ -118,7 +118,7 @@
%strong.fly-out-top-item-name
= _('Kubernetes')
= render_if_exists 'groups/sidebar/packages' # EE-specific
= render_if_exists 'groups/sidebar/packages'
- if group_sidebar_link?(:group_members)
= nav_link(path: 'group_members#index') do
......
---
title: Group level Container Registry browser
merge_request: 17615
author:
type: added
......@@ -77,6 +77,8 @@ constraints(::Constraints::GroupUrlConstrainer.new) do
post :pause
end
end
resources :container_registries, only: [:index], controller: 'registry/repositories'
end
scope(path: '*id',
......
......@@ -33,16 +33,19 @@ module EE
"Repositories within this group #{show_lfs} will be restricted to this maximum size. Can be overridden inside each project. 0 for unlimited. Leave empty to inherit the global value."
end
override :group_packages_nav_link_paths
def group_packages_nav_link_paths
%w[
groups/packages#index
groups/dependency_proxies#show
groups/container_registries#index
]
end
def group_packages_nav?
group_packages_list_nav? ||
group_dependency_proxy_nav?
group_dependency_proxy_nav? ||
group_container_registry_nav?
end
def group_packages_list_nav?
......
- packages_link = group_packages_list_nav? ? group_packages_path(@group) : group_container_registries_path(@group)
- if group_packages_nav?
= nav_link(path: group_packages_nav_link_paths) do
= link_to group_packages_path(@group) do
= link_to packages_link, title: _('Packages') do
.nav-icon-container
= sprite_icon('package')
%span.nav-item-name
= _('Packages')
%ul.sidebar-sub-level-items
= nav_link(controller: [:packages, :repositories], html_options: { class: "fly-out-top-item" } ) do
= link_to group_packages_path(@group) do
= link_to packages_link, title: _('Packages') do
%strong.fly-out-top-item-name
= _('Packages')
%li.divider.fly-out-top-item
......@@ -15,6 +17,10 @@
= nav_link(controller: 'groups/packages') do
= link_to group_packages_path(@group), title: _('Packages') do
%span= _('List')
- if group_container_registry_nav?
= nav_link(controller: 'groups/container_registries') do
= link_to group_container_registries_path(@group), title: _('Container Registry') do
%span= _('Container Registry')
- if group_dependency_proxy_nav?
= nav_link(controller: 'groups/dependency_proxies') do
= link_to group_dependency_proxy_path(@group), title: _('Dependency Proxy') do
......
......@@ -4352,7 +4352,7 @@ msgstr ""
msgid "ContainerRegistry|Docker connection error"
msgstr ""
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token %{personalAccessTokensDocLinkEnd}instead of a password."
msgid "ContainerRegistry|If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr ""
msgid "ContainerRegistry|Last Updated"
......@@ -4384,6 +4384,9 @@ msgstr ""
msgid "ContainerRegistry|Tag ID"
msgstr ""
msgid "ContainerRegistry|There are no container images available in this group"
msgstr ""
msgid "ContainerRegistry|There are no container images stored for this project"
msgstr ""
......@@ -4393,6 +4396,9 @@ msgstr ""
msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
......
# frozen_string_literal: true
require 'spec_helper'
describe Groups::Registry::RepositoriesController do
let_it_be(:user) { create(:user) }
let_it_be(:guest) { create(:user) }
let_it_be(:group, reload: true) { create(:group) }
before do
stub_container_registry_config(enabled: true)
group.add_owner(user)
group.add_guest(guest)
sign_in(user)
end
context 'GET #index' do
context 'when container registry is enabled' do
it 'show index page' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:ok)
end
it 'has the correct response schema' do
get :index, params: {
group_id: group,
format: :json
}
expect(response).to match_response_schema('registry/repositories')
end
it 'returns a list of projects for json format' do
project = create(:project, group: group)
repo = create(:container_repository, project: project)
get :index, params: {
group_id: group,
format: :json
}
expect(response).to have_gitlab_http_status(:ok)
expect(json_response).to be_kind_of(Array)
expect(json_response.first).to include(
'id' => repo.id,
'name' => repo.name
)
end
it 'tracks the event' do
expect(Gitlab::Tracking).to receive(:event).with(anything, 'list_repositories', {})
get :index, params: {
group_id: group
}
end
end
context 'container registry is disabled' do
before do
stub_container_registry_config(enabled: false)
end
it 'renders not found' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:not_found)
end
end
context 'user do not have acces to container registry' do
before do
sign_out(user)
sign_in(guest)
end
it 'renders not found' do
get :index, params: {
group_id: group
}
expect(response).to have_gitlab_http_status(:not_found)
end
end
end
end
# frozen_string_literal: true
require 'spec_helper'
describe 'Groups > sidebar' do
let(:user) { create(:user) }
let(:group) { create(:group) }
before do
group.add_developer(user)
sign_in(user)
end
context 'Package menu' do
context 'when container registry is enabled' do
before do
stub_container_registry_config(enabled: true)
visit group_path(group)
end
it 'shows main menu' do
within '.nav-sidebar' do
expect(page).to have_link(_('Packages'))
end
end
it 'has container registry link' do
within '.nav-sidebar' do
expect(page).to have_link(_('Container Registry'))
end
end
end
context 'when container registry is disabled' do
before do
stub_container_registry_config(enabled: false)
visit group_path(group)
end
it 'does not have container registry link' do
within '.nav-sidebar' do
expect(page).not_to have_link(_('Container Registry'))
end
end
end
end
end
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Group Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images available in this group"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images available in this group
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Project Empty state to match the default snapshot 1`] = `
<div
class="row container-message empty-state"
>
<div
class="col-12"
>
<div
class="svg-250 svg-content"
>
<img
alt="There are no container images stored for this project"
class=""
src="imageUrl"
/>
</div>
</div>
<div
class="col-12"
>
<div
class="text-content"
>
<h4
class="center"
style=""
>
There are no container images stored for this project
</h4>
<p
class="center"
style=""
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images.
<a
href="help"
target="_blank"
>
More Information
</a>
</p>
<h5>
Quick Start
</h5>
<p
class="js-not-logged-in-to-registry-text"
>
If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have
<a
href="help_link"
target="_blank"
>
Two-Factor Authentication
</a>
enabled, use a
<a
href="personal_token"
target="_blank"
>
Personal Access Token
</a>
instead of a password.
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker login host"
data-original-title="Copy login command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
<p />
<p>
You can add an image to this registry with the following commands:
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker build -t url ."
data-original-title="Copy build command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
<div
class="input-group"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<button
class="btn input-group-text btn-secondary btn-default"
data-clipboard-text="docker push url"
data-original-title="Copy push command"
title=""
type="button"
>
<svg
aria-hidden="true"
class="s16 ic-duplicate"
>
<use
xlink:href="#duplicate"
/>
</svg>
</button>
</span>
</div>
</p>
<div
class="text-center"
>
<!---->
<!---->
</div>
</div>
</div>
</div>
`;
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import registry from '~/registry/components/app.vue';
import { TEST_HOST } from '../../helpers/test_constants';
......@@ -7,8 +8,8 @@ describe('Registry List', () => {
let wrapper;
const findCollapsibleContainer = w => w.findAll({ name: 'CollapsibeContainerRegisty' });
const findNoContainerImagesText = w => w.find('.js-no-container-images-text');
const findNotLoggedInToRegistryText = w => w.find('.js-not-logged-in-to-registry-text');
const findProjectEmptyState = w => w.find({ name: 'ProjectEmptyState' });
const findGroupEmptyState = w => w.find({ name: 'GroupEmptyState' });
const findSpinner = w => w.find('.gl-spinner');
const findCharacterErrorText = w => w.find('.js-character-error-text');
......@@ -25,13 +26,18 @@ describe('Registry List', () => {
const setMainEndpoint = jest.fn();
const fetchRepos = jest.fn();
const setIsDeleteDisabled = jest.fn();
const methods = {
setMainEndpoint,
fetchRepos,
setIsDeleteDisabled,
};
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests.
// See https://github.com/vuejs/vue-test-utils/issues/532.
Vue.config.silent = true;
wrapper = mount(registry, {
propsData,
computed: {
......@@ -43,6 +49,12 @@ describe('Registry List', () => {
});
});
afterEach(() => {
jest.clearAllMocks();
Vue.config.silent = false;
wrapper.destroy();
});
describe('with data', () => {
it('should render a list of CollapsibeContainerRegisty', () => {
const containers = findCollapsibleContainer(wrapper);
......@@ -65,18 +77,9 @@ describe('Registry List', () => {
});
});
it('should render empty message', () => {
const noContainerImagesText = findNoContainerImagesText(localWrapper);
expect(noContainerImagesText.text()).toEqual(
'With the Container Registry, every project can have its own space to store its Docker images. More Information',
);
});
it('should render login help text', () => {
const notLoggedInToRegistryText = findNotLoggedInToRegistryText(localWrapper);
expect(notLoggedInToRegistryText.text()).toEqual(
'If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have Two-Factor Authentication enabled, use a Personal Access Token instead of a password.',
);
it('should render project empty message', () => {
const projectEmptyState = findProjectEmptyState(localWrapper);
expect(projectEmptyState.exists()).toBe(true);
});
});
......@@ -129,4 +132,29 @@ describe('Registry List', () => {
);
});
});
describe('with groupId set', () => {
const isGroupPage = true;
beforeEach(() => {
wrapper = mount(registry, {
propsData: {
...propsData,
endpoint: null,
isGroupPage,
},
methods,
});
});
it('call the right vuex setters', () => {
expect(methods.setMainEndpoint).toHaveBeenLastCalledWith(null);
expect(methods.setIsDeleteDisabled).toHaveBeenLastCalledWith(true);
});
it('should render groups empty message', () => {
const groupEmptyState = findGroupEmptyState(wrapper);
expect(groupEmptyState.exists()).toBe(true);
});
});
});
import Vue from 'vue';
import { mount } from '@vue/test-utils';
import Vuex from 'vuex';
import { mount, createLocalVue } from '@vue/test-utils';
import collapsibleComponent from '~/registry/components/collapsible_container.vue';
import { repoPropsData } from '../mock_data';
import createFlash from '~/flash';
import * as getters from '~/registry/stores/getters';
jest.mock('~/flash.js');
const localVue = createLocalVue();
localVue.use(Vuex);
describe('collapsible registry container', () => {
let wrapper;
let store;
const findDeleteBtn = w => w.find('.js-remove-repo');
const findContainerImageTags = w => w.find('.container-image-tags');
const findToggleRepos = w => w.findAll('.js-toggle-repo');
const mountWithStore = config => mount(collapsibleComponent, { ...config, store, localVue });
beforeEach(() => {
createFlash.mockClear();
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(collapsibleComponent, {
store = new Vuex.Store({
state: {
isDeleteDisabled: false,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
......@@ -27,6 +43,7 @@ describe('collapsible registry container', () => {
afterEach(() => {
Vue.config.silent = false;
wrapper.destroy();
});
describe('toggle', () => {
......@@ -86,4 +103,25 @@ describe('collapsible registry container', () => {
});
});
});
describe('disabled delete', () => {
beforeEach(() => {
store = new Vuex.Store({
state: {
isDeleteDisabled: true,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
},
});
});
it('should not render delete button', () => {
const deleteBtn = findDeleteBtn(wrapper);
expect(deleteBtn.exists()).toBe(false);
});
});
});
import { mount } from '@vue/test-utils';
import groupEmptyState from '~/registry/components/group_empty_state.vue';
describe('Registry Group Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(groupEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import { mount } from '@vue/test-utils';
import projectEmptyState from '~/registry/components/project_empty_state.vue';
describe('Registry Project Empty state', () => {
let wrapper;
beforeEach(() => {
wrapper = mount(projectEmptyState, {
propsData: {
noContainersImage: 'imageUrl',
helpPagePath: 'help',
repositoryUrl: 'url',
twoFactorAuthHelpLink: 'help_link',
personalAccessTokensHelpLink: 'personal_token',
registryHostUrlWithPort: 'host',
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vue from 'vue';
import Vuex from 'vuex';
import tableRegistry from '~/registry/components/table_registry.vue';
import { mount } from '@vue/test-utils';
import { mount, createLocalVue } from '@vue/test-utils';
import { repoPropsData } from '../mock_data';
import * as getters from '~/registry/stores/getters';
const [firstImage, secondImage] = repoPropsData.list;
const localVue = createLocalVue();
localVue.use(Vuex);
describe('table registry', () => {
let wrapper;
let store;
const findSelectAllCheckbox = w => w.find('.js-select-all-checkbox > input');
const findSelectCheckboxes = w => w.findAll('.js-select-checkbox > input');
......@@ -15,19 +22,31 @@ describe('table registry', () => {
const findPagination = w => w.find('.js-registry-pagination');
const bulkDeletePath = 'path';
const mountWithStore = config => mount(tableRegistry, { ...config, store, localVue });
beforeEach(() => {
// This is needed due to console.error called by vue to emit a warning that stop the tests
// see https://github.com/vuejs/vue-test-utils/issues/532
Vue.config.silent = true;
wrapper = mount(tableRegistry, {
store = new Vuex.Store({
state: {
isDeleteDisabled: false,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
canDeleteRepo: true,
},
});
});
afterEach(() => {
Vue.config.silent = false;
wrapper.destroy();
});
describe('rendering', () => {
......@@ -149,7 +168,6 @@ describe('table registry', () => {
});
describe('pagination', () => {
let localWrapper = null;
const repo = {
repoPropsData,
pagination: {
......@@ -160,7 +178,7 @@ describe('table registry', () => {
};
beforeEach(() => {
localWrapper = mount(tableRegistry, {
wrapper = mount(tableRegistry, {
propsData: {
repo,
},
......@@ -168,13 +186,13 @@ describe('table registry', () => {
});
it('should exist', () => {
const pagination = findPagination(localWrapper);
const pagination = findPagination(wrapper);
expect(pagination.exists()).toBe(true);
});
it('should be visible when pagination is needed', () => {
const pagination = findPagination(localWrapper);
const pagination = findPagination(wrapper);
expect(pagination.isVisible()).toBe(true);
localWrapper.setProps({
wrapper.setProps({
repo: {
pagination: {
total: 0,
......@@ -182,13 +200,13 @@ describe('table registry', () => {
},
},
});
expect(localWrapper.vm.shouldRenderPagination).toBe(false);
expect(wrapper.vm.shouldRenderPagination).toBe(false);
});
it('should have a change function that update the list when run', () => {
const fetchList = jest.fn().mockResolvedValue();
localWrapper.setMethods({ fetchList });
localWrapper.vm.onPageChange(1);
expect(localWrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
wrapper.setMethods({ fetchList });
wrapper.vm.onPageChange(1);
expect(wrapper.vm.fetchList).toHaveBeenCalledWith({ repo, page: 1 });
});
});
......@@ -208,4 +226,41 @@ describe('table registry', () => {
expect(wrapper.vm.modalDescription).toContain('<b>2</b> tags');
});
});
describe('disabled delete', () => {
beforeEach(() => {
store = new Vuex.Store({
state: {
isDeleteDisabled: true,
},
getters,
});
wrapper = mountWithStore({
propsData: {
repo: repoPropsData,
canDeleteRepo: false,
},
});
});
it('should not render select all', () => {
const selectAll = findSelectAllCheckbox(wrapper);
expect(selectAll.exists()).toBe(false);
});
it('should not render any select checkbox', () => {
const selects = findSelectCheckboxes(wrapper);
expect(selects.length).toBe(0);
});
it('should not render delete registry button', () => {
const deleteBtn = findDeleteButton(wrapper);
expect(deleteBtn.exists()).toBe(false);
});
it('should not render delete row button', () => {
const deleteBtns = findDeleteButtonsRow(wrapper);
expect(deleteBtns.length).toBe(0);
});
});
});
......@@ -34,7 +34,7 @@ describe('Actions Registry Store', () => {
mock.onGet(`${TEST_HOST}/endpoint.json`).replyOnce(200, reposServerResponse, {});
});
it('should set receveived repos', done => {
it('should set received repos', done => {
testAction(
actions.fetchRepos,
null,
......@@ -71,10 +71,10 @@ describe('Actions Registry Store', () => {
beforeEach(() => {
state.repos = parsedReposServerResponse;
[, repo] = state.repos;
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
});
it('should set received list', done => {
mock.onGet(repo.tagsPath).replyOnce(200, registryServerResponse, {});
testAction(
actions.fetchList,
{ repo },
......@@ -97,6 +97,7 @@ describe('Actions Registry Store', () => {
});
it('should create flash on API error', done => {
mock.onGet(repo.tagsPath).replyOnce(400);
const updatedRepo = {
...repo,
tagsPath: null,
......@@ -133,6 +134,19 @@ describe('Actions Registry Store', () => {
});
});
describe('setIsDeleteDisabled', () => {
it('should commit set is delete disabled', done => {
testAction(
actions.setIsDeleteDisabled,
true,
state,
[{ type: types.SET_IS_DELETE_DISABLED, payload: true }],
[],
done,
);
});
});
describe('toggleLoading', () => {
it('should commit toggle main loading', done => {
testAction(
......
......@@ -7,6 +7,7 @@ describe('Getters Registry Store', () => {
state = {
isLoading: false,
endpoint: '/root/empty-project/container_registry.json',
isDeleteDisabled: false,
repos: [
{
canDelete: true,
......@@ -43,4 +44,9 @@ describe('Getters Registry Store', () => {
expect(getters.repos(state)).toEqual(state.repos);
});
});
describe('isDeleteDisabled', () => {
it('should return isDeleteDisabled', () => {
expect(getters.isDeleteDisabled(state)).toEqual(state.isDeleteDisabled);
});
});
});
......@@ -19,7 +19,16 @@ describe('Mutations Registry Store', () => {
const expectedState = Object.assign({}, mockState, { endpoint: 'foo' });
mutations[types.SET_MAIN_ENDPOINT](mockState, 'foo');
expect(mockState).toEqual(expectedState);
expect(mockState.endpoint).toEqual(expectedState.endpoint);
});
});
describe('SET_IS_DELETE_DISABLED', () => {
it('should set the is delete disabled', () => {
const expectedState = Object.assign({}, mockState, { isDeleteDisabled: true });
mutations[types.SET_IS_DELETE_DISABLED](mockState, true);
expect(mockState.isDeleteDisabled).toEqual(expectedState.isDeleteDisabled);
});
});
......
......@@ -191,6 +191,41 @@ describe GroupsHelper do
end
end
describe '#group_container_registry_nav' do
let(:group) { create(:group, :public) }
let(:user) { create(:user) }
before do
stub_container_registry_config(enabled: true)
allow(helper).to receive(:current_user) { user }
allow(helper).to receive(:can?).with(user, :read_container_image, group) { true }
helper.instance_variable_set(:@group, group)
end
subject { helper.group_container_registry_nav? }
context 'when container registry is enabled' do
it { is_expected.to be_truthy }
it 'is disabled for guest' do
allow(helper).to receive(:can?).with(user, :read_container_image, group) { false }
expect(subject).to be false
end
end
context 'when container registry is not enabled' do
before do
stub_container_registry_config(enabled: false)
end
it { is_expected.to be_falsy }
it 'is disabled for guests' do
allow(helper).to receive(:can?).with(user, :read_container_image, group) { false }
expect(subject).to be false
end
end
end
describe '#group_sidebar_links' do
let(:group) { create(:group, :public) }
let(:user) { create(:user) }
......
......@@ -25,6 +25,18 @@ describe ContainerRepositoryEntity do
expect(subject).to include(:id, :path, :location, :tags_path)
end
context 'when project is not preset in the request' do
before do
allow(request).to receive(:respond_to?).and_return(false)
allow(request).to receive(:project).and_return(nil)
end
it 'uses project from the object' do
expect(request.project).not_to equal(project)
expect(subject).to include(:tags_path)
end
end
context 'when user can manage repositories' do
before do
project.add_developer(user)
......
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