Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2020-11-10 21:08:51 +00:00
parent 206b03aeae
commit 13bcb82213
69 changed files with 2168 additions and 216 deletions

View File

@ -5,7 +5,7 @@ import { parseDataAttributes } from 'ee_else_ce/groups/members/utils';
import App from './components/app.vue';
import membersModule from '~/vuex_shared/modules/members';
export const initGroupMembersApp = (el, tableFields, requestFormatter) => {
export const initGroupMembersApp = (el, tableFields, tableAttrs, requestFormatter) => {
if (!el) {
return () => {};
}
@ -18,6 +18,7 @@ export const initGroupMembersApp = (el, tableFields, requestFormatter) => {
...parseDataAttributes(el),
currentUserId: gon.current_user_id || null,
tableFields,
tableAttrs,
requestFormatter,
}),
});

View File

@ -39,6 +39,16 @@ export default {
type: String,
required: true,
},
groupId: {
type: String,
required: false,
default: '',
},
groupMilestonesAvailable: {
type: Boolean,
required: false,
default: false,
},
extraLinks: {
type: Array,
default: () => [],
@ -56,12 +66,13 @@ export default {
noMilestone: s__('MilestoneCombobox|No milestone'),
noResultsLabel: s__('MilestoneCombobox|No matching results'),
searchMilestones: s__('MilestoneCombobox|Search Milestones'),
searhErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'),
projectMilestones: s__('MilestoneCombobox|Project milestones'),
groupMilestones: s__('MilestoneCombobox|Group milestones'),
},
computed: {
...mapState(['matches', 'selectedMilestones']),
...mapGetters(['isLoading']),
...mapGetters(['isLoading', 'groupMilestonesEnabled']),
selectedMilestonesLabel() {
const { selectedMilestones } = this;
const firstMilestoneName = selectedMilestones[0];
@ -85,8 +96,14 @@ export default {
this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error,
);
},
showGroupMilestoneSection() {
return (
this.groupMilestonesEnabled &&
Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error)
);
},
showNoResults() {
return !this.showProjectMilestoneSection;
return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection;
},
},
watch: {
@ -115,11 +132,15 @@ export default {
}, SEARCH_DEBOUNCE_MS);
this.setProjectId(this.projectId);
this.setGroupId(this.groupId);
this.setGroupMilestonesAvailable(this.groupMilestonesAvailable);
this.fetchMilestones();
},
methods: {
...mapActions([
'setProjectId',
'setGroupId',
'setGroupMilestonesAvailable',
'setSelectedMilestones',
'clearSelectedMilestones',
'toggleMilestones',
@ -194,15 +215,28 @@ export default {
</template>
<template v-else>
<milestone-results-section
v-if="showProjectMilestoneSection"
:section-title="$options.translations.projectMilestones"
:total-count="matches.projectMilestones.totalCount"
:items="matches.projectMilestones.list"
:selected-milestones="selectedMilestones"
:error="matches.projectMilestones.error"
:error-message="$options.translations.searhErrorMessage"
:error-message="$options.translations.searchErrorMessage"
data-testid="project-milestones-section"
@selected="selectMilestone($event)"
/>
<milestone-results-section
v-if="showGroupMilestoneSection"
:section-title="$options.translations.groupMilestones"
:total-count="matches.groupMilestones.totalCount"
:items="matches.groupMilestones.list"
:selected-milestones="selectedMilestones"
:error="matches.groupMilestones.error"
:error-message="$options.translations.searchErrorMessage"
data-testid="group-milestones-section"
@selected="selectMilestone($event)"
/>
</template>
<gl-dropdown-item
v-for="(item, idx) in extraLinks"

View File

@ -2,6 +2,9 @@ import Api from '~/api';
import * as types from './mutation_types';
export const setProjectId = ({ commit }, projectId) => commit(types.SET_PROJECT_ID, projectId);
export const setGroupId = ({ commit }, groupId) => commit(types.SET_GROUP_ID, groupId);
export const setGroupMilestonesAvailable = ({ commit }, groupMilestonesAvailable) =>
commit(types.SET_GROUP_MILESTONES_AVAILABLE, groupMilestonesAvailable);
export const setSelectedMilestones = ({ commit }, selectedMilestones) =>
commit(types.SET_SELECTED_MILESTONES, selectedMilestones);
@ -18,13 +21,23 @@ export const toggleMilestones = ({ commit, state }, selectedMilestone) => {
}
};
export const search = ({ dispatch, commit }, searchQuery) => {
export const search = ({ dispatch, commit, getters }, searchQuery) => {
commit(types.SET_SEARCH_QUERY, searchQuery);
dispatch('searchMilestones');
dispatch('searchProjectMilestones');
if (getters.groupMilestonesEnabled) {
dispatch('searchGroupMilestones');
}
};
export const fetchMilestones = ({ commit, state }) => {
export const fetchMilestones = ({ dispatch, getters }) => {
dispatch('fetchProjectMilestones');
if (getters.groupMilestonesEnabled) {
dispatch('fetchGroupMilestones');
}
};
export const fetchProjectMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.projectMilestones(state.projectId)
@ -39,14 +52,29 @@ export const fetchMilestones = ({ commit, state }) => {
});
};
export const searchMilestones = ({ commit, state }) => {
export const fetchGroupMilestones = ({ commit, state }) => {
commit(types.REQUEST_START);
Api.groupMilestones(state.groupId)
.then(response => {
commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};
export const searchProjectMilestones = ({ commit, state }) => {
const options = {
search: state.searchQuery,
scope: 'milestones',
};
commit(types.REQUEST_START);
Api.projectSearch(state.projectId, options)
.then(response => {
commit(types.RECEIVE_PROJECT_MILESTONES_SUCCESS, response);
@ -58,3 +86,22 @@ export const searchMilestones = ({ commit, state }) => {
commit(types.REQUEST_FINISH);
});
};
export const searchGroupMilestones = ({ commit, state }) => {
const options = {
search: state.searchQuery,
};
commit(types.REQUEST_START);
Api.groupMilestones(state.groupId, options)
.then(response => {
commit(types.RECEIVE_GROUP_MILESTONES_SUCCESS, response);
})
.catch(error => {
commit(types.RECEIVE_GROUP_MILESTONES_ERROR, error);
})
.finally(() => {
commit(types.REQUEST_FINISH);
});
};

View File

@ -1,2 +1,6 @@
/** Returns `true` if there is at least one in-progress request */
export const isLoading = ({ requestCount }) => requestCount > 0;
/** Returns `true` if there is a group ID and group milestones are available */
export const groupMilestonesEnabled = ({ groupId, groupMilestonesAvailable }) =>
Boolean(groupId && groupMilestonesAvailable);

View File

@ -1,4 +1,6 @@
export const SET_PROJECT_ID = 'SET_PROJECT_ID';
export const SET_GROUP_ID = 'SET_GROUP_ID';
export const SET_GROUP_MILESTONES_AVAILABLE = 'SET_GROUP_MILESTONES_AVAILABLE';
export const SET_SELECTED_MILESTONES = 'SET_SELECTED_MILESTONES';
export const CLEAR_SELECTED_MILESTONES = 'CLEAR_SELECTED_MILESTONES';
@ -12,3 +14,6 @@ export const REQUEST_FINISH = 'REQUEST_FINISH';
export const RECEIVE_PROJECT_MILESTONES_SUCCESS = 'RECEIVE_PROJECT_MILESTONES_SUCCESS';
export const RECEIVE_PROJECT_MILESTONES_ERROR = 'RECEIVE_PROJECT_MILESTONES_ERROR';
export const RECEIVE_GROUP_MILESTONES_SUCCESS = 'RECEIVE_GROUP_MILESTONES_SUCCESS';
export const RECEIVE_GROUP_MILESTONES_ERROR = 'RECEIVE_GROUP_MILESTONES_ERROR';

View File

@ -1,11 +1,16 @@
import Vue from 'vue';
import * as types from './mutation_types';
import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
export default {
[types.SET_PROJECT_ID](state, projectId) {
state.projectId = projectId;
},
[types.SET_GROUP_ID](state, groupId) {
state.groupId = groupId;
},
[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable) {
state.groupMilestonesAvailable = groupMilestonesAvailable;
},
[types.SET_SELECTED_MILESTONES](state, selectedMilestones) {
Vue.set(state, 'selectedMilestones', selectedMilestones);
},
@ -32,7 +37,7 @@ export default {
},
[types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) {
state.matches.projectMilestones = {
list: convertObjectPropsToCamelCase(response.data).map(({ title }) => ({ title })),
list: response.data.map(({ title }) => ({ title })),
totalCount: parseInt(response.headers['x-total'], 10),
error: null,
};
@ -44,4 +49,18 @@ export default {
error,
};
},
[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) {
state.matches.groupMilestones = {
list: response.data.map(({ title }) => ({ title })),
totalCount: parseInt(response.headers['x-total'], 10),
error: null,
};
},
[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error) {
state.matches.groupMilestones = {
list: [],
totalCount: 0,
error,
};
},
};

View File

@ -1,6 +1,7 @@
export default () => ({
projectId: null,
groupId: null,
groupMilestonesAvailable: false,
searchQuery: '',
matches: {
projectMilestones: {
@ -8,6 +9,11 @@ export default () => ({
totalCount: 0,
error: null,
},
groupMilestones: {
list: [],
totalCount: 0,
error: null,
},
},
selectedMilestones: [],
requestCount: 0,

View File

@ -22,12 +22,8 @@ export default class NotificationsForm {
// eslint-disable-next-line class-methods-use-this
showCheckboxLoadingSpinner($parent) {
$parent
.addClass('is-loading')
.find('.custom-notification-event-loading')
.removeClass('fa-check')
.addClass('spinner align-middle')
.removeClass('is-done');
$parent.find('.is-loading').removeClass('gl-display-none');
$parent.find('.is-done').addClass('gl-display-none');
}
saveEvent($checkbox, $parent) {
@ -39,14 +35,11 @@ export default class NotificationsForm {
.then(({ data }) => {
$checkbox.enable();
if (data.saved) {
$parent
.find('.custom-notification-event-loading')
.toggleClass('spinner fa-check is-done align-middle');
$parent.find('.is-loading').addClass('gl-display-none');
$parent.find('.is-done').removeClass('gl-display-none');
setTimeout(() => {
$parent
.removeClass('is-loading')
.find('.custom-notification-event-loading')
.toggleClass('spinner fa-check is-done align-middle');
$parent.find('.is-done').addClass('gl-display-none');
}, 2000);
}
})

View File

@ -25,21 +25,25 @@ const SHARED_FIELDS = ['account', 'expires', 'maxRole', 'expiration', 'actions']
initGroupMembersApp(
document.querySelector('.js-group-members-list'),
SHARED_FIELDS.concat(['source', 'granted']),
{ tr: { 'data-qa-selector': 'member_row' } },
memberRequestFormatter,
);
initGroupMembersApp(
document.querySelector('.js-group-linked-list'),
SHARED_FIELDS.concat('granted'),
{ table: { 'data-qa-selector': 'groups_list' }, tr: { 'data-qa-selector': 'group_row' } },
groupLinkRequestFormatter,
);
initGroupMembersApp(
document.querySelector('.js-group-invited-members-list'),
SHARED_FIELDS.concat('invited'),
{},
memberRequestFormatter,
);
initGroupMembersApp(
document.querySelector('.js-group-access-requests-list'),
SHARED_FIELDS.concat('requested'),
{},
memberRequestFormatter,
);

View File

@ -34,6 +34,8 @@ export default {
'newMilestonePath',
'manageMilestonesPath',
'projectId',
'groupId',
'groupMilestonesAvailable',
]),
...mapGetters('detail', ['isValid', 'isExistingRelease']),
showForm() {
@ -141,6 +143,8 @@ export default {
<milestone-combobox
v-model="releaseMilestones"
:project-id="projectId"
:group-id="groupId"
:group-milestones-available="groupMilestonesAvailable"
:extra-links="milestoneComboboxExtraLinks"
/>
</div>

View File

@ -1,5 +1,7 @@
export default ({
projectId,
groupId,
groupMilestonesAvailable = false,
projectPath,
markdownDocsPath,
markdownPreviewPath,
@ -13,6 +15,8 @@ export default ({
defaultBranch = null,
}) => ({
projectId,
groupId,
groupMilestonesAvailable: Boolean(groupMilestonesAvailable),
projectPath,
markdownDocsPath,
markdownPreviewPath,

View File

@ -39,7 +39,7 @@ export default {
),
},
computed: {
...mapState(['members', 'tableFields', 'currentUserId', 'sourceId']),
...mapState(['members', 'tableFields', 'tableAttrs', 'currentUserId', 'sourceId']),
filteredFields() {
return FIELDS.filter(field => this.tableFields.includes(field.key) && this.showField(field));
},
@ -79,6 +79,7 @@ export default {
<template>
<div>
<gl-table
v-bind="tableAttrs.table"
class="members-table"
data-testid="members-table"
head-variant="white"
@ -89,6 +90,7 @@ export default {
thead-class="border-bottom"
:empty-text="__('No members found')"
show-empty
:tbody-tr-attr="tableAttrs.tr"
>
<template #cell(account)="{ item: member }">
<members-table-cell #default="{ memberType, isCurrentUser }" :member="member">

View File

@ -35,6 +35,14 @@ export default {
},
mounted() {
this.isDesktop = bp.isDesktop();
// Bootstrap Vue and GlDropdown to not support adding attributes to the dropdown toggle
// This can be changed once https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1060 is implemented
const dropdownToggle = this.$refs.glDropdown.$el.querySelector('.dropdown-toggle');
if (dropdownToggle) {
dropdownToggle.setAttribute('data-qa-selector', 'access_level_dropdown');
}
},
methods: {
...mapActions(['updateMemberRole']),
@ -63,6 +71,7 @@ export default {
<template>
<gl-dropdown
ref="glDropdown"
:right="!isDesktop"
:text="member.accessLevel.stringValue"
:header-text="__('Change permissions')"
@ -73,6 +82,7 @@ export default {
:key="value"
is-check-item
:is-checked="value === member.accessLevel.integerValue"
data-qa-selector="access_level_link"
@click="handleSelect(value, name)"
>
{{ name }}

View File

@ -179,7 +179,10 @@ export default {
</template>
<template v-if="!instructionsEmpty">
<div class="gl-display-flex">
<pre class="bg-light gl-flex-fill-1" data-testid="binary-instructions">
<pre
class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
data-testid="binary-instructions"
>
{{ instructions.installInstructions }}
</pre>
<gl-button
@ -196,7 +199,10 @@ export default {
<h5 class="gl-mb-5">{{ $options.i18n.registerRunner }}</h5>
<h5 class="gl-mb-5">{{ $options.i18n.method }}</h5>
<div class="gl-display-flex">
<pre class="bg-light gl-flex-fill-1" data-testid="runner-instructions">
<pre
class="bg-light gl-flex-fill-1 gl-white-space-pre-line"
data-testid="runner-instructions"
>
{{ instructions.registerInstructions }}
</pre>
<gl-button

View File

@ -3,6 +3,7 @@ export default ({
sourceId,
currentUserId,
tableFields,
tableAttrs,
memberPath,
requestFormatter,
}) => ({
@ -10,6 +11,7 @@ export default ({
sourceId,
currentUserId,
tableFields,
tableAttrs,
memberPath,
requestFormatter,
showError: false,

View File

@ -993,23 +993,6 @@ pre.light-well {
}
}
.custom-notifications-form {
.is-loading {
.custom-notification-event-loading {
display: inline-block;
}
}
}
.custom-notification-event-loading {
display: none;
margin-left: 5px;
&.is-done {
color: $green-600;
}
}
.project-refs-form .dropdown-menu,
.dropdown-menu-projects {
width: 300px;

View File

@ -5,19 +5,20 @@ module Resolvers
class SentryDetailedErrorResolver < BaseResolver
type Types::ErrorTracking::SentryDetailedErrorType, null: true
argument :id, GraphQL::ID_TYPE,
argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError],
required: true,
description: 'ID of the Sentry issue'
def resolve(**args)
current_user = context[:current_user]
issue_id = GlobalID.parse(args[:id])&.model_id
def resolve(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError].coerce_isolated_input(id)
# Get data from Sentry
response = ::ErrorTracking::IssueDetailsService.new(
project,
current_user,
{ issue_id: issue_id }
{ issue_id: id.model_id }
).execute
issue = response[:issue]
issue.gitlab_project = project if issue

View File

@ -3,18 +3,20 @@
module Resolvers
module ErrorTracking
class SentryErrorStackTraceResolver < BaseResolver
argument :id, GraphQL::ID_TYPE,
argument :id, ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError],
required: true,
description: 'ID of the Sentry issue'
def resolve(**args)
issue_id = GlobalID.parse(args[:id])&.model_id
def resolve(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::Gitlab::ErrorTracking::DetailedError].coerce_isolated_input(id)
# Get data from Sentry
response = ::ErrorTracking::IssueLatestEventService.new(
project,
current_user,
{ issue_id: issue_id }
{ issue_id: id.model_id }
).execute
event = response[:latest_event]

View File

@ -0,0 +1,21 @@
# frozen_string_literal: true
module Types
class ContainerRepositoryDetailsType < Types::ContainerRepositoryType
graphql_name 'ContainerRepositoryDetails'
description 'Details of a container repository'
authorize :read_container_image
field :tags,
Types::ContainerRepositoryTagType.connection_type,
null: true,
description: 'Tags of the container repository',
max_page_size: 20
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
end
end
end

View File

@ -0,0 +1,25 @@
# frozen_string_literal: true
module Types
class ContainerRepositoryTagType < BaseObject
graphql_name 'ContainerRepositoryTag'
description 'A tag from a container repository'
authorize :read_container_image
field :name, GraphQL::STRING_TYPE, null: false, description: 'Name of the tag.'
field :path, GraphQL::STRING_TYPE, null: false, description: 'Path of the tag.'
field :location, GraphQL::STRING_TYPE, null: false, description: 'URL of the tag.'
field :digest, GraphQL::STRING_TYPE, null: false, description: 'Digest of the tag.'
field :revision, GraphQL::STRING_TYPE, null: false, description: 'Revision of the tag.'
field :short_revision, GraphQL::STRING_TYPE, null: false, description: 'Short revision of the tag.'
field :total_size, GraphQL::INT_TYPE, null: false, description: 'The size of the tag.'
field :created_at, Types::TimeType, null: false, description: 'Timestamp when the tag was created.'
field :can_delete, GraphQL::BOOLEAN_TYPE, null: false, description: 'Can the current user delete this tag.'
def can_delete
Ability.allowed?(current_user, :destroy_container_image, object)
end
end
end

View File

@ -296,8 +296,7 @@ module Types
Types::ContainerRepositoryType.connection_type,
null: true,
description: 'Container repositories of the project',
resolver: Resolvers::ContainerRepositoriesResolver,
authorize: :read_container_image
resolver: Resolvers::ContainerRepositoriesResolver
field :label,
Types::LabelType,

View File

@ -50,10 +50,14 @@ module Types
field :milestone, ::Types::MilestoneType,
null: true,
description: 'Find a milestone' do
argument :id, ::Types::GlobalIDType[Milestone],
required: true,
description: 'Find a milestone by its ID'
end
argument :id, ::Types::GlobalIDType[Milestone], required: true, description: 'Find a milestone by its ID'
end
field :container_repository, Types::ContainerRepositoryDetailsType,
null: true,
description: 'Find a container repository' do
argument :id, ::Types::GlobalIDType[::ContainerRepository], required: true, description: 'The global ID of the container repository'
end
field :user, Types::UserType,
null: true,
@ -105,6 +109,13 @@ module Types
id = ::Types::GlobalIDType[Milestone].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
def container_repository(id:)
# TODO: remove this line when the compatibility layer is removed
# See: https://gitlab.com/gitlab-org/gitlab/-/issues/257883
id = ::Types::GlobalIDType[::ContainerRepository].coerce_isolated_input(id)
GitlabSchema.find_by_gid(id)
end
end
end

View File

@ -51,11 +51,17 @@ module ReleasesHelper
)
end
def group_milestone_project_releases_available?(project)
false
end
private
def new_edit_pages_shared_data
{
project_id: @project.id,
group_id: @project.group&.id,
group_milestones_available: group_milestone_project_releases_available?(@project),
project_path: @project.full_path,
markdown_preview_path: preview_markdown_path(@project),
markdown_docs_path: help_page_path('user/markdown'),
@ -66,3 +72,5 @@ module ReleasesHelper
}
end
end
ReleasesHelper.prepend_if_ee('EE::ReleasesHelper')

View File

@ -362,7 +362,7 @@ class Namespace < ApplicationRecord
def pages_virtual_domain
Pages::VirtualDomain.new(
all_projects_with_pages.includes(:route, :project_feature),
all_projects_with_pages.includes(:route, :project_feature, pages_metadatum: :pages_deployment),
trim_prefix: full_path
)
end

View File

@ -22,11 +22,7 @@ module Pages
end
def source
if artifacts_archive && !artifacts_archive.file_storage?
zip_source
else
file_source
end
zip_source || file_source
end
def prefix
@ -42,19 +38,39 @@ module Pages
attr_reader :project, :trim_prefix, :domain
def artifacts_archive
return unless Feature.enabled?(:pages_artifacts_archive, project)
return unless Feature.enabled?(:pages_serve_from_artifacts_archive, project)
# Using build artifacts is temporary solution for quick test
# in production environment, we'll replace this with proper
# `pages_deployments` later
project.pages_metadatum.artifacts_archive&.file
archive = project.pages_metadatum.artifacts_archive
archive&.file
end
def deployment
return unless Feature.enabled?(:pages_serve_from_deployments, project)
deployment = project.pages_metadatum.pages_deployment
deployment&.file
end
def zip_source
{
type: 'zip',
path: artifacts_archive.url(expire_at: 1.day.from_now)
}
source = deployment || artifacts_archive
return unless source
if source.file_storage?
return unless Feature.enabled?(:pages_serve_with_zip_file_protocol, project)
{
type: 'zip',
path: 'file://' + source.path
}
else
{
type: 'zip',
path: source.url(expire_at: 1.day.from_now)
}
end
end
def file_source

View File

@ -21,6 +21,10 @@ class PagesDeployment < ApplicationRecord
mount_file_store_uploader ::Pages::DeploymentUploader
def log_geo_deleted_event
# this is to be adressed in https://gitlab.com/groups/gitlab-org/-/epics/589
end
private
def set_size

View File

@ -908,11 +908,10 @@ class User < ApplicationRecord
# Returns the groups a user has access to, either through a membership or a project authorization
def authorized_groups
Group.unscoped do
Group.from_union([
groups,
authorized_projects.joins(:namespace).select('namespaces.*')
])
if Feature.enabled?(:shared_group_membership_auth, self)
authorized_groups_with_shared_membership
else
authorized_groups_without_shared_membership
end
end
@ -1807,6 +1806,26 @@ class User < ApplicationRecord
private
def authorized_groups_without_shared_membership
Group.from_union([
groups,
authorized_projects.joins(:namespace).select('namespaces.*')
])
end
def authorized_groups_with_shared_membership
cte = Gitlab::SQL::CTE.new(:direct_groups, authorized_groups_without_shared_membership)
cte_alias = cte.table.alias(Group.table_name)
Group
.with(cte.to_arel)
.from_union([
Group.from(cte_alias),
Group.joins(:shared_with_group_links)
.where(group_group_links: { shared_with_group_id: Group.from(cte_alias) })
])
end
def default_private_profile_to_false
return unless private_profile_changed? && private_profile.nil?

View File

@ -0,0 +1,6 @@
# frozen_string_literal: true
module ContainerRegistry
class TagPolicy < BasePolicy
delegate { @subject.repository }
end
end

View File

@ -30,4 +30,5 @@
%label.form-check-label{ for: field_id }
%strong
= notification_event_name(event)
.fa.custom-notification-event-loading.spinner
%span.spinner.is-loading.gl-vertical-align-middle.gl-display-none
= sprite_icon('check', css_class: 'is-done gl-display-none gl-vertical-align-middle gl-text-green-600')

View File

@ -0,0 +1,5 @@
---
title: Container repository details GraphQL API
merge_request: 46560
author:
type: added

View File

@ -0,0 +1,5 @@
---
title: Use global IDs for GraphQL arguments accepting sentry IDs
merge_request: 36098
author:
type: changed

View File

@ -0,0 +1,5 @@
---
title: Replace fa-check icon in custom notifications
merge_request: 47288
author:
type: changed

View File

@ -1,8 +1,8 @@
---
name: pages_artifacts_archive
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/40361
rollout_issue_url:
name: pages_serve_from_artifacts_archive
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46320
rollout_issue_url:
group: group::release management
milestone: '13.4'
type: development
group: group::release management
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: pages_serve_from_deployments
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46320
rollout_issue_url: https://gitlab.com/gitlab-com/gl-infra/production/-/issues/2932
milestone: '13.6'
type: development
group: group::Release Management
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: pages_serve_with_zip_file_protocol
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46320
rollout_issue_url:
milestone: '13.6'
type: development
group: group::Release Management
default_enabled: false

View File

@ -0,0 +1,8 @@
---
name: shared_group_membership_auth
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/46412
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/224771
milestone: '13.6'
type: development
group: group::access
default_enabled: false

View File

@ -3344,6 +3344,86 @@ type ContainerRepositoryConnection {
pageInfo: PageInfo!
}
"""
Details of a container repository
"""
type ContainerRepositoryDetails {
"""
Can the current user delete the container repository.
"""
canDelete: Boolean!
"""
Timestamp when the container repository was created.
"""
createdAt: Time!
"""
Timestamp when the cleanup done by the expiration policy was started on the container repository.
"""
expirationPolicyStartedAt: Time
"""
ID of the container repository.
"""
id: ID!
"""
URL of the container repository.
"""
location: String!
"""
Name of the container repository.
"""
name: String!
"""
Path of the container repository.
"""
path: String!
"""
Status of the container repository.
"""
status: ContainerRepositoryStatus
"""
Tags of the container repository
"""
tags(
"""
Returns the elements in the list that come after the specified cursor.
"""
after: String
"""
Returns the elements in the list that come before the specified cursor.
"""
before: String
"""
Returns the first _n_ elements from the list.
"""
first: Int
"""
Returns the last _n_ elements from the list.
"""
last: Int
): ContainerRepositoryTagConnection
"""
Number of tags associated with this image.
"""
tagsCount: Int!
"""
Timestamp when the container repository was updated.
"""
updatedAt: Time!
}
"""
An edge in a connection.
"""
@ -3359,6 +3439,11 @@ type ContainerRepositoryEdge {
node: ContainerRepository
}
"""
Identifier of ContainerRepository
"""
scalar ContainerRepositoryID
"""
Status of a container repository
"""
@ -3374,6 +3459,91 @@ enum ContainerRepositoryStatus {
DELETE_SCHEDULED
}
"""
A tag from a container repository
"""
type ContainerRepositoryTag {
"""
Can the current user delete this tag.
"""
canDelete: Boolean!
"""
Timestamp when the tag was created.
"""
createdAt: Time!
"""
Digest of the tag.
"""
digest: String!
"""
URL of the tag.
"""
location: String!
"""
Name of the tag.
"""
name: String!
"""
Path of the tag.
"""
path: String!
"""
Revision of the tag.
"""
revision: String!
"""
Short revision of the tag.
"""
shortRevision: String!
"""
The size of the tag.
"""
totalSize: Int!
}
"""
The connection type for ContainerRepositoryTag.
"""
type ContainerRepositoryTagConnection {
"""
A list of edges.
"""
edges: [ContainerRepositoryTagEdge]
"""
A list of nodes.
"""
nodes: [ContainerRepositoryTag]
"""
Information to aid in pagination.
"""
pageInfo: PageInfo!
}
"""
An edge in a connection.
"""
type ContainerRepositoryTagEdge {
"""
A cursor for use in pagination.
"""
cursor: String!
"""
The item at the end of the edge.
"""
node: ContainerRepositoryTag
}
"""
Autogenerated input type of CreateAlertIssue
"""
@ -8097,6 +8267,11 @@ type GeoNode {
verificationMaxCapacity: Int
}
"""
Identifier of Gitlab::ErrorTracking::DetailedError
"""
scalar GitlabErrorTrackingDetailedErrorID
type GrafanaIntegration {
"""
Timestamp of the issue's creation
@ -15911,7 +16086,7 @@ type Project {
"""
ID of the Sentry issue
"""
id: ID!
id: GitlabErrorTrackingDetailedErrorID!
): SentryDetailedError
"""
@ -16810,6 +16985,16 @@ type PromoteToEpicPayload {
}
type Query {
"""
Find a container repository
"""
containerRepository(
"""
The global ID of the container repository
"""
id: ContainerRepositoryID!
): ContainerRepositoryDetails
"""
Get information about current user
"""
@ -19300,7 +19485,7 @@ type SentryErrorCollection {
"""
ID of the Sentry issue
"""
id: ID!
id: GitlabErrorTrackingDetailedErrorID!
): SentryDetailedError
"""
@ -19310,7 +19495,7 @@ type SentryErrorCollection {
"""
ID of the Sentry issue
"""
id: ID!
id: GitlabErrorTrackingDetailedErrorID!
): SentryErrorStackTrace
"""

View File

@ -9074,6 +9074,244 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ContainerRepositoryDetails",
"description": "Details of a container repository",
"fields": [
{
"name": "canDelete",
"description": "Can the current user delete the container repository.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp when the container repository was created.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "expirationPolicyStartedAt",
"description": "Timestamp when the cleanup done by the expiration policy was started on the container repository.",
"args": [
],
"type": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "id",
"description": "ID of the container repository.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "location",
"description": "URL of the container repository.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the container repository.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "path",
"description": "Path of the container repository.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "status",
"description": "Status of the container repository.",
"args": [
],
"type": {
"kind": "ENUM",
"name": "ContainerRepositoryStatus",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "tags",
"description": "Tags of the container repository",
"args": [
{
"name": "after",
"description": "Returns the elements in the list that come after the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "before",
"description": "Returns the elements in the list that come before the specified cursor.",
"type": {
"kind": "SCALAR",
"name": "String",
"ofType": null
},
"defaultValue": null
},
{
"name": "first",
"description": "Returns the first _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
},
{
"name": "last",
"description": "Returns the last _n_ elements from the list.",
"type": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ContainerRepositoryTagConnection",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "tagsCount",
"description": "Number of tags associated with this image.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "updatedAt",
"description": "Timestamp when the container repository was updated.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ContainerRepositoryEdge",
@ -9119,6 +9357,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "ContainerRepositoryID",
"description": "Identifier of ContainerRepository",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "ENUM",
"name": "ContainerRepositoryStatus",
@ -9142,6 +9390,293 @@
],
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ContainerRepositoryTag",
"description": "A tag from a container repository",
"fields": [
{
"name": "canDelete",
"description": "Can the current user delete this tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Boolean",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "createdAt",
"description": "Timestamp when the tag was created.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Time",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "digest",
"description": "Digest of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "location",
"description": "URL of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "name",
"description": "Name of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "path",
"description": "Path of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "revision",
"description": "Revision of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "shortRevision",
"description": "Short revision of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "totalSize",
"description": "The size of the tag.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "Int",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ContainerRepositoryTagConnection",
"description": "The connection type for ContainerRepositoryTag.",
"fields": [
{
"name": "edges",
"description": "A list of edges.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ContainerRepositoryTagEdge",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "nodes",
"description": "A list of nodes.",
"args": [
],
"type": {
"kind": "LIST",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "ContainerRepositoryTag",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "pageInfo",
"description": "Information to aid in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "OBJECT",
"name": "PageInfo",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "ContainerRepositoryTagEdge",
"description": "An edge in a connection.",
"fields": [
{
"name": "cursor",
"description": "A cursor for use in pagination.",
"args": [
],
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "String",
"ofType": null
}
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "node",
"description": "The item at the end of the edge.",
"args": [
],
"type": {
"kind": "OBJECT",
"name": "ContainerRepositoryTag",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
}
],
"inputFields": null,
"interfaces": [
],
"enumValues": null,
"possibleTypes": null
},
{
"kind": "INPUT_OBJECT",
"name": "CreateAlertIssueInput",
@ -22356,6 +22891,16 @@
"enumValues": null,
"possibleTypes": null
},
{
"kind": "SCALAR",
"name": "GitlabErrorTrackingDetailedErrorID",
"description": "Identifier of Gitlab::ErrorTracking::DetailedError",
"fields": null,
"inputFields": null,
"interfaces": null,
"enumValues": null,
"possibleTypes": null
},
{
"kind": "OBJECT",
"name": "GrafanaIntegration",
@ -46175,7 +46720,7 @@
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"name": "GitlabErrorTrackingDetailedErrorID",
"ofType": null
}
},
@ -48884,6 +49429,33 @@
"name": "Query",
"description": null,
"fields": [
{
"name": "containerRepository",
"description": "Find a container repository",
"args": [
{
"name": "id",
"description": "The global ID of the container repository",
"type": {
"kind": "NON_NULL",
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ContainerRepositoryID",
"ofType": null
}
},
"defaultValue": null
}
],
"type": {
"kind": "OBJECT",
"name": "ContainerRepositoryDetails",
"ofType": null
},
"isDeprecated": false,
"deprecationReason": null
},
{
"name": "currentUser",
"description": "Get information about current user",
@ -55814,7 +56386,7 @@
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"name": "GitlabErrorTrackingDetailedErrorID",
"ofType": null
}
},
@ -55841,7 +56413,7 @@
"name": null,
"ofType": {
"kind": "SCALAR",
"name": "ID",
"name": "GitlabErrorTrackingDetailedErrorID",
"ofType": null
}
},

View File

@ -541,6 +541,40 @@ A container repository.
| `tagsCount` | Int! | Number of tags associated with this image. |
| `updatedAt` | Time! | Timestamp when the container repository was updated. |
### ContainerRepositoryDetails
Details of a container repository.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `canDelete` | Boolean! | Can the current user delete the container repository. |
| `createdAt` | Time! | Timestamp when the container repository was created. |
| `expirationPolicyStartedAt` | Time | Timestamp when the cleanup done by the expiration policy was started on the container repository. |
| `id` | ID! | ID of the container repository. |
| `location` | String! | URL of the container repository. |
| `name` | String! | Name of the container repository. |
| `path` | String! | Path of the container repository. |
| `status` | ContainerRepositoryStatus | Status of the container repository. |
| `tags` | ContainerRepositoryTagConnection | Tags of the container repository |
| `tagsCount` | Int! | Number of tags associated with this image. |
| `updatedAt` | Time! | Timestamp when the container repository was updated. |
### ContainerRepositoryTag
A tag from a container repository.
| Field | Type | Description |
| ----- | ---- | ----------- |
| `canDelete` | Boolean! | Can the current user delete this tag. |
| `createdAt` | Time! | Timestamp when the tag was created. |
| `digest` | String! | Digest of the tag. |
| `location` | String! | URL of the tag. |
| `name` | String! | Name of the tag. |
| `path` | String! | Path of the tag. |
| `revision` | String! | Revision of the tag. |
| `shortRevision` | String! | Short revision of the tag. |
| `totalSize` | Int! | The size of the tag. |
### CreateAlertIssuePayload
Autogenerated return type of CreateAlertIssue.

View File

@ -130,6 +130,8 @@ In the interface, to add release notes to an existing Git tag:
You can associate a release with one or more [project milestones](../milestones/index.md#project-milestones-and-group-milestones).
[GitLab Premium](https://about.gitlab.com/pricing/) customers can specify [group milestones](../milestones/index.md#project-milestones-and-group-milestones) to associate with a release.
You can do this in the user interface, or by including a `milestones` array in your request to
the [Releases API](../../../api/releases/index.md#create-a-release).

View File

@ -17388,6 +17388,9 @@ msgstr ""
msgid "MilestoneCombobox|An error occurred while searching for milestones"
msgstr ""
msgid "MilestoneCombobox|Group milestones"
msgstr ""
msgid "MilestoneCombobox|Milestone"
msgstr ""

View File

@ -44,7 +44,7 @@
"@babel/preset-env": "^7.10.1",
"@gitlab/at.js": "1.5.5",
"@gitlab/svgs": "1.175.0",
"@gitlab/ui": "23.3.0",
"@gitlab/ui": "23.4.0",
"@gitlab/visual-review-tools": "1.6.1",
"@rails/actioncable": "^6.0.3-3",
"@rails/ujs": "^6.0.3-2",

View File

@ -16,17 +16,24 @@ module QA
element :invite_member_button
end
view 'app/views/shared/members/_member.html.haml' do
view 'app/assets/javascripts/pages/groups/group_members/index.js' do
element :member_row
element :groups_list
element :group_row
end
view 'app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue' do
element :access_level_dropdown
element :access_level_link
end
view 'app/assets/javascripts/vue_shared/components/members/action_buttons/remove_member_button.vue' do
element :delete_member_button
element :developer_access_level_link, 'qa_selector: "#{role.downcase}_access_level_link"' # rubocop:disable QA/ElementWithPattern, Lint/InterpolationCheck
end
view 'app/views/groups/group_members/index.html.haml' do
element :invite_group_tab
element :groups_list_tab
element :groups_list
end
view 'app/views/shared/members/_invite_group.html.haml' do
@ -34,10 +41,6 @@ module QA
element :invite_group_button
end
view 'app/views/shared/members/_group.html.haml' do
element :group_row
end
def select_group(group_name)
click_element :group_select_field
search_and_select(group_name)
@ -57,7 +60,7 @@ module QA
def update_access_level(username, access_level)
within_element(:member_row, text: username) do
click_element :access_level_dropdown
click_element "#{access_level.downcase}_access_level_link"
click_element :access_level_link, text: access_level
end
end

View File

@ -3,8 +3,8 @@
require 'spec_helper'
RSpec.describe GroupDescendantsFinder do
let(:user) { create(:user) }
let(:group) { create(:group) }
let_it_be(:user) { create(:user) }
let_it_be(:group) { create(:group) }
let(:params) { {} }
subject(:finder) do
@ -129,6 +129,39 @@ RSpec.describe GroupDescendantsFinder do
end
end
context 'with shared groups' do
let_it_be(:other_group) { create(:group) }
let_it_be(:shared_group_link) do
create(:group_group_link,
shared_group: group,
shared_with_group: other_group)
end
context 'without common ancestor' do
it { expect(finder.execute).to be_empty }
end
context 'with common ancestor' do
let_it_be(:common_ancestor) { create(:group) }
let_it_be(:other_group) { create(:group, parent: common_ancestor) }
let_it_be(:group) { create(:group, parent: common_ancestor) }
context 'querying under the common ancestor' do
it { expect(finder.execute).to be_empty }
end
context 'querying the common ancestor' do
subject(:finder) do
described_class.new(current_user: user, parent_group: common_ancestor, params: params)
end
it 'contains shared subgroups' do
expect(finder.execute).to contain_exactly(group, other_group)
end
end
end
end
context 'with nested groups' do
let!(:project) { create(:project, namespace: group) }
let!(:subgroup) { create(:group, :private, parent: group) }

View File

@ -0,0 +1,78 @@
{
"type": "object",
"required": ["id", "name", "path", "location", "createdAt", "updatedAt", "tagsCount", "canDelete", "tags"],
"properties": {
"id": {
"type": "string"
},
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"location": {
"type": "string"
},
"createdAt": {
"type": "string"
},
"updatedAt": {
"type": "string"
},
"expirationPolicyStartedAt": {
"type": ["string", "null"]
},
"status": {
"type": ["string", "null"]
},
"tagsCount": {
"type": "integer"
},
"canDelete": {
"type": "boolean"
},
"tags": {
"type": "object",
"required": ["nodes"],
"properties": {
"nodes": {
"type": "array",
"items": {
"type": "object",
"required": ["name", "path", "location", "digest", "revision", "shortRevision", "totalSize", "createdAt", "canDelete"],
"properties": {
"name": {
"type": "string"
},
"path": {
"type": "string"
},
"location": {
"type": "string"
},
"digest": {
"type": "string"
},
"revision": {
"type": "string"
},
"shortRevision": {
"type": "string"
},
"totalSize": {
"type": "integer"
},
"createdAt": {
"type": "string"
},
"canDelete": {
"type": "boolean"
}
}
}
}
}
}
}
}

View File

@ -14,7 +14,7 @@
"source": { "type": "object",
"required": ["type", "path"],
"properties" : {
"type": { "type": "string", "enum": ["file"] },
"type": { "type": "string", "enum": ["file", "zip", "zip_local"] },
"path": { "type": "string" }
},
"additionalProperties": false

View File

@ -9,7 +9,12 @@ describe('initGroupMembersApp', () => {
let wrapper;
const setup = () => {
vm = initGroupMembersApp(el, ['account'], () => ({}));
vm = initGroupMembersApp(
el,
['account'],
{ table: { 'data-qa-selector': 'members_list' } },
() => ({}),
);
wrapper = createWrapper(vm);
};
@ -68,6 +73,12 @@ describe('initGroupMembersApp', () => {
expect(vm.$store.state.tableFields).toEqual(['account']);
});
it('sets `tableAttrs` in Vuex store', () => {
setup();
expect(vm.$store.state.tableAttrs).toEqual({ table: { 'data-qa-selector': 'members_list' } });
});
it('sets `requestFormatter` in Vuex store', () => {
setup();

View File

@ -6,7 +6,7 @@ import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { s__ } from '~/locale';
import { ENTER_KEY } from '~/lib/utils/keys';
import MilestoneCombobox from '~/milestones/components/milestone_combobox.vue';
import { milestones as projectMilestones } from './mock_data';
import { projectMilestones, groupMilestones } from './mock_data';
import createStore from '~/milestones/stores/';
const extraLinks = [
@ -19,16 +19,21 @@ localVue.use(Vuex);
describe('Milestone combobox component', () => {
const projectId = '8';
const groupId = '24';
const groupMilestonesAvailable = true;
const X_TOTAL_HEADER = 'x-total';
let wrapper;
let projectMilestonesApiCallSpy;
let groupMilestonesApiCallSpy;
let searchApiCallSpy;
const createComponent = (props = {}, attrs = {}) => {
wrapper = mount(MilestoneCombobox, {
propsData: {
projectId,
groupId,
groupMilestonesAvailable,
extraLinks,
value: [],
...props,
@ -56,6 +61,10 @@ describe('Milestone combobox component', () => {
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, groupMilestones, { [X_TOTAL_HEADER]: '6' }]);
searchApiCallSpy = jest
.fn()
.mockReturnValue([200, projectMilestones, { [X_TOTAL_HEADER]: '6' }]);
@ -64,6 +73,10 @@ describe('Milestone combobox component', () => {
.onGet(`/api/v4/projects/${projectId}/milestones`)
.reply(config => projectMilestonesApiCallSpy(config));
mock
.onGet(`/api/v4/groups/${groupId}/milestones`)
.reply(config => groupMilestonesApiCallSpy(config));
mock.onGet(`/api/v4/projects/${projectId}/search`).reply(config => searchApiCallSpy(config));
});
@ -89,6 +102,11 @@ describe('Milestone combobox component', () => {
findProjectMilestonesSection().findAll(GlDropdownItem);
const findFirstProjectMilestonesDropdownItem = () => findProjectMilestonesDropdownItems().at(0);
const findGroupMilestonesSection = () => wrapper.find('[data-testid="group-milestones-section"]');
const findGroupMilestonesDropdownItems = () =>
findGroupMilestonesSection().findAll(GlDropdownItem);
const findFirstGroupMilestonesDropdownItem = () => findGroupMilestonesDropdownItems().at(0);
//
// Expecters
//
@ -100,6 +118,14 @@ describe('Milestone combobox component', () => {
.includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
};
const groupMilestoneSectionContainsErrorMessage = () => {
const groupMilestoneSection = findGroupMilestonesSection();
return groupMilestoneSection
.text()
.includes(s__('MilestoneCombobox|An error occurred while searching for milestones'));
};
//
// Convenience methods
//
@ -111,19 +137,25 @@ describe('Milestone combobox component', () => {
findFirstProjectMilestonesDropdownItem().vm.$emit('click');
};
const selectFirstGroupMilestone = () => {
findFirstGroupMilestonesDropdownItem().vm.$emit('click');
};
const waitForRequests = ({ andClearMocks } = { andClearMocks: false }) =>
axios.waitForAll().then(() => {
if (andClearMocks) {
projectMilestonesApiCallSpy.mockClear();
groupMilestonesApiCallSpy.mockClear();
}
});
describe('initialization behavior', () => {
beforeEach(createComponent);
it('initializes the dropdown with project milestones when mounted', () => {
it('initializes the dropdown with milestones when mounted', () => {
return waitForRequests().then(() => {
expect(projectMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
expect(groupMilestonesApiCallSpy).toHaveBeenCalledTimes(1);
});
});
@ -166,7 +198,7 @@ describe('Milestone combobox component', () => {
return waitForRequests();
});
it('renders the pre-selected project milestones', () => {
it('renders the pre-selected milestones', () => {
expect(findButtonContent().text()).toBe('v0.1 + 5 more');
});
});
@ -209,6 +241,8 @@ describe('Milestone combobox component', () => {
.fn()
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
groupMilestonesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
@ -288,65 +322,195 @@ describe('Milestone combobox component', () => {
expect(projectMilestoneSectionContainsErrorMessage()).toBe(true);
});
});
});
describe('selection', () => {
beforeEach(() => {
createComponent();
describe('selection', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
return waitForRequests();
});
it('renders a checkmark by the selected item', async () => {
selectFirstProjectMilestone();
it('renders a checkmark by the selected item', async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
await localVue.nextTick();
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
selectFirstProjectMilestone();
selectFirstProjectMilestone();
await localVue.nextTick();
return localVue.nextTick().then(() => {
expect(
findFirstProjectMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(true);
});
});
describe('when a project milestones is selected', () => {
describe('when a project milestones is selected', () => {
beforeEach(() => {
createComponent();
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
return waitForRequests();
});
it("displays the project milestones name in the dropdown's button", async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('v1.0');
});
it('updates the v-model binding with the project milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
selectFirstProjectMilestone();
expect(wrapper.vm.value).toEqual(['v1.0']);
});
});
});
});
describe('group milestones', () => {
describe('when the group milestones search returns results', () => {
beforeEach(() => {
createComponent();
projectMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
return waitForRequests();
});
it("displays the project milestones name in the dropdown's button", async () => {
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstProjectMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('v1.0');
it('renders the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(true);
});
it('updates the v-model binding with the project milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
it('renders the "Group milestones" heading with a total number indicator', () => {
expect(
findGroupMilestonesSection()
.find('[data-testid="milestone-results-section-header"]')
.text(),
).toBe('Group milestones 6');
});
selectFirstProjectMilestone();
it("does not render an error message in the group milestone section's body", () => {
expect(groupMilestoneSectionContainsErrorMessage()).toBe(false);
});
expect(wrapper.vm.value).toEqual(['v1.0']);
it('renders each group milestones as a selectable item', () => {
const dropdownItems = findGroupMilestonesDropdownItems();
groupMilestones.forEach((milestone, i) => {
expect(dropdownItems.at(i).text()).toBe(milestone.title);
});
});
});
describe('when the group milestones search returns no results', () => {
beforeEach(() => {
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
createComponent();
return waitForRequests();
});
it('does not render the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(false);
});
});
describe('when the group milestones search returns an error', () => {
beforeEach(() => {
groupMilestonesApiCallSpy = jest.fn().mockReturnValue([500]);
searchApiCallSpy = jest.fn().mockReturnValue([500]);
createComponent({ value: [] });
return waitForRequests();
});
it('renders the group milestones section in the dropdown', () => {
expect(findGroupMilestonesSection().exists()).toBe(true);
});
it("renders an error message in the group milestones section's body", () => {
expect(groupMilestoneSectionContainsErrorMessage()).toBe(true);
});
});
describe('selection', () => {
beforeEach(() => {
createComponent();
return waitForRequests();
});
it('renders a checkmark by the selected item', async () => {
selectFirstGroupMilestone();
await localVue.nextTick();
expect(
findFirstGroupMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(false);
selectFirstGroupMilestone();
await localVue.nextTick();
expect(
findFirstGroupMilestonesDropdownItem()
.find('span')
.classes('selected-item'),
).toBe(true);
});
describe('when a group milestones is selected', () => {
beforeEach(() => {
createComponent();
groupMilestonesApiCallSpy = jest
.fn()
.mockReturnValue([200, [{ title: 'group-v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
return waitForRequests();
});
it("displays the group milestones name in the dropdown's button", async () => {
selectFirstGroupMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
selectFirstGroupMilestone();
await localVue.nextTick();
expect(findButtonContent().text()).toBe('group-v1.0');
});
it('updates the v-model binding with the group milestone title', () => {
expect(wrapper.vm.value).toEqual([]);
selectFirstGroupMilestone();
expect(wrapper.vm.value).toEqual(['group-v1.0']);
});
});
});
});

View File

@ -1,4 +1,4 @@
export const milestones = [
export const projectMilestones = [
{
id: 41,
iid: 6,
@ -79,4 +79,94 @@ export const milestones = [
},
];
export default milestones;
export const groupMilestones = [
{
id: 141,
iid: 16,
project_id: 8,
group_id: 12,
title: 'group-v0.1',
description: '',
state: 'active',
created_at: '2020-04-04T01:30:40.051Z',
updated_at: '2020-04-04T01:30:40.051Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6',
},
{
id: 140,
iid: 15,
project_id: 8,
group_id: 12,
title: 'group-v4.0',
description: 'Laboriosam nisi sapiente dolores et magnam nobis ad earum.',
state: 'closed',
created_at: '2020-01-13T19:39:15.191Z',
updated_at: '2020-01-13T19:39:15.191Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/5',
},
{
id: 139,
iid: 14,
project_id: 8,
group_id: 12,
title: 'group-v3.0',
description: 'Necessitatibus illo alias et repellat dolorum assumenda ut.',
state: 'closed',
created_at: '2020-01-13T19:39:15.176Z',
updated_at: '2020-01-13T19:39:15.176Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/4',
},
{
id: 138,
iid: 13,
project_id: 8,
group_id: 12,
title: 'group-v2.0',
description: 'Doloribus qui repudiandae iste sit.',
state: 'closed',
created_at: '2020-01-13T19:39:15.161Z',
updated_at: '2020-01-13T19:39:15.161Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/3',
},
{
id: 137,
iid: 12,
project_id: 8,
group_id: 12,
title: 'group-v1.0',
description: 'Illo sint odio officia ea.',
state: 'closed',
created_at: '2020-01-13T19:39:15.146Z',
updated_at: '2020-01-13T19:39:15.146Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/2',
},
{
id: 136,
iid: 11,
project_id: 8,
group_id: 12,
title: 'group-v0.0',
description: 'Sed quae facilis deleniti at delectus assumenda nobis veritatis.',
state: 'active',
created_at: '2020-01-13T19:39:15.127Z',
updated_at: '2020-01-13T19:39:15.127Z',
due_date: null,
start_date: null,
web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/1',
},
];
export default {
projectMilestones,
groupMilestones,
};

View File

@ -4,6 +4,7 @@ import * as actions from '~/milestones/stores/actions';
import * as types from '~/milestones/stores/mutation_types';
let mockProjectMilestonesReturnValue;
let mockGroupMilestonesReturnValue;
let mockProjectSearchReturnValue;
jest.mock('~/api', () => ({
@ -13,6 +14,7 @@ jest.mock('~/api', () => ({
default: {
projectMilestones: () => mockProjectMilestonesReturnValue,
projectSearch: () => mockProjectSearchReturnValue,
groupMilestones: () => mockGroupMilestonesReturnValue,
},
}));
@ -32,6 +34,24 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
describe('setGroupId', () => {
it(`commits ${types.SET_GROUP_ID} with the new group ID`, () => {
const groupId = '123';
testAction(actions.setGroupId, groupId, state, [
{ type: types.SET_GROUP_ID, payload: groupId },
]);
});
});
describe('setGroupMilestonesAvailable', () => {
it(`commits ${types.SET_GROUP_MILESTONES_AVAILABLE} with the boolean indicating if group milestones are available (Premium)`, () => {
state.groupMilestonesAvailable = true;
testAction(actions.setGroupMilestonesAvailable, state.groupMilestonesAvailable, state, [
{ type: types.SET_GROUP_MILESTONES_AVAILABLE, payload: state.groupMilestonesAvailable },
]);
});
});
describe('setSelectedMilestones', () => {
it(`commits ${types.SET_SELECTED_MILESTONES} with the new selected milestones name`, () => {
const selectedMilestones = ['v1.2.3'];
@ -66,19 +86,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
describe('search', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query`, () => {
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
state,
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchMilestones' }],
);
describe('when project has license to add group milestones', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project and group milestones`, () => {
const getters = {
groupMilestonesEnabled: () => true,
};
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
{ ...state, ...getters },
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchProjectMilestones' }, { type: 'searchGroupMilestones' }],
);
});
});
describe('when project does not have license to add group milestones', () => {
it(`commits ${types.SET_SEARCH_QUERY} with the new search query to search for project milestones`, () => {
const searchQuery = 'v1.0';
testAction(
actions.search,
searchQuery,
state,
[{ type: types.SET_SEARCH_QUERY, payload: searchQuery }],
[{ type: 'searchProjectMilestones' }],
);
});
});
});
describe('searchMilestones', () => {
describe('searchProjectMilestones', () => {
describe('when the search is successful', () => {
const projectSearchApiResponse = { data: [{ title: 'v1.0' }] };
@ -87,7 +126,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchMilestones, undefined, state, [
return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectSearchApiResponse },
{ type: types.REQUEST_FINISH },
@ -103,7 +142,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchMilestones, undefined, state, [
return testAction(actions.searchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
@ -112,7 +151,71 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
describe('searchGroupMilestones', () => {
describe('when the search is successful', () => {
const groupSearchApiResponse = { data: [{ title: 'group-v1.0' }] };
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.resolve(groupSearchApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupSearchApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the search fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.searchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
describe('fetchMilestones', () => {
describe('when project has license to add group milestones', () => {
it(`dispatchs fetchProjectMilestones and fetchGroupMilestones`, () => {
const getters = {
groupMilestonesEnabled: () => true,
};
testAction(
actions.fetchMilestones,
undefined,
{ ...state, ...getters },
[],
[{ type: 'fetchProjectMilestones' }, { type: 'fetchGroupMilestones' }],
);
});
});
describe('when project does not have license to add group milestones', () => {
it(`dispatchs fetchProjectMilestones`, () => {
testAction(
actions.fetchMilestones,
undefined,
state,
[],
[{ type: 'fetchProjectMilestones' }],
);
});
});
});
describe('fetchProjectMilestones', () => {
describe('when the fetch is successful', () => {
const projectMilestonesApiResponse = { data: [{ title: 'v1.0' }] };
@ -121,7 +224,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchMilestones, undefined, state, [
return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_SUCCESS, payload: projectMilestonesApiResponse },
{ type: types.REQUEST_FINISH },
@ -137,7 +240,7 @@ describe('Milestone combobox Vuex store actions', () => {
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_PROJECT_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchMilestones, undefined, state, [
return testAction(actions.fetchProjectMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_PROJECT_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
@ -145,4 +248,38 @@ describe('Milestone combobox Vuex store actions', () => {
});
});
});
describe('fetchGroupMilestones', () => {
describe('when the fetch is successful', () => {
const groupMilestonesApiResponse = { data: [{ title: 'group-v1.0' }] };
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.resolve(groupMilestonesApiResponse);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_SUCCESS} with the response from the API, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_SUCCESS, payload: groupMilestonesApiResponse },
{ type: types.REQUEST_FINISH },
]);
});
});
describe('when the fetch fails', () => {
const error = new Error('Something went wrong!');
beforeEach(() => {
mockGroupMilestonesReturnValue = Promise.reject(error);
});
it(`commits ${types.REQUEST_START}, ${types.RECEIVE_GROUP_MILESTONES_ERROR} with the error object, and ${types.REQUEST_FINISH}`, () => {
return testAction(actions.fetchGroupMilestones, undefined, state, [
{ type: types.REQUEST_START },
{ type: types.RECEIVE_GROUP_MILESTONES_ERROR, payload: error },
{ type: types.REQUEST_FINISH },
]);
});
});
});
});

View File

@ -12,4 +12,22 @@ describe('Milestone comboxbox Vuex store getters', () => {
expect(getters.isLoading({ requestCount })).toBe(isLoading);
});
});
describe('groupMilestonesEnabled', () => {
it.each`
groupId | groupMilestonesAvailable | groupMilestonesEnabled
${'1'} | ${true} | ${true}
${'1'} | ${false} | ${false}
${''} | ${true} | ${false}
${''} | ${false} | ${false}
${null} | ${true} | ${false}
`(
'returns true when groupId is a truthy string and groupMilestonesAvailable is true',
({ groupId, groupMilestonesAvailable, groupMilestonesEnabled }) => {
expect(getters.groupMilestonesEnabled({ groupId, groupMilestonesAvailable })).toBe(
groupMilestonesEnabled,
);
},
);
});
});

View File

@ -14,6 +14,7 @@ describe('Milestones combobox Vuex store mutations', () => {
expect(state).toEqual({
projectId: null,
groupId: null,
groupMilestonesAvailable: false,
searchQuery: '',
matches: {
projectMilestones: {
@ -21,6 +22,11 @@ describe('Milestones combobox Vuex store mutations', () => {
totalCount: 0,
error: null,
},
groupMilestones: {
list: [],
totalCount: 0,
error: null,
},
},
selectedMilestones: [],
requestCount: 0,
@ -37,6 +43,24 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.SET_GROUP_ID}`, () => {
it('updates the group ID', () => {
const newGroupId = '8';
mutations[types.SET_GROUP_ID](state, newGroupId);
expect(state.groupId).toBe(newGroupId);
});
});
describe(`${types.SET_GROUP_MILESTONES_AVAILABLE}`, () => {
it('sets boolean indicating if group milestones are available', () => {
const groupMilestonesAvailable = true;
mutations[types.SET_GROUP_MILESTONES_AVAILABLE](state, groupMilestonesAvailable);
expect(state.groupMilestonesAvailable).toBe(groupMilestonesAvailable);
});
});
describe(`${types.SET_SELECTED_MILESTONES}`, () => {
it('sets the selected milestones', () => {
const selectedMilestones = ['v1.2.3'];
@ -60,7 +84,7 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
describe(`${types.ADD_SELECTED_MILESTONESs}`, () => {
describe(`${types.ADD_SELECTED_MILESTONES}`, () => {
it('adds the selected milestones', () => {
const selectedMilestone = 'v1.2.3';
mutations[types.ADD_SELECTED_MILESTONE](state, selectedMilestone);
@ -170,4 +194,57 @@ describe('Milestones combobox Vuex store mutations', () => {
});
});
});
describe(`${types.RECEIVE_GROUP_MILESTONES_SUCCESS}`, () => {
it('updates state.matches.groupMilestones based on the provided API response', () => {
const response = {
data: [
{
title: 'group-0.1',
},
{
title: 'group-0.2',
},
],
headers: {
'x-total': 2,
},
};
mutations[types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response);
expect(state.matches.groupMilestones).toEqual({
list: [
{
title: 'group-0.1',
},
{
title: 'group-0.2',
},
],
error: null,
totalCount: 2,
});
});
describe(`${types.RECEIVE_GROUP_MILESTONES_ERROR}`, () => {
it('updates state.matches.groupMilestones to an empty state with the error object', () => {
const error = new Error('Something went wrong!');
state.matches.groupMilestones = {
list: [{ title: 'group-0.1' }],
totalCount: 1,
error: null,
};
mutations[types.RECEIVE_GROUP_MILESTONES_ERROR](state, error);
expect(state.matches.groupMilestones).toEqual({
list: [],
totalCount: 0,
error,
});
});
});
});
});

View File

@ -27,6 +27,8 @@ describe('Release edit/new component', () => {
updateReleaseApiDocsPath: 'path/to/update/release/api/docs',
releasesPagePath: 'path/to/releases/page',
projectId: '8',
groupId: '42',
groupMilestonesAvailable: true,
};
actions = {

View File

@ -5,7 +5,7 @@ import {
getByTestId as getByTestIdHelper,
within,
} from '@testing-library/dom';
import { GlBadge } from '@gitlab/ui';
import { GlBadge, GlTable } from '@gitlab/ui';
import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
@ -28,6 +28,10 @@ describe('MemberList', () => {
state: {
members: [],
tableFields: [],
tableAttrs: {
table: { 'data-qa-selector': 'members_list' },
tr: { 'data-qa-selector': 'member_row' },
},
sourceId: 1,
currentUserId: 1,
...state,
@ -58,6 +62,8 @@ describe('MemberList', () => {
const getByTestId = (id, options) =>
createWrapper(getByTestIdHelper(wrapper.element, id, options));
const findTable = () => wrapper.find(GlTable);
afterEach(() => {
wrapper.destroy();
wrapper = null;
@ -187,4 +193,20 @@ describe('MemberList', () => {
expect(initUserPopoversMock).toHaveBeenCalled();
});
it('adds QA selector to table', () => {
createComponent();
expect(findTable().attributes('data-qa-selector')).toBe('members_list');
});
it('adds QA selector to table row', () => {
createComponent();
expect(
findTable()
.find('tbody tr')
.attributes('data-qa-selector'),
).toBe('member_row');
});
});

View File

@ -65,7 +65,9 @@ RSpec.describe Resolvers::ErrorTracking::SentryDetailedErrorResolver do
context 'blank id' do
let(:args) { { id: '' } }
it_behaves_like 'it resolves to nil'
it 'responds with an error' do
expect { resolve_error(args) }.to raise_error(::GraphQL::CoercionError)
end
end
end

View File

@ -0,0 +1,23 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryDetails'] do
fields = %i[id name path location created_at updated_at expiration_policy_started_at status tags_count can_delete tags]
it { expect(described_class.graphql_name).to eq('ContainerRepositoryDetails') }
it { expect(described_class.description).to eq('Details of a container repository') }
it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
it { expect(described_class).to have_graphql_fields(fields) }
describe 'tags field' do
subject { described_class.fields['tags'] }
it 'returns tags connection type' do
is_expected.to have_graphql_type(Types::ContainerRepositoryTagType.connection_type)
end
end
end

View File

@ -0,0 +1,15 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe GitlabSchema.types['ContainerRepositoryTag'] do
fields = %i[name path location digest revision short_revision total_size created_at can_delete]
it { expect(described_class.graphql_name).to eq('ContainerRepositoryTag') }
it { expect(described_class.description).to eq('A tag from a container repository') }
it { expect(described_class).to require_graphql_authorizations(:read_container_image) }
it { expect(described_class).to have_graphql_fields(fields) }
end

View File

@ -88,4 +88,10 @@ RSpec.describe GitlabSchema.types['Query'] do
is_expected.to have_graphql_type(Types::Ci::RunnerSetupType)
end
end
describe 'container_repository field' do
subject { described_class.fields['containerRepository'] }
it { is_expected.to have_graphql_type(Types::ContainerRepositoryDetailsType) }
end
end

View File

@ -64,6 +64,8 @@ RSpec.describe ReleasesHelper do
describe '#data_for_edit_release_page' do
it 'has the needed data to display the "edit release" page' do
keys = %i(project_id
group_id
group_milestones_available
project_path
tag_name
markdown_preview_path
@ -81,6 +83,8 @@ RSpec.describe ReleasesHelper do
describe '#data_for_new_release_page' do
it 'has the needed data to display the "new release" page' do
keys = %i(project_id
group_id
group_milestones_available
project_path
releases_page_path
markdown_preview_path

View File

@ -625,7 +625,7 @@ RSpec.describe Ci::Pipeline, :mailer, factory_default: :keep do
end
end
describe "coverage" do
describe '#coverage' do
let(:project) { create(:project, build_coverage_regex: "/.*/") }
let(:pipeline) { create(:ci_empty_pipeline, project: project) }

View File

@ -1271,24 +1271,6 @@ RSpec.describe Namespace do
expect(virtual_domain.lookup_paths).not_to be_empty
end
end
it 'preloads project_feature and route' do
project2 = create(:project, namespace: namespace)
project3 = create(:project, namespace: namespace)
project.mark_pages_as_deployed
project2.mark_pages_as_deployed
project3.mark_pages_as_deployed
virtual_domain = namespace.pages_virtual_domain
queries = ActiveRecord::QueryRecorder.new { virtual_domain.lookup_paths }
# 1 to load projects
# 1 to preload project features
# 1 to load routes
expect(queries.count).to eq(3)
end
end
end

View File

@ -3,15 +3,14 @@
require 'spec_helper'
RSpec.describe Pages::LookupPath do
let_it_be(:project) do
create(:project, :pages_private, pages_https_only: true)
end
let(:project) { create(:project, :pages_private, pages_https_only: true) }
subject(:lookup_path) { described_class.new(project) }
before do
stub_pages_setting(access_control: true, external_https: ["1.1.1.1:443"])
stub_artifacts_object_storage
stub_pages_object_storage(::Pages::DeploymentUploader)
end
describe '#project_id' do
@ -47,18 +46,63 @@ RSpec.describe Pages::LookupPath do
end
describe '#source' do
shared_examples 'uses disk storage' do
it 'sets the source type to "file"' do
expect(lookup_path.source[:type]).to eq('file')
end
let(:source) { lookup_path.source }
it 'sets the source path to the project full path suffixed with "public/' do
expect(lookup_path.source[:path]).to eq(project.full_path + "/public/")
shared_examples 'uses disk storage' do
it 'uses disk storage', :aggregate_failures do
expect(source[:type]).to eq('file')
expect(source[:path]).to eq(project.full_path + "/public/")
end
end
include_examples 'uses disk storage'
context 'when there is pages deployment' do
let(:deployment) { create(:pages_deployment, project: project) }
before do
project.mark_pages_as_deployed
project.pages_metadatum.update!(pages_deployment: deployment)
end
it 'uses deployment from object storage', :aggregate_failures do
Timecop.freeze do
expect(source[:type]).to eq('zip')
expect(source[:path]).to eq(deployment.file.url(expire_at: 1.day.from_now))
expect(source[:path]).to include("Expires=86400")
end
end
context 'when deployment is in the local storage' do
before do
deployment.file.migrate!(::ObjectStorage::Store::LOCAL)
end
it 'uses file protocol', :aggregate_failures do
Timecop.freeze do
expect(source[:type]).to eq('zip')
expect(source[:path]).to eq('file://' + deployment.file.path)
end
end
context 'when pages_serve_with_zip_file_protocol feature flag is disabled' do
before do
stub_feature_flags(pages_serve_with_zip_file_protocol: false)
end
include_examples 'uses disk storage'
end
end
context 'when pages_serve_from_deployments feature flag is disabled' do
before do
stub_feature_flags(pages_serve_from_deployments: false)
end
include_examples 'uses disk storage'
end
end
context 'when artifact_id from build job is present in pages metadata' do
let(:artifacts_archive) { create(:ci_job_artifact, :zip, :remote_store, project: project) }
@ -66,26 +110,36 @@ RSpec.describe Pages::LookupPath do
project.mark_pages_as_deployed(artifacts_archive: artifacts_archive)
end
it 'sets the source type to "zip"' do
expect(lookup_path.source[:type]).to eq('zip')
end
it 'sets the source path to the artifacts archive URL' do
it 'uses artifacts object storage', :aggregate_failures do
Timecop.freeze do
expect(lookup_path.source[:path]).to eq(artifacts_archive.file.url(expire_at: 1.day.from_now))
expect(lookup_path.source[:path]).to include("Expires=86400")
expect(source[:type]).to eq('zip')
expect(source[:path]).to eq(artifacts_archive.file.url(expire_at: 1.day.from_now))
expect(source[:path]).to include("Expires=86400")
end
end
context 'when artifact is not uploaded to object storage' do
let(:artifacts_archive) { create(:ci_job_artifact, :zip) }
include_examples 'uses disk storage'
it 'uses file protocol', :aggregate_failures do
Timecop.freeze do
expect(source[:type]).to eq('zip')
expect(source[:path]).to eq('file://' + artifacts_archive.file.path)
end
end
context 'when pages_serve_with_zip_file_protocol feature flag is disabled' do
before do
stub_feature_flags(pages_serve_with_zip_file_protocol: false)
end
include_examples 'uses disk storage'
end
end
context 'when feature flag is disabled' do
before do
stub_feature_flags(pages_artifacts_archive: false)
stub_feature_flags(pages_serve_from_artifacts_archive: false)
end
include_examples 'uses disk storage'

View File

@ -2906,6 +2906,34 @@ RSpec.describe User do
subject { user.authorized_groups }
it { is_expected.to contain_exactly private_group, project_group }
context 'with shared memberships' do
let!(:shared_group) { create(:group) }
let!(:other_group) { create(:group) }
before do
create(:group_group_link, shared_group: shared_group, shared_with_group: private_group)
create(:group_group_link, shared_group: private_group, shared_with_group: other_group)
end
context 'when shared_group_membership_auth is enabled' do
before do
stub_feature_flags(shared_group_membership_auth: user)
end
it { is_expected.to include shared_group }
it { is_expected.not_to include other_group }
end
context 'when shared_group_membership_auth is disabled' do
before do
stub_feature_flags(shared_group_membership_auth: false)
end
it { is_expected.not_to include shared_group }
it { is_expected.not_to include other_group }
end
end
end
describe '#membership_groups' do

View File

@ -0,0 +1,108 @@
# frozen_string_literal: true
require 'spec_helper'
RSpec.describe 'container repository details' do
using RSpec::Parameterized::TableSyntax
include GraphqlHelpers
let_it_be_with_reload(:project) { create(:project) }
let_it_be(:container_repository) { create(:container_repository, project: project) }
let(:query) do
graphql_query_for(
'containerRepository',
{ id: container_repository_global_id },
all_graphql_fields_for('ContainerRepositoryDetails')
)
end
let(:user) { project.owner }
let(:variables) { {} }
let(:tags) { %w(latest tag1 tag2 tag3 tag4 tag5) }
let(:container_repository_global_id) { container_repository.to_global_id.to_s }
let(:container_repository_details_response) { graphql_data.dig('containerRepository') }
before do
stub_container_registry_config(enabled: true)
stub_container_registry_tags(repository: container_repository.path, tags: tags, with_manifest: true)
end
subject { post_graphql(query, current_user: user, variables: variables) }
it_behaves_like 'a working graphql query' do
before do
subject
end
it 'matches the expected schema' do
expect(container_repository_details_response).to match_schema('graphql/container_repository_details')
end
end
context 'with different permissions' do
let_it_be(:user) { create(:user) }
let(:tags_response) { container_repository_details_response.dig('tags', 'nodes') }
where(:project_visibility, :role, :access_granted, :can_delete) do
:private | :maintainer | true | true
:private | :developer | true | true
:private | :reporter | true | false
:private | :guest | false | false
:private | :anonymous | false | false
:public | :maintainer | true | true
:public | :developer | true | true
:public | :reporter | true | false
:public | :guest | true | false
:public | :anonymous | true | false
end
with_them do
before do
project.update!(visibility_level: Gitlab::VisibilityLevel.const_get(project_visibility.to_s.upcase, false))
project.add_user(user, role) unless role == :anonymous
end
it 'return the proper response' do
subject
if access_granted
expect(tags_response.size).to eq(tags.size)
expect(container_repository_details_response.dig('canDelete')).to eq(can_delete)
else
expect(container_repository_details_response).to eq(nil)
end
end
end
end
context 'limiting the number of tags' do
let(:limit) { 2 }
let(:tags_response) { container_repository_details_response.dig('tags', 'edges') }
let(:variables) do
{ id: container_repository_global_id, n: limit }
end
let(:query) do
<<~GQL
query($id: ID!, $n: Int) {
containerRepository(id: $id) {
tags(first: $n) {
edges {
node {
#{all_graphql_fields_for('ContainerRepositoryTag')}
}
}
}
}
}
GQL
end
it 'only returns n tags' do
subject
expect(tags_response.size).to eq(limit)
end
end
end

View File

@ -92,9 +92,9 @@ RSpec.describe 'getting container repositories in a group' do
end
context 'limiting the number of repositories' do
let(:issue_limit) { 1 }
let(:limit) { 1 }
let(:variables) do
{ path: group.full_path, n: issue_limit }
{ path: group.full_path, n: limit }
end
let(:query) do
@ -107,10 +107,10 @@ RSpec.describe 'getting container repositories in a group' do
GQL
end
it 'only returns N issues' do
it 'only returns N repositories' do
subject
expect(container_repositories_response.size).to eq(issue_limit)
expect(container_repositories_response.size).to eq(limit)
end
end

View File

@ -87,9 +87,9 @@ RSpec.describe 'getting container repositories in a project' do
end
context 'limiting the number of repositories' do
let(:issue_limit) { 1 }
let(:limit) { 1 }
let(:variables) do
{ path: project.full_path, n: issue_limit }
{ path: project.full_path, n: limit }
end
let(:query) do
@ -102,10 +102,10 @@ RSpec.describe 'getting container repositories in a project' do
GQL
end
it 'only returns N issues' do
it 'only returns N repositories' do
subject
expect(container_repositories_response.size).to eq(issue_limit)
expect(container_repositories_response.size).to eq(limit)
end
end

View File

@ -191,7 +191,7 @@ RSpec.describe 'sentry errors requests' do
describe 'getting a stack trace' do
let_it_be(:sentry_stack_trace) { build(:error_tracking_error_event) }
let(:sentry_gid) { Gitlab::ErrorTracking::DetailedError.new(id: 1).to_global_id.to_s }
let(:sentry_gid) { global_id_of(Gitlab::ErrorTracking::DetailedError.new(id: 1)) }
let(:stack_trace_fields) do
all_graphql_fields_for('SentryErrorStackTrace'.classify)

View File

@ -12,6 +12,7 @@ RSpec.describe API::Internal::Pages do
before do
allow(Gitlab::Pages).to receive(:secret).and_return(pages_secret)
stub_pages_object_storage(::Pages::DeploymentUploader)
end
describe "GET /internal/pages/status" do
@ -38,6 +39,12 @@ RSpec.describe API::Internal::Pages do
get api("/internal/pages"), headers: headers, params: { host: host }
end
around do |example|
freeze_time do
example.run
end
end
context 'not authenticated' do
it 'responds with 401 Unauthorized' do
query_host('pages.gitlab.io')
@ -55,7 +62,9 @@ RSpec.describe API::Internal::Pages do
end
def deploy_pages(project)
deployment = create(:pages_deployment, project: project)
project.mark_pages_as_deployed
project.update_pages_deployment!(deployment)
end
context 'domain does not exist' do
@ -190,8 +199,8 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/',
'source' => {
'type' => 'file',
'path' => 'gitlab-org/gitlab-ce/public/'
'type' => 'zip',
'path' => project.pages_metadatum.pages_deployment.file.url(expire_at: 1.day.from_now)
}
}
]
@ -226,8 +235,8 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/myproject/',
'source' => {
'type' => 'file',
'path' => 'mygroup/myproject/public/'
'type' => 'zip',
'path' => project.pages_metadatum.pages_deployment.file.url(expire_at: 1.day.from_now)
}
}
]
@ -235,6 +244,20 @@ RSpec.describe API::Internal::Pages do
end
end
it 'avoids N+1 queries' do
project = create(:project, group: group)
deploy_pages(project)
control = ActiveRecord::QueryRecorder.new { query_host('mygroup.gitlab-pages.io') }
3.times do
project = create(:project, group: group)
deploy_pages(project)
end
expect { query_host('mygroup.gitlab-pages.io') }.not_to exceed_query_limit(control)
end
context 'group root project' do
it 'responds with the correct domain configuration' do
project = create(:project, group: group, name: 'mygroup.gitlab-pages.io')
@ -253,8 +276,8 @@ RSpec.describe API::Internal::Pages do
'https_only' => false,
'prefix' => '/',
'source' => {
'type' => 'file',
'path' => 'mygroup/mygroup.gitlab-pages.io/public/'
'type' => 'zip',
'path' => project.pages_metadatum.pages_deployment.file.url(expire_at: 1.day.from_now)
}
}
]

View File

@ -866,10 +866,10 @@
resolved "https://registry.yarnpkg.com/@gitlab/svgs/-/svgs-1.175.0.tgz#734f341784af1cd1d62d160a17bcdfb61ff7b04d"
integrity sha512-gXpc87TGSXIzfAr4QER1Qw1v3P47pBO6BXkma52blgwXVmcFNe3nhQzqsqt66wKNzrIrk3lAcB4GUyPHbPVXpg==
"@gitlab/ui@23.3.0":
version "23.3.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-23.3.0.tgz#642e5246320342824a77a4bc5c9e3d348758821c"
integrity sha512-hRKbihMy1qFEwW3FCYsoC7hgD7gGLhbGZXY3e9yIxL+cthRGwnA+RUuuXmMn6qCTFzM1i95hT6JViKa6NNygTg==
"@gitlab/ui@23.4.0":
version "23.4.0"
resolved "https://registry.yarnpkg.com/@gitlab/ui/-/ui-23.4.0.tgz#09fabb7174a99ba2993f7fe293143470c16c5ef6"
integrity sha512-B69i5Tl78aehxPA4iRsGk1d5Za5f5KuJw4UaWeZcGQV9JkKFw+44oPvkwvIslzuq3poReO7toXaMFjXRXLIKaQ==
dependencies:
"@babel/standalone" "^7.0.0"
"@gitlab/vue-toasted" "^1.3.0"