diff options
| author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-10 21:08:51 +0000 |
|---|---|---|
| committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-11-10 21:08:51 +0000 |
| commit | 13bcb8221306526671a61df589f7c05505c9934c (patch) | |
| tree | baa61780ec5f526ea180c209af8f4d75a5cb4425 | |
| parent | 206b03aeae3a368983ac3d6ad5e5828030bbaacd (diff) | |
| download | gitlab-ce-13bcb8221306526671a61df589f7c05505c9934c.tar.gz | |
Add latest changes from gitlab-org/gitlab@master
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" |
