summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2020-11-10 21:08:51 +0000
committerGitLab Bot <gitlab-bot@gitlab.com>2020-11-10 21:08:51 +0000
commit13bcb8221306526671a61df589f7c05505c9934c (patch)
treebaa61780ec5f526ea180c209af8f4d75a5cb4425
parent206b03aeae3a368983ac3d6ad5e5828030bbaacd (diff)
downloadgitlab-ce-13bcb8221306526671a61df589f7c05505c9934c.tar.gz
Add latest changes from gitlab-org/gitlab@master
-rw-r--r--app/assets/javascripts/groups/members/index.js3
-rw-r--r--app/assets/javascripts/milestones/components/milestone_combobox.vue42
-rw-r--r--app/assets/javascripts/milestones/stores/actions.js55
-rw-r--r--app/assets/javascripts/milestones/stores/getters.js4
-rw-r--r--app/assets/javascripts/milestones/stores/mutation_types.js5
-rw-r--r--app/assets/javascripts/milestones/stores/mutations.js23
-rw-r--r--app/assets/javascripts/milestones/stores/state.js6
-rw-r--r--app/assets/javascripts/notifications_form.js19
-rw-r--r--app/assets/javascripts/pages/groups/group_members/index.js4
-rw-r--r--app/assets/javascripts/releases/components/app_edit_new.vue4
-rw-r--r--app/assets/javascripts/releases/stores/modules/detail/state.js4
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/members_table.vue4
-rw-r--r--app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue10
-rw-r--r--app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue10
-rw-r--r--app/assets/javascripts/vuex_shared/modules/members/state.js2
-rw-r--r--app/assets/stylesheets/pages/projects.scss17
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb11
-rw-r--r--app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb10
-rw-r--r--app/graphql/types/container_repository_details_type.rb21
-rw-r--r--app/graphql/types/container_repository_tag_type.rb25
-rw-r--r--app/graphql/types/project_type.rb3
-rw-r--r--app/graphql/types/query_type.rb19
-rw-r--r--app/helpers/releases_helper.rb8
-rw-r--r--app/models/namespace.rb2
-rw-r--r--app/models/pages/lookup_path.rb44
-rw-r--r--app/models/pages_deployment.rb4
-rw-r--r--app/models/user.rb29
-rw-r--r--app/policies/container_registry/tag_policy.rb6
-rw-r--r--app/views/shared/notifications/_custom_notifications.html.haml3
-rw-r--r--changelogs/unreleased/10io-graphql-container-repository-details-api.yml5
-rw-r--r--changelogs/unreleased/ajk-globalid-error-tracking.yml5
-rw-r--r--changelogs/unreleased/mw-replace-fa-icons-custom-notifications.yml5
-rw-r--r--config/feature_flags/development/pages_serve_from_artifacts_archive.yml (renamed from config/feature_flags/development/pages_artifacts_archive.yml)8
-rw-r--r--config/feature_flags/development/pages_serve_from_deployments.yml8
-rw-r--r--config/feature_flags/development/pages_serve_with_zip_file_protocol.yml8
-rw-r--r--config/feature_flags/development/shared_group_membership_auth.yml8
-rw-r--r--doc/api/graphql/reference/gitlab_schema.graphql191
-rw-r--r--doc/api/graphql/reference/gitlab_schema.json578
-rw-r--r--doc/api/graphql/reference/index.md34
-rw-r--r--doc/user/project/releases/index.md2
-rw-r--r--locale/gitlab.pot3
-rw-r--r--package.json2
-rw-r--r--qa/qa/page/group/members.rb19
-rw-r--r--spec/finders/group_descendants_finder_spec.rb37
-rw-r--r--spec/fixtures/api/schemas/graphql/container_repository_details.json78
-rw-r--r--spec/fixtures/api/schemas/internal/pages/lookup_path.json2
-rw-r--r--spec/frontend/groups/members/index_spec.js13
-rw-r--r--spec/frontend/milestones/milestone_combobox_spec.js226
-rw-r--r--spec/frontend/milestones/mock_data.js94
-rw-r--r--spec/frontend/milestones/stores/actions_spec.js165
-rw-r--r--spec/frontend/milestones/stores/getter_spec.js18
-rw-r--r--spec/frontend/milestones/stores/mutations_spec.js79
-rw-r--r--spec/frontend/releases/components/app_edit_new_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js24
-rw-r--r--spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb4
-rw-r--r--spec/graphql/types/container_repository_details_type_spec.rb23
-rw-r--r--spec/graphql/types/container_repository_tag_type_spec.rb15
-rw-r--r--spec/graphql/types/query_type_spec.rb6
-rw-r--r--spec/helpers/releases_helper_spec.rb4
-rw-r--r--spec/models/ci/pipeline_spec.rb2
-rw-r--r--spec/models/namespace_spec.rb18
-rw-r--r--spec/models/pages/lookup_path_spec.rb90
-rw-r--r--spec/models/user_spec.rb28
-rw-r--r--spec/requests/api/graphql/container_repository/container_repository_details_spec.rb108
-rw-r--r--spec/requests/api/graphql/group/container_repositories_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/container_repositories_spec.rb8
-rw-r--r--spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb2
-rw-r--r--spec/requests/api/internal/pages_spec.rb35
-rw-r--r--yarn.lock8
69 files changed, 2161 insertions, 209 deletions
diff --git a/app/assets/javascripts/groups/members/index.js b/app/assets/javascripts/groups/members/index.js
index 3bbef14d199..cb28fb057c9 100644
--- a/app/assets/javascripts/groups/members/index.js
+++ b/app/assets/javascripts/groups/members/index.js
@@ -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,
}),
});
diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue
index fad61b95124..08fd5a5994f 100644
--- a/app/assets/javascripts/milestones/components/milestone_combobox.vue
+++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue
@@ -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"
diff --git a/app/assets/javascripts/milestones/stores/actions.js b/app/assets/javascripts/milestones/stores/actions.js
index 56a07562f62..df45c7156ad 100644
--- a/app/assets/javascripts/milestones/stores/actions.js
+++ b/app/assets/javascripts/milestones/stores/actions.js
@@ -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 = ({ dispatch, getters }) => {
+ dispatch('fetchProjectMilestones');
+ if (getters.groupMilestonesEnabled) {
+ dispatch('fetchGroupMilestones');
+ }
};
-export const fetchMilestones = ({ commit, state }) => {
+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);
+ });
+};
diff --git a/app/assets/javascripts/milestones/stores/getters.js b/app/assets/javascripts/milestones/stores/getters.js
index d8a283403ec..b5fcfbe35d5 100644
--- a/app/assets/javascripts/milestones/stores/getters.js
+++ b/app/assets/javascripts/milestones/stores/getters.js
@@ -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);
diff --git a/app/assets/javascripts/milestones/stores/mutation_types.js b/app/assets/javascripts/milestones/stores/mutation_types.js
index 6c58fca9dca..22e50571e34 100644
--- a/app/assets/javascripts/milestones/stores/mutation_types.js
+++ b/app/assets/javascripts/milestones/stores/mutation_types.js
@@ -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';
diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js
index 71331965d2a..601b88cb62a 100644
--- a/app/assets/javascripts/milestones/stores/mutations.js
+++ b/app/assets/javascripts/milestones/stores/mutations.js
@@ -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,
+ };
+ },
};
diff --git a/app/assets/javascripts/milestones/stores/state.js b/app/assets/javascripts/milestones/stores/state.js
index 8466228dc17..82723ab32f9 100644
--- a/app/assets/javascripts/milestones/stores/state.js
+++ b/app/assets/javascripts/milestones/stores/state.js
@@ -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,
diff --git a/app/assets/javascripts/notifications_form.js b/app/assets/javascripts/notifications_form.js
index 0d1b95f75f8..1b12fece23a 100644
--- a/app/assets/javascripts/notifications_form.js
+++ b/app/assets/javascripts/notifications_form.js
@@ -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);
}
})
diff --git a/app/assets/javascripts/pages/groups/group_members/index.js b/app/assets/javascripts/pages/groups/group_members/index.js
index 8912f1c6b1d..009a3eee526 100644
--- a/app/assets/javascripts/pages/groups/group_members/index.js
+++ b/app/assets/javascripts/pages/groups/group_members/index.js
@@ -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,
);
diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue
index c3582cf04dc..e0705489738 100644
--- a/app/assets/javascripts/releases/components/app_edit_new.vue
+++ b/app/assets/javascripts/releases/components/app_edit_new.vue
@@ -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>
diff --git a/app/assets/javascripts/releases/stores/modules/detail/state.js b/app/assets/javascripts/releases/stores/modules/detail/state.js
index 782a5c46d6c..e22d06f8daa 100644
--- a/app/assets/javascripts/releases/stores/modules/detail/state.js
+++ b/app/assets/javascripts/releases/stores/modules/detail/state.js
@@ -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,
diff --git a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
index 723e890ef92..a4f67caff31 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/members_table.vue
@@ -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">
diff --git a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
index 42a5ca1b3c9..6f6cae6072d 100644
--- a/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
+++ b/app/assets/javascripts/vue_shared/components/members/table/role_dropdown.vue
@@ -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 }}
diff --git a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
index 995922454c4..b70b1277155 100644
--- a/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
+++ b/app/assets/javascripts/vue_shared/components/runner_instructions/runner_instructions.vue
@@ -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
diff --git a/app/assets/javascripts/vuex_shared/modules/members/state.js b/app/assets/javascripts/vuex_shared/modules/members/state.js
index e4867819e17..ab3ebb34616 100644
--- a/app/assets/javascripts/vuex_shared/modules/members/state.js
+++ b/app/assets/javascripts/vuex_shared/modules/members/state.js
@@ -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,
diff --git a/app/assets/stylesheets/pages/projects.scss b/app/assets/stylesheets/pages/projects.scss
index 9f80d7b57ab..09501d3713d 100644
--- a/app/assets/stylesheets/pages/projects.scss
+++ b/app/assets/stylesheets/pages/projects.scss
@@ -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;
diff --git a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
index 07e70d5c819..09e76dba645 100644
--- a/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_detailed_error_resolver.rb
@@ -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
diff --git a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
index c365baaf475..669b487db10 100644
--- a/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
+++ b/app/graphql/resolvers/error_tracking/sentry_error_stack_trace_resolver.rb
@@ -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]
diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb
new file mode 100644
index 00000000000..34523f3ea4a
--- /dev/null
+++ b/app/graphql/types/container_repository_details_type.rb
@@ -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
diff --git a/app/graphql/types/container_repository_tag_type.rb b/app/graphql/types/container_repository_tag_type.rb
new file mode 100644
index 00000000000..25e605b689d
--- /dev/null
+++ b/app/graphql/types/container_repository_tag_type.rb
@@ -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
diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb
index 95cd0e3ebfa..388876cac4d 100644
--- a/app/graphql/types/project_type.rb
+++ b/app/graphql/types/project_type.rb
@@ -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,
diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb
index a975035c305..d194b0979b3 100644
--- a/app/graphql/types/query_type.rb
+++ b/app/graphql/types/query_type.rb
@@ -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
diff --git a/app/helpers/releases_helper.rb b/app/helpers/releases_helper.rb
index 050b27840a0..72441226ef7 100644
--- a/app/helpers/releases_helper.rb
+++ b/app/helpers/releases_helper.rb
@@ -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')
diff --git a/app/models/namespace.rb b/app/models/namespace.rb
index 016cf4b37a9..553accefd89 100644
--- a/app/models/namespace.rb
+++ b/app/models/namespace.rb
@@ -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
diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb
index 84d820e539c..89f6591ea1e 100644
--- a/app/models/pages/lookup_path.rb
+++ b/app/models/pages/lookup_path.rb
@@ -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)
+
+ 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
- # 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
+ 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
diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb
index fb13dbfb8ca..f60f04aa804 100644
--- a/app/models/pages_deployment.rb
+++ b/app/models/pages_deployment.rb
@@ -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
diff --git a/app/models/user.rb b/app/models/user.rb
index ef30cce9a6c..148a0cc1ccf 100644
--- a/app/models/user.rb
+++ b/app/models/user.rb
@@ -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?
diff --git a/app/policies/container_registry/tag_policy.rb b/app/policies/container_registry/tag_policy.rb
new file mode 100644
index 00000000000..8c75f2a6f20
--- /dev/null
+++ b/app/policies/container_registry/tag_policy.rb
@@ -0,0 +1,6 @@
+# frozen_string_literal: true
+module ContainerRegistry
+ class TagPolicy < BasePolicy
+ delegate { @subject.repository }
+ end
+end
diff --git a/app/views/shared/notifications/_custom_notifications.html.haml b/app/views/shared/notifications/_custom_notifications.html.haml
index 51b7da7dee8..946e3c67dcf 100644
--- a/app/views/shared/notifications/_custom_notifications.html.haml
+++ b/app/views/shared/notifications/_custom_notifications.html.haml
@@ -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')
diff --git a/changelogs/unreleased/10io-graphql-container-repository-details-api.yml b/changelogs/unreleased/10io-graphql-container-repository-details-api.yml
new file mode 100644
index 00000000000..4a0f48ff23f
--- /dev/null
+++ b/changelogs/unreleased/10io-graphql-container-repository-details-api.yml
@@ -0,0 +1,5 @@
+---
+title: Container repository details GraphQL API
+merge_request: 46560
+author:
+type: added
diff --git a/changelogs/unreleased/ajk-globalid-error-tracking.yml b/changelogs/unreleased/ajk-globalid-error-tracking.yml
new file mode 100644
index 00000000000..12567b785ab
--- /dev/null
+++ b/changelogs/unreleased/ajk-globalid-error-tracking.yml
@@ -0,0 +1,5 @@
+---
+title: Use global IDs for GraphQL arguments accepting sentry IDs
+merge_request: 36098
+author:
+type: changed
diff --git a/changelogs/unreleased/mw-replace-fa-icons-custom-notifications.yml b/changelogs/unreleased/mw-replace-fa-icons-custom-notifications.yml
new file mode 100644
index 00000000000..573a72950fb
--- /dev/null
+++ b/changelogs/unreleased/mw-replace-fa-icons-custom-notifications.yml
@@ -0,0 +1,5 @@
+---
+title: Replace fa-check icon in custom notifications
+merge_request: 47288
+author:
+type: changed
diff --git a/config/feature_flags/development/pages_artifacts_archive.yml b/config/feature_flags/development/pages_serve_from_artifacts_archive.yml
index f58f7199508..4cc29601e48 100644
--- a/config/feature_flags/development/pages_artifacts_archive.yml
+++ b/config/feature_flags/development/pages_serve_from_artifacts_archive.yml
@@ -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
diff --git a/config/feature_flags/development/pages_serve_from_deployments.yml b/config/feature_flags/development/pages_serve_from_deployments.yml
new file mode 100644
index 00000000000..ab75ec16952
--- /dev/null
+++ b/config/feature_flags/development/pages_serve_from_deployments.yml
@@ -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
diff --git a/config/feature_flags/development/pages_serve_with_zip_file_protocol.yml b/config/feature_flags/development/pages_serve_with_zip_file_protocol.yml
new file mode 100644
index 00000000000..7700cf7fae5
--- /dev/null
+++ b/config/feature_flags/development/pages_serve_with_zip_file_protocol.yml
@@ -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
diff --git a/config/feature_flags/development/shared_group_membership_auth.yml b/config/feature_flags/development/shared_group_membership_auth.yml
new file mode 100644
index 00000000000..e6aaad9bbd6
--- /dev/null
+++ b/config/feature_flags/development/shared_group_membership_auth.yml
@@ -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
diff --git a/doc/api/graphql/reference/gitlab_schema.graphql b/doc/api/graphql/reference/gitlab_schema.graphql
index e214ed7f028..1c57851fe9d 100644
--- a/doc/api/graphql/reference/gitlab_schema.graphql
+++ b/doc/api/graphql/reference/gitlab_schema.graphql
@@ -3345,6 +3345,86 @@ type ContainerRepositoryConnection {
}
"""
+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.
"""
type ContainerRepositoryEdge {
@@ -3360,6 +3440,11 @@ type ContainerRepositoryEdge {
}
"""
+Identifier of ContainerRepository
+"""
+scalar ContainerRepositoryID
+
+"""
Status of a container repository
"""
enum ContainerRepositoryStatus {
@@ -3375,6 +3460,91 @@ enum ContainerRepositoryStatus {
}
"""
+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
"""
input CreateAlertIssueInput {
@@ -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
"""
@@ -16811,6 +16986,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
"""
currentUser: 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
"""
diff --git a/doc/api/graphql/reference/gitlab_schema.json b/doc/api/graphql/reference/gitlab_schema.json
index 1b5f3b7f1f9..4f44328f827 100644
--- a/doc/api/graphql/reference/gitlab_schema.json
+++ b/doc/api/graphql/reference/gitlab_schema.json
@@ -9076,6 +9076,244 @@
},
{
"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",
"description": "An edge in a connection.",
"fields": [
@@ -9120,6 +9358,16 @@
"possibleTypes": null
},
{
+ "kind": "SCALAR",
+ "name": "ContainerRepositoryID",
+ "description": "Identifier of ContainerRepository",
+ "fields": null,
+ "inputFields": null,
+ "interfaces": null,
+ "enumValues": null,
+ "possibleTypes": null
+ },
+ {
"kind": "ENUM",
"name": "ContainerRepositoryStatus",
"description": "Status of a container repository",
@@ -9143,6 +9391,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",
"description": "Autogenerated input type of CreateAlertIssue",
@@ -22357,6 +22892,16 @@
"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",
"description": null,
@@ -46175,7 +46720,7 @@
"name": null,
"ofType": {
"kind": "SCALAR",
- "name": "ID",
+ "name": "GitlabErrorTrackingDetailedErrorID",
"ofType": null
}
},
@@ -48885,6 +49430,33 @@
"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",
"args": [
@@ -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
}
},
diff --git a/doc/api/graphql/reference/index.md b/doc/api/graphql/reference/index.md
index 914f7ae2edd..2225e15af0f 100644
--- a/doc/api/graphql/reference/index.md
+++ b/doc/api/graphql/reference/index.md
@@ -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.
diff --git a/doc/user/project/releases/index.md b/doc/user/project/releases/index.md
index 962d612cac1..927593f5d78 100644
--- a/doc/user/project/releases/index.md
+++ b/doc/user/project/releases/index.md
@@ -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).
diff --git a/locale/gitlab.pot b/locale/gitlab.pot
index 5e25af512dc..36c8d1c7ae5 100644
--- a/locale/gitlab.pot
+++ b/locale/gitlab.pot
@@ -17388,6 +17388,9 @@ msgstr ""
msgid "MilestoneCombobox|An error occurred while searching for milestones"
msgstr ""
+msgid "MilestoneCombobox|Group milestones"
+msgstr ""
+
msgid "MilestoneCombobox|Milestone"
msgstr ""
diff --git a/package.json b/package.json
index 1e4ec1e3e33..10f287a643b 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/qa/qa/page/group/members.rb b/qa/qa/page/group/members.rb
index dce18ee5c55..16e447a2be5 100644
--- a/qa/qa/page/group/members.rb
+++ b/qa/qa/page/group/members.rb
@@ -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
diff --git a/spec/finders/group_descendants_finder_spec.rb b/spec/finders/group_descendants_finder_spec.rb
index 2f9303606b1..b66d0ffce87 100644
--- a/spec/finders/group_descendants_finder_spec.rb
+++ b/spec/finders/group_descendants_finder_spec.rb
@@ -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) }
diff --git a/spec/fixtures/api/schemas/graphql/container_repository_details.json b/spec/fixtures/api/schemas/graphql/container_repository_details.json
new file mode 100644
index 00000000000..b076711dcea
--- /dev/null
+++ b/spec/fixtures/api/schemas/graphql/container_repository_details.json
@@ -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"
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/spec/fixtures/api/schemas/internal/pages/lookup_path.json b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
index b2b3d3f9d0a..a8a059577a6 100644
--- a/spec/fixtures/api/schemas/internal/pages/lookup_path.json
+++ b/spec/fixtures/api/schemas/internal/pages/lookup_path.json
@@ -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
diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js
index 2fb7904bcfe..aaa36665c45 100644
--- a/spec/frontend/groups/members/index_spec.js
+++ b/spec/frontend/groups/members/index_spec.js
@@ -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();
diff --git a/spec/frontend/milestones/milestone_combobox_spec.js b/spec/frontend/milestones/milestone_combobox_spec.js
index 2996c05d96e..047484f117f 100644
--- a/spec/frontend/milestones/milestone_combobox_spec.js
+++ b/spec/frontend/milestones/milestone_combobox_spec.js
@@ -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', () => {
+ 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('when a project milestones is selected', () => {
+ describe('group milestones', () => {
+ describe('when the group milestones search returns results', () => {
beforeEach(() => {
createComponent();
- projectMilestonesApiCallSpy = jest
+
+ return waitForRequests();
+ });
+
+ it('renders the group milestones section in the dropdown', () => {
+ expect(findGroupMilestonesSection().exists()).toBe(true);
+ });
+
+ 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');
+ });
+
+ it("does not render an error message in the group milestone section's body", () => {
+ expect(groupMilestoneSectionContainsErrorMessage()).toBe(false);
+ });
+
+ 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, [{ title: 'v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
+ .mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]);
+
+ createComponent();
return waitForRequests();
});
- it("displays the project milestones name in the dropdown's button", async () => {
- selectFirstProjectMilestone();
+ 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(findButtonContent().text()).toBe(s__('MilestoneCombobox|No milestone'));
+ expect(
+ findFirstGroupMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(false);
- selectFirstProjectMilestone();
+ selectFirstGroupMilestone();
await localVue.nextTick();
- expect(findButtonContent().text()).toBe('v1.0');
+
+ expect(
+ findFirstGroupMilestonesDropdownItem()
+ .find('span')
+ .classes('selected-item'),
+ ).toBe(true);
});
- it('updates the v-model binding with the project milestone title', () => {
- expect(wrapper.vm.value).toEqual([]);
+ describe('when a group milestones is selected', () => {
+ beforeEach(() => {
+ createComponent();
+ groupMilestonesApiCallSpy = jest
+ .fn()
+ .mockReturnValue([200, [{ title: 'group-v1.0' }], { [X_TOTAL_HEADER]: '1' }]);
- selectFirstProjectMilestone();
+ return waitForRequests();
+ });
+
+ it("displays the group milestones name in the dropdown's button", async () => {
+ selectFirstGroupMilestone();
+ await localVue.nextTick();
- expect(wrapper.vm.value).toEqual(['v1.0']);
+ 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']);
+ });
});
});
});
diff --git a/spec/frontend/milestones/mock_data.js b/spec/frontend/milestones/mock_data.js
index c64eeeba663..71fbfe54141 100644
--- a/spec/frontend/milestones/mock_data.js
+++ b/spec/frontend/milestones/mock_data.js
@@ -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,
+};
diff --git a/spec/frontend/milestones/stores/actions_spec.js b/spec/frontend/milestones/stores/actions_spec.js
index e14eb9280e4..a62b0c49a80 100644
--- a/spec/frontend/milestones/stores/actions_spec.js
+++ b/spec/frontend/milestones/stores/actions_spec.js
@@ -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 },
+ ]);
+ });
+ });
+ });
});
diff --git a/spec/frontend/milestones/stores/getter_spec.js b/spec/frontend/milestones/stores/getter_spec.js
index df7c3d28e67..4a6116b642c 100644
--- a/spec/frontend/milestones/stores/getter_spec.js
+++ b/spec/frontend/milestones/stores/getter_spec.js
@@ -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,
+ );
+ },
+ );
+ });
});
diff --git a/spec/frontend/milestones/stores/mutations_spec.js b/spec/frontend/milestones/stores/mutations_spec.js
index 236e0a49ebe..0b69a9d572d 100644
--- a/spec/frontend/milestones/stores/mutations_spec.js
+++ b/spec/frontend/milestones/stores/mutations_spec.js
@@ -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,
+ });
+ });
+ });
+ });
});
diff --git a/spec/frontend/releases/components/app_edit_new_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js
index d92bdc3b99a..c0680acb7cd 100644
--- a/spec/frontend/releases/components/app_edit_new_spec.js
+++ b/spec/frontend/releases/components/app_edit_new_spec.js
@@ -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 = {
diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
index 39234e230dc..e593e88438c 100644
--- a/spec/frontend/vue_shared/components/members/table/members_table_spec.js
+++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
@@ -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');
+ });
});
diff --git a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
index 145aada019e..bf8d2139c82 100644
--- a/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
+++ b/spec/graphql/resolvers/error_tracking/sentry_detailed_error_resolver_spec.rb
@@ -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
diff --git a/spec/graphql/types/container_repository_details_type_spec.rb b/spec/graphql/types/container_repository_details_type_spec.rb
new file mode 100644
index 00000000000..13563dbb5aa
--- /dev/null
+++ b/spec/graphql/types/container_repository_details_type_spec.rb
@@ -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
diff --git a/spec/graphql/types/container_repository_tag_type_spec.rb b/spec/graphql/types/container_repository_tag_type_spec.rb
new file mode 100644
index 00000000000..1d1a76d6916
--- /dev/null
+++ b/spec/graphql/types/container_repository_tag_type_spec.rb
@@ -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
diff --git a/spec/graphql/types/query_type_spec.rb b/spec/graphql/types/query_type_spec.rb
index eee92fbb61d..7a0b3035607 100644
--- a/spec/graphql/types/query_type_spec.rb
+++ b/spec/graphql/types/query_type_spec.rb
@@ -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
diff --git a/spec/helpers/releases_helper_spec.rb b/spec/helpers/releases_helper_spec.rb
index 704e8dc40cb..7dc1328f065 100644
--- a/spec/helpers/releases_helper_spec.rb
+++ b/spec/helpers/releases_helper_spec.rb
@@ -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
diff --git a/spec/models/ci/pipeline_spec.rb b/spec/models/ci/pipeline_spec.rb
index 88d08f1ec45..065e756ea28 100644
--- a/spec/models/ci/pipeline_spec.rb
+++ b/spec/models/ci/pipeline_spec.rb
@@ -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) }
diff --git a/spec/models/namespace_spec.rb b/spec/models/namespace_spec.rb
index a18aea38eac..85f9005052e 100644
--- a/spec/models/namespace_spec.rb
+++ b/spec/models/namespace_spec.rb
@@ -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
diff --git a/spec/models/pages/lookup_path_spec.rb b/spec/models/pages/lookup_path_spec.rb
index cb1938a0113..bd890a71dfd 100644
--- a/spec/models/pages/lookup_path_spec.rb
+++ b/spec/models/pages/lookup_path_spec.rb
@@ -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'
diff --git a/spec/models/user_spec.rb b/spec/models/user_spec.rb
index 4c254f54590..2b7268fd380 100644
--- a/spec/models/user_spec.rb
+++ b/spec/models/user_spec.rb
@@ -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
diff --git a/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
new file mode 100644
index 00000000000..a63adb8efc4
--- /dev/null
+++ b/spec/requests/api/graphql/container_repository/container_repository_details_spec.rb
@@ -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
diff --git a/spec/requests/api/graphql/group/container_repositories_spec.rb b/spec/requests/api/graphql/group/container_repositories_spec.rb
index 4fa3c030761..bcf689a5e8f 100644
--- a/spec/requests/api/graphql/group/container_repositories_spec.rb
+++ b/spec/requests/api/graphql/group/container_repositories_spec.rb
@@ -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
diff --git a/spec/requests/api/graphql/project/container_repositories_spec.rb b/spec/requests/api/graphql/project/container_repositories_spec.rb
index 8790314fa76..428424802a2 100644
--- a/spec/requests/api/graphql/project/container_repositories_spec.rb
+++ b/spec/requests/api/graphql/project/container_repositories_spec.rb
@@ -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
diff --git a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
index cd84ce9cb96..acf5201a68c 100644
--- a/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
+++ b/spec/requests/api/graphql/project/error_tracking/sentry_errors_request_spec.rb
@@ -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)
diff --git a/spec/requests/api/internal/pages_spec.rb b/spec/requests/api/internal/pages_spec.rb
index e58eba02132..7f17f22b007 100644
--- a/spec/requests/api/internal/pages_spec.rb
+++ b/spec/requests/api/internal/pages_spec.rb
@@ -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)
}
}
]
diff --git a/yarn.lock b/yarn.lock
index 133651075db..6be829afe3e 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -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"