Add latest changes from gitlab-org/gitlab@master

This commit is contained in:
GitLab Bot 2025-07-01 12:11:45 +00:00
parent 8d9b19289a
commit 1f92a1f626
64 changed files with 1095 additions and 442 deletions

View File

@ -3,7 +3,6 @@
*/
export default {
files: [
'app/assets/javascripts/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue',
'app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_comment_form.vue',
'app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue',
'app/assets/javascripts/admin/statistics_panel/components/app.vue',

View File

@ -382,6 +382,9 @@ Dangerfile
/ee/app/models/ee/merge_request.rb
/ee/app/services/merge_requests/
/ee/app/services/ee/merge_requests
!/ee/app/services/merge_requests/merge_audit_event_service.rb
!/ee/app/services/merge_requests/create_from_vulnerability_data_service.rb
!/ee/app/services/merge_requests/policy_violations_detected_audit_event_service.rb
/ee/app/workers/merge_requests/
/ee/app/workers/ee/merge_requests
/ee/app/workers/merge_request_reset_approvals_worker.rb

View File

@ -38,7 +38,6 @@ Gitlab/NoFindInWorkers:
- 'app/workers/issuable_export_csv_worker.rb'
- 'app/workers/issues/placement_worker.rb'
- 'app/workers/members_destroyer/unassign_issuables_worker.rb'
- 'app/workers/merge_requests/handle_assignees_change_worker.rb'
- 'app/workers/merge_worker.rb'
- 'app/workers/namespaces/root_statistics_worker.rb'
- 'app/workers/namespaces/schedule_aggregation_worker.rb'

View File

@ -3702,7 +3702,6 @@ Layout/LineLength:
- 'spec/services/clusters/kubernetes/create_or_update_service_account_service_spec.rb'
- 'spec/services/clusters/management/validate_management_project_permissions_service_spec.rb'
- 'spec/services/clusters/update_service_spec.rb'
- 'spec/services/commits/cherry_pick_service_spec.rb'
- 'spec/services/concerns/exclusive_lease_guard_spec.rb'
- 'spec/services/concerns/merge_requests/assigns_merge_params_spec.rb'
- 'spec/services/concerns/rate_limited_service_spec.rb'

View File

@ -1 +1 @@
0.23.1
0.24.0

View File

@ -365,7 +365,7 @@
{"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"},
{"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"},
{"name":"launchy","version":"2.5.2","platform":"ruby","checksum":"8aa0441655aec5514008e1d04892c2de3ba57bd337afb984568da091121a241b"},
{"name":"lefthook","version":"1.11.13","platform":"ruby","checksum":"64eee33daf516f27b8948c9734e495d740ba9aa211aadcf34c35cfb1008fbaa2"},
{"name":"lefthook","version":"1.11.14","platform":"ruby","checksum":"c11c55f5096f5d38068b66be8a33143899b7095f28a8145c9adf0b3eb611c098"},
{"name":"letter_opener","version":"1.10.0","platform":"ruby","checksum":"2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2"},
{"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"},
{"name":"libyajl2","version":"2.1.0","platform":"ruby","checksum":"aa5df6c725776fc050c8418450de0f7c129cb7200b811907c4c0b3b5c0aea0ef"},
@ -464,8 +464,8 @@
{"name":"open4","version":"1.3.4","platform":"ruby","checksum":"a1df037310624ecc1ea1d81264b11c83e96d0c3c1c6043108d37d396dcd0f4b1"},
{"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"},
{"name":"opensearch-ruby","version":"3.4.0","platform":"ruby","checksum":"0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d"},
{"name":"openssl","version":"3.2.0","platform":"java","checksum":"9a1c870b4175ee90bcd233b5041a5ca8072f5f5f06d404ab3c786aa31daffa02"},
{"name":"openssl","version":"3.2.0","platform":"ruby","checksum":"3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14"},
{"name":"openssl","version":"3.3.0","platform":"java","checksum":"1755479b8f17a507f0d01020365b4ba96484c033ae88aef410f69d3240261657"},
{"name":"openssl","version":"3.3.0","platform":"ruby","checksum":"ff3a573fc97ab30f69483fddc80029f91669bf36532859bd182d1836f45aee79"},
{"name":"openssl-signature_algorithm","version":"1.3.0","platform":"ruby","checksum":"a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80"},
{"name":"opentelemetry-api","version":"1.2.5","platform":"ruby","checksum":"ab3d9a0566cd2ee068ade40e840bc973383ab8568e693c0c5712f0c789122cc9"},
{"name":"opentelemetry-common","version":"0.21.0","platform":"ruby","checksum":"fe891a44583a20bc3217b324aec76d066504494951682d391cfd57d40cd01c98"},

View File

@ -1116,7 +1116,7 @@ GEM
language_server-protocol (3.17.0.3)
launchy (2.5.2)
addressable (~> 2.8)
lefthook (1.11.13)
lefthook (1.11.14)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@ -1324,7 +1324,7 @@ GEM
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.2.5)

View File

@ -365,7 +365,7 @@
{"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"},
{"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"},
{"name":"launchy","version":"2.5.2","platform":"ruby","checksum":"8aa0441655aec5514008e1d04892c2de3ba57bd337afb984568da091121a241b"},
{"name":"lefthook","version":"1.11.13","platform":"ruby","checksum":"64eee33daf516f27b8948c9734e495d740ba9aa211aadcf34c35cfb1008fbaa2"},
{"name":"lefthook","version":"1.11.14","platform":"ruby","checksum":"c11c55f5096f5d38068b66be8a33143899b7095f28a8145c9adf0b3eb611c098"},
{"name":"letter_opener","version":"1.10.0","platform":"ruby","checksum":"2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2"},
{"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"},
{"name":"libyajl2","version":"2.1.0","platform":"ruby","checksum":"aa5df6c725776fc050c8418450de0f7c129cb7200b811907c4c0b3b5c0aea0ef"},
@ -464,8 +464,8 @@
{"name":"open4","version":"1.3.4","platform":"ruby","checksum":"a1df037310624ecc1ea1d81264b11c83e96d0c3c1c6043108d37d396dcd0f4b1"},
{"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"},
{"name":"opensearch-ruby","version":"3.4.0","platform":"ruby","checksum":"0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d"},
{"name":"openssl","version":"3.2.0","platform":"java","checksum":"9a1c870b4175ee90bcd233b5041a5ca8072f5f5f06d404ab3c786aa31daffa02"},
{"name":"openssl","version":"3.2.0","platform":"ruby","checksum":"3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14"},
{"name":"openssl","version":"3.3.0","platform":"java","checksum":"1755479b8f17a507f0d01020365b4ba96484c033ae88aef410f69d3240261657"},
{"name":"openssl","version":"3.3.0","platform":"ruby","checksum":"ff3a573fc97ab30f69483fddc80029f91669bf36532859bd182d1836f45aee79"},
{"name":"openssl-signature_algorithm","version":"1.3.0","platform":"ruby","checksum":"a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80"},
{"name":"opentelemetry-api","version":"1.2.5","platform":"ruby","checksum":"ab3d9a0566cd2ee068ade40e840bc973383ab8568e693c0c5712f0c789122cc9"},
{"name":"opentelemetry-common","version":"0.21.0","platform":"ruby","checksum":"fe891a44583a20bc3217b324aec76d066504494951682d391cfd57d40cd01c98"},

View File

@ -1110,7 +1110,7 @@ GEM
language_server-protocol (3.17.0.3)
launchy (2.5.2)
addressable (~> 2.8)
lefthook (1.11.13)
lefthook (1.11.14)
letter_opener (1.10.0)
launchy (>= 2.2, < 4)
letter_opener_web (3.0.0)
@ -1318,7 +1318,7 @@ GEM
opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3)
multi_json (>= 1.0)
openssl (3.2.0)
openssl (3.3.0)
openssl-signature_algorithm (1.3.0)
openssl (> 2.0)
opentelemetry-api (1.2.5)

View File

@ -105,6 +105,7 @@ export default {
'contextCommits',
'contextCommitsLoadingError',
'selectedCommits',
// eslint-disable-next-line vue/no-unused-properties -- searchText is mapped from Vuex and used in handleSearchCommits(),
'searchText',
'toRemoveCommits',
]),
@ -281,9 +282,6 @@ export default {
handleModalHide() {
this.resetModalState();
},
shouldShowInputDateFormat(value) {
return ['Committed-before', 'Committed-after'].indexOf(value) !== -1;
},
},
};
</script>

View File

@ -1,7 +1,5 @@
<script>
import { GlSprintf, GlIcon, GlButton } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { mapState } from 'pinia';
import { IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { sprintf, __ } from '~/locale';
@ -11,6 +9,7 @@ import {
getLineClasses,
} from '~/notes/components/multiline_comment_utils';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
import resolvedStatusMixin from '../mixins/resolved_status';
export default {
@ -28,7 +27,7 @@ export default {
},
computed: {
...mapState(useLegacyDiffs, ['getDiffFileByHash']),
...mapGetters(['getDiscussion']),
...mapState(useNotes, ['getDiscussion']),
iconName() {
return this.isDiffDiscussion || this.draft.line_code ? 'doc-text' : 'comment';
},

View File

@ -1,6 +1,6 @@
// eslint-disable-next-line no-restricted-imports
import { mapGetters } from 'vuex';
import { mapState } from 'pinia';
import { sprintf, s__, __ } from '~/locale';
import { useNotes } from '~/notes/store/legacy_notes';
export default {
props: {
@ -21,7 +21,7 @@ export default {
},
},
computed: {
...mapGetters(['isDiscussionResolved']),
...mapState(useNotes, ['isDiscussionResolved']),
resolvedStatusMessage() {
let message;
const discussionResolved = this.isDiscussionResolved(

View File

@ -26,14 +26,18 @@ export const TAB_NAMES = Object.freeze({
export default {
components: {
AlertDetailsTable,
DescriptionComponent,
GlTab,
GlTabs,
HighlightBar,
...(gon.features?.hideIncidentManagementFeatures ? {} : { TimelineTab }),
IncidentMetricTab: () =>
import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
...(gon.features?.hideIncidentManagementFeatures
? {}
: {
AlertDetailsTable,
TimelineTab,
IncidentMetricTab: () =>
import('ee_component/issues/show/components/incidents/incident_metric_tab.vue'),
}),
},
inject: ['fullPath', 'iid', 'hasLinkedAlerts', 'uploadMetricsFeatureAvailable'],
i18n: incidentTabsI18n,
@ -75,10 +79,10 @@ export default {
tabMapping() {
const availableTabs = [TAB_NAMES.SUMMARY];
if (this.uploadMetricsFeatureAvailable) {
if (this.uploadMetricsFeatureAvailable && this.showIncidentManagementFeatures) {
availableTabs.push(TAB_NAMES.METRICS);
}
if (this.hasLinkedAlerts) {
if (this.hasLinkedAlerts && this.showIncidentManagementFeatures) {
availableTabs.push(TAB_NAMES.ALERTS);
}
@ -158,18 +162,20 @@ export default {
<description-component v-bind="$attrs" v-on="$listeners" />
</gl-tab>
<gl-tab
v-if="uploadMetricsFeatureAvailable"
v-if="uploadMetricsFeatureAvailable && showIncidentManagementFeatures"
:title="$options.i18n.metricsTitle"
data-testid="metrics-tab"
>
<!-- eslint-disable-next-line vue/no-undef-components -->
<incident-metric-tab />
</gl-tab>
<gl-tab
v-if="hasLinkedAlerts"
v-if="hasLinkedAlerts && showIncidentManagementFeatures"
class="alert-management-details"
:title="$options.i18n.alertsTitle"
data-testid="alert-details-tab"
>
<!-- eslint-disable-next-line vue/no-undef-components -->
<alert-details-table :alert="alert" :loading="loading" />
</gl-tab>
<gl-tab

View File

@ -1,12 +1,12 @@
<script>
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { escape } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { mapActions } from 'pinia';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { truncateSha } from '~/lib/utils/text_utility';
import { s__, __, sprintf } from '~/locale';
import { FILE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { useNotes } from '~/notes/store/legacy_notes';
import NoteEditedText from './note_edited_text.vue';
import NoteHeader from './note_header.vue';
import ToggleRepliesWidget from './toggle_replies_widget.vue';
@ -102,7 +102,7 @@ export default {
},
},
methods: {
...mapActions(['toggleDiscussion']),
...mapActions(useNotes, ['toggleDiscussion']),
toggleDiscussionHandler() {
this.toggleDiscussion({ discussionId: this.discussion.id });
},

View File

@ -1,8 +1,6 @@
<script>
import { GlButton, GlSkeletonLoader } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { mapState } from 'pinia';
import { mapState, mapActions } from 'pinia';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import SafeHtml from '~/vue_shared/directives/safe_html';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
@ -13,6 +11,7 @@ import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import { isCollapsed } from '~/diffs/utils/diff_file';
import { FILE_DIFF_POSITION_TYPE, IMAGE_DIFF_POSITION_TYPE } from '~/diffs/constants';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
const FIRST_CHAR_REGEX = /^(\+|-| )/;
@ -117,7 +116,7 @@ export default {
}
},
methods: {
...mapActions(['fetchDiscussionDiffLines']),
...mapActions(useNotes, ['fetchDiscussionDiffLines']),
fetchDiff() {
this.error = false;
this.fetchDiscussionDiffLines(this.discussion)

View File

@ -7,8 +7,7 @@ import {
GlButtonGroup,
GlTooltipDirective,
} from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapState } from 'vuex';
import { mapActions, mapState } from 'pinia';
import { InternalEvents } from '~/tracking';
import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import { __ } from '~/locale';
@ -19,6 +18,7 @@ import {
MR_FILTER_TRACKING_USER_COMMENTS,
MR_FILTER_TRACKING_BOT_COMMENTS,
} from '~/notes/constants';
import { useNotes } from '~/notes/store/legacy_notes';
const filterOptionToTrackingEventMap = {
comments: MR_FILTER_TRACKING_USER_COMMENTS,
@ -46,10 +46,7 @@ export default {
};
},
computed: {
...mapState({
mergeRequestFilters: (state) => state.notes.mergeRequestFilters,
discussionSortOrder: (state) => state.notes.discussionSortOrder,
}),
...mapState(useNotes, ['mergeRequestFilters', 'discussionSortOrder']),
selectedFilterText() {
const { length } = this.mergeRequestFilters;
@ -80,7 +77,7 @@ export default {
},
},
methods: {
...mapActions(['updateMergeRequestFilters', 'setDiscussionSortDirection']),
...mapActions(useNotes, ['updateMergeRequestFilters', 'setDiscussionSortDirection']),
updateSortDirection() {
this.setDiscussionSortDirection({
direction: this.isSortAsc ? 'desc' : 'asc',

View File

@ -1,7 +1,7 @@
<script>
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { mapActions } from 'pinia';
import { useNotes } from '~/notes/store/legacy_notes';
import { getSymbol, getLineClasses } from './multiline_comment_utils';
export default {
@ -48,7 +48,7 @@ export default {
this.setSelectedCommentPosition();
},
methods: {
...mapActions(['setSelectedCommentPosition']),
...mapActions(useNotes, ['setSelectedCommentPosition']),
getSymbol({ type }) {
return getSymbol(type);
},

View File

@ -6,8 +6,7 @@ import {
GlDisclosureDropdownItem,
GlDisclosureDropdownGroup,
} from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters, mapState } from 'vuex';
import { mapActions, mapState } from 'pinia';
import Api from '~/api';
import resolvedStatusMixin from '~/batch_comments/mixins/resolved_status';
import { createAlert } from '~/alert';
@ -16,6 +15,7 @@ import { __, sprintf } from '~/locale';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { splitCamelCase } from '~/lib/utils/text_utility';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import { useNotes } from '~/notes/store/legacy_notes';
import ReplyButton from './note_actions/reply_button.vue';
import TimelineEventButton from './note_actions/timeline_event_button.vue';
@ -146,8 +146,12 @@ export default {
};
},
computed: {
...mapState(['isPromoteCommentToTimelineEventInProgress']),
...mapGetters(['getUserDataByProp', 'getNoteableData', 'canUserAddIncidentTimelineEvents']),
...mapState(useNotes, [
'isPromoteCommentToTimelineEventInProgress',
'getUserDataByProp',
'getNoteableData',
'canUserAddIncidentTimelineEvents',
]),
shouldShowActionsDropdown() {
return this.currentUserId;
},
@ -204,7 +208,7 @@ export default {
},
},
methods: {
...mapActions(['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
...mapActions(useNotes, ['toggleAwardRequest', 'promoteCommentToTimelineEvent']),
onEdit() {
this.$emit('handleEdit');
},

View File

@ -1,9 +1,9 @@
<script>
// eslint-disable-next-line no-restricted-imports
import { mapActions, mapGetters } from 'vuex';
import { mapActions, mapState } from 'pinia';
import { createAlert } from '~/alert';
import { __ } from '~/locale';
import AwardsList from '~/vue_shared/components/awards_list.vue';
import { useNotes } from '~/notes/store/legacy_notes';
export default {
components: {
@ -37,13 +37,13 @@ export default {
},
},
computed: {
...mapGetters(['getUserData']),
...mapState(useNotes, ['getUserData']),
isAuthoredByMe() {
return this.noteAuthorId === this.getUserData.id;
},
},
methods: {
...mapActions(['toggleAwardRequest']),
...mapActions(useNotes, ['toggleAwardRequest']),
handleAward(awardName) {
const data = {
endpoint: this.toggleAwardPath,

View File

@ -1,18 +1,13 @@
<script>
import { escape } from 'lodash';
// eslint-disable-next-line no-restricted-imports
import {
mapActions as mapVuexActions,
mapGetters as mapVuexGetters,
mapState as mapVuexState,
} from 'vuex';
import { mapState } from 'pinia';
import { mapState, mapActions } from 'pinia';
import SafeHtml from '~/vue_shared/directives/safe_html';
import { __, sprintf } from '~/locale';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { renderGFM } from '~/behaviors/markdown/render_gfm';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useMrNotes } from '~/mr_notes/store/legacy_mr_notes';
import { useNotes } from '~/notes/store/legacy_notes';
import NoteAttachment from './note_attachment.vue';
import NoteAwardsList from './note_awards_list.vue';
import NoteEditedText from './note_edited_text.vue';
@ -72,12 +67,14 @@ export default {
},
},
computed: {
...mapVuexGetters(['getDiscussion', 'suggestionsCount', 'getSuggestionsFilePaths']),
...mapState(useLegacyDiffs, ['suggestionCommitMessage']),
...mapState(useMrNotes, ['failedToLoadMetadata']),
...mapVuexState({
batchSuggestionsInfo: (state) => state.notes.batchSuggestionsInfo,
}),
...mapState(useNotes, [
'batchSuggestionsInfo',
'getDiscussion',
'suggestionsCount',
'getSuggestionsFilePaths',
]),
discussion() {
if (!this.note.isDraft) return {};
@ -157,7 +154,7 @@ export default {
},
},
methods: {
...mapVuexActions([
...mapActions(useNotes, [
'submitSuggestion',
'submitSuggestionBatch',
'addSuggestionInfoToBatch',

View File

@ -1,7 +1,6 @@
<script>
import { GlBadge, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui';
// eslint-disable-next-line no-restricted-imports
import { mapActions } from 'vuex';
import { getActivePinia } from 'pinia';
import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils';
import { TYPE_ACTIVITY, TYPE_COMMENT } from '~/import/constants';
import { s__ } from '~/locale';
@ -129,11 +128,10 @@ export default {
},
},
methods: {
...mapActions(['setTargetNoteHash']),
updateTargetNoteHash() {
if (this.$store) {
this.setTargetNoteHash(this.noteTimestampLink);
}
async updateTargetNoteHash() {
if (!getActivePinia()) return;
const { useNotes } = await import('~/notes/store/legacy_notes');
useNotes().setTargetNoteHash(this.noteTimestampLink);
},
handleUsernameMouseEnter() {
this.$refs.authorNameLink.dispatchEvent(new Event('mouseenter'));

View File

@ -31,9 +31,14 @@ export default {
},
computed: {
tagCountText() {
if (isEmpty(this.pageInfo)) {
if (
isEmpty(this.pageInfo) ||
!Number.isInteger(this.pageInfo.total) ||
this.pageInfo.total === 0
) {
return EMPTY_TAG_LABEL;
}
return tagsCountText(this.pageInfo.total);
},
},

View File

@ -146,7 +146,7 @@ export const fetchSidebarCount = ({ commit, state }, skipBlobs) => {
return Promise.all(promises);
};
export const setQuery = async ({ state, commit, getters }, { key, value }) => {
export const setQuery = async ({ state, commit, getters, dispatch }, { key, value }) => {
commit(types.SET_QUERY, { key, value });
if (SIDEBAR_PARAMS.includes(key)) {
@ -157,13 +157,32 @@ export const setQuery = async ({ state, commit, getters }, { key, value }) => {
setDataToLS(LS_REGEX_HANDLE, value);
}
if (state.searchType === SEARCH_TYPE_ZOEKT && getters.currentScope === SCOPE_BLOB) {
const newUrl = setUrlParams({ ...state.query }, window.location.href, false, true);
const isZoektSearch =
state.searchType === SEARCH_TYPE_ZOEKT && getters.currentScope === SCOPE_BLOB;
if (isZoektSearch && key === 'search') {
const shouldResetPage = state.query?.page > 1 || state.urlQuery?.page > 1;
const query = shouldResetPage ? { ...state.query, page: 1 } : { ...state.query };
const newUrl = setUrlParams(query, window.location.href, true, true);
document.title = buildDocumentTitle(state.query.search);
updateHistory({ state: state.query, title: state.query.search, url: newUrl, replace: false });
updateHistory({ state: query, title: state.query.search, url: newUrl, replace: true });
if (shouldResetPage) {
commit(types.SET_QUERY, { key: 'page', value: 1 });
}
await nextTick();
fetchSidebarCount({ state, commit });
dispatch('fetchSidebarCount');
}
if (isZoektSearch && key === 'page') {
updateHistory({
state: state.query,
title: state.query.search,
url: setUrlParams({ ...state.query }, window.location.href, true, true),
replace: true,
});
}
};

View File

@ -1,5 +1,5 @@
<script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui';
import { GlButton, GlTooltipDirective, GlAnimatedChevronLgDownUpIcon } from '@gitlab/ui';
import { __ } from '~/locale';
import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './mr_widget_status_icon.vue';
@ -8,6 +8,7 @@ import Actions from './action_buttons.vue';
export default {
components: {
GlButton,
GlAnimatedChevronLgDownUpIcon,
StatusIcon,
Actions,
},
@ -120,13 +121,14 @@ export default {
:title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
:aria-label="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
:aria-expanded="collapsed ? 'false' : 'true'"
:icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
size="small"
class="gl-align-top"
class="btn-icon"
data-testid="widget-toggle"
@click="() => $emit('toggle')"
/>
>
<gl-animated-chevron-lg-down-up-icon :is-on="!collapsed" />
</gl-button>
</div>
</div>
<button

View File

@ -1,6 +1,12 @@
<!-- eslint-disable vue/multi-word-component-names -->
<script>
import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui';
import {
GlButton,
GlLink,
GlTooltipDirective,
GlLoadingIcon,
GlAnimatedChevronLgDownUpIcon,
} from '@gitlab/ui';
import { kebabCase } from 'lodash';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { normalizeHeaders } from '~/lib/utils/common_utils';
@ -40,6 +46,7 @@ export default {
GlLink,
GlButton,
GlLoadingIcon,
GlAnimatedChevronLgDownUpIcon,
ContentRow,
DynamicContent,
DynamicScroller,
@ -443,12 +450,14 @@ export default {
:title="collapseButtonLabel"
:aria-expanded="`${!isCollapsed}`"
:aria-label="collapseButtonLabel"
:icon="isCollapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary"
data-testid="toggle-button"
size="small"
class="btn-icon"
@click="toggleCollapsed"
/>
>
<gl-animated-chevron-lg-down-up-icon :is-on="!isCollapsed" />
</gl-button>
</div>
</div>
</div>

View File

@ -399,17 +399,24 @@ export default {
</template>
</gl-filtered-search>
</div>
<gl-sorting
v-if="selectedSortOption"
:sort-options="transformedSortOptions"
:sort-by="sortById"
:is-ascending="sortDirectionAscending"
class="sort-dropdown-container gl-w-full sm:!gl-m-0 sm:gl-w-auto"
dropdown-class="gl-grow"
dropdown-toggle-class="gl-grow"
sort-direction-toggle-class="!gl-shrink !gl-grow-0"
@sortByChange="handleSortByChange"
@sortDirectionChange="handleSortDirectionChange"
/>
<div
:class="{
'gl-flex gl-items-center gl-justify-between gl-gap-3': $scopedSlots['user-preference'],
}"
>
<slot name="user-preference"></slot>
<gl-sorting
v-if="selectedSortOption"
:sort-options="transformedSortOptions"
:sort-by="sortById"
:is-ascending="sortDirectionAscending"
class="sort-dropdown-container gl-w-full sm:!gl-m-0 sm:gl-w-auto"
dropdown-toggle-class="gl-grow"
dropdown-class="gl-grow"
sort-direction-toggle-class="!gl-shrink !gl-grow-0"
@sortByChange="handleSortByChange"
@sortDirectionChange="handleSortDirectionChange"
/>
</div>
</div>
</template>

View File

@ -355,7 +355,11 @@ export default {
@checked-input="handleAllIssuablesCheckedInput"
@onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)"
/>
>
<template #user-preference>
<slot name="user-preference"></slot>
</template>
</filtered-search-bar>
<gl-alert
v-if="error"
variant="danger"

View File

@ -0,0 +1,126 @@
<script>
import {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
GlToggle,
GlTooltipDirective,
} from '@gitlab/ui';
import { __, s__ } from '~/locale';
import { createAlert } from '~/alert';
import updateWorkItemsDisplaySettings from '../../graphql/update_user_preferences.mutation.graphql';
export default {
components: {
GlDisclosureDropdown,
GlDisclosureDropdownItem,
GlToggle,
},
directives: {
GlTooltip: GlTooltipDirective,
},
inject: {
isSignedIn: {
default: false,
},
},
i18n: {
displayOptions: s__('WorkItems|Display options'),
yourPreferences: s__('WorkItems|Your preferences'),
openItemsInSidePanel: s__('WorkItems|Open items in side panel'),
},
props: {
displaySettings: {
type: Object,
required: false,
default: () => ({}),
},
},
data() {
return {
isDropdownVisible: false,
isLoading: false,
};
},
computed: {
tooltipText() {
return !this.isDropdownVisible ? this.$options.i18n.displayOptions : '';
},
shouldOpenItemsInSidePanel() {
return this.displaySettings?.shouldOpenItemsInSidePanel ?? true;
},
},
methods: {
showDropdown() {
this.isDropdownVisible = true;
},
hideDropdown() {
this.isDropdownVisible = false;
},
async toggleSidePanelPreference() {
const newDisplaySettings = {
...this.displaySettings,
shouldOpenItemsInSidePanel: !this.shouldOpenItemsInSidePanel,
};
const input = {
workItemsDisplaySettings: newDisplaySettings,
};
this.isLoading = true;
try {
await this.$apollo.mutate({
mutation: updateWorkItemsDisplaySettings,
variables: {
input,
},
});
this.$emit('displaySettingsChanged', newDisplaySettings);
} catch (error) {
createAlert({
message: __('Something went wrong while saving the preference.'),
captureError: true,
error,
});
} finally {
this.isLoading = false;
}
},
},
};
</script>
<template>
<gl-disclosure-dropdown
v-if="isSignedIn"
v-gl-tooltip="tooltipText"
icon="preferences"
text-sr-only
:toggle-text="$options.i18n.displayOptions"
category="primary"
no-caret
placement="bottom-end"
:auto-close="false"
class="gl-mt-[10px] sm:gl-mt-0"
@shown="showDropdown"
@hidden="hideDropdown"
>
<div class="gl-mt-2">
<span class="gl-pl-4 gl-text-sm gl-font-bold">{{ $options.i18n.yourPreferences }}</span>
<gl-disclosure-dropdown-item
class="work-item-dropdown-toggle"
@action="toggleSidePanelPreference"
>
<template #list-item>
<gl-toggle
:value="shouldOpenItemsInSidePanel"
:label="$options.i18n.openItemsInSidePanel"
class="gl-justify-between"
label-position="left"
:is-loading="isLoading"
/>
</template>
</gl-disclosure-dropdown-item>
</div>
</gl-disclosure-dropdown>
</template>

View File

@ -0,0 +1,8 @@
query getUserWorkItemsDisplaySettingsPreferences {
currentUser {
id
userPreferences {
workItemsDisplaySettings
}
}
}

View File

@ -0,0 +1,7 @@
mutation updateWorkItemsDisplaySettings($input: UserPreferencesUpdateInput!) {
userPreferencesUpdate(input: $input) {
userPreferences {
workItemsDisplaySettings
}
}
}

View File

@ -91,6 +91,7 @@ import CreateWorkItemModal from '../components/create_work_item_modal.vue';
import WorkItemHealthStatus from '../components/work_item_health_status.vue';
import WorkItemDrawer from '../components/work_item_drawer.vue';
import WorkItemListHeading from '../components/work_item_list_heading.vue';
import WorkItemUserPreferences from '../components/shared/work_item_list_preferences.vue';
import {
BASE_ALLOWED_CREATE_TYPES,
DETAIL_VIEW_QUERY_PARAM_NAME,
@ -102,6 +103,7 @@ import {
WORK_ITEM_TYPE_NAME_KEY_RESULT,
WORK_ITEM_TYPE_NAME_OBJECTIVE,
} from '../constants';
import getUserWorkItemsDisplaySettingsPreferences from '../graphql/get_user_preferences.query.graphql';
const EmojiToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
@ -135,6 +137,7 @@ export default {
CreateWorkItemModal,
LocalBoard,
WorkItemListHeading,
WorkItemUserPreferences,
},
mixins: [glFeatureFlagMixin()],
inject: [
@ -204,9 +207,27 @@ export default {
initialLoadWasFiltered: false,
showLocalBoard: false,
namespaceId: null,
displaySettings: {},
};
},
apollo: {
displaySettings: {
query: getUserWorkItemsDisplaySettingsPreferences,
update(data) {
return (
data?.currentUser?.userPreferences?.workItemsDisplaySettings ?? {
shouldOpenItemsInSidePanel: true,
}
);
},
skip() {
return !this.isSignedIn;
},
error(error) {
this.error = __('An error occurred while getting work item user preference.');
Sentry.captureException(error);
},
},
workItemsFull: {
query() {
return getWorkItemsQuery;
@ -335,6 +356,8 @@ export default {
});
},
workItemDrawerEnabled() {
const shouldOpenItemsInSidePanel = this.displaySettings?.shouldOpenItemsInSidePanel ?? true;
if (!shouldOpenItemsInSidePanel) return false;
if (this.glFeatures.workItemViewForIssues) {
return true;
}
@ -950,6 +973,9 @@ export default {
this.isInitialLoadComplete = false;
this.refetchItems({ refetchCounts: true });
},
handleDisplaySettingsChanged(newDisplaySettings) {
this.displaySettings = newDisplaySettings;
},
},
};
</script>
@ -1009,7 +1035,14 @@ export default {
@previous-page="handlePreviousPage"
@sort="handleSort"
@select-issuable="handleToggle"
@displaySettingsChanged="handleDisplaySettingsChanged"
>
<template #user-preference>
<work-item-user-preferences
:display-settings="displaySettings"
@displaySettingsChanged="handleDisplaySettingsChanged"
/>
</template>
<template v-if="!isPlanningViewsEnabled" #nav-actions>
<div class="gl-flex gl-gap-3">
<gl-button

View File

@ -442,6 +442,7 @@ class ProjectPolicy < BasePolicy
rule { can?(:reporter_access) }.policy do
enable :admin_issue_board
enable :read_code
enable :download_code
enable :read_statistics
enable :daily_statistics
@ -896,6 +897,7 @@ class ProjectPolicy < BasePolicy
rule { repository_disabled }.policy do
prevent :build_push_code
prevent :push_code
prevent :read_code
prevent :download_code
prevent :build_download_code
prevent :fork_project
@ -996,6 +998,7 @@ class ProjectPolicy < BasePolicy
enable :read_deployment
enable :read_commit_status
enable :read_container_image
enable :read_code
enable :download_code
enable :read_release
enable :download_wiki_code
@ -1093,6 +1096,7 @@ class ProjectPolicy < BasePolicy
rule { download_code_deploy_key }.policy do
enable :download_code
enable :read_code
end
rule { push_code_deploy_key }.policy do
@ -1207,10 +1211,6 @@ class ProjectPolicy < BasePolicy
enable :read_project_metadata
end
rule { can?(:download_code) }.policy do
enable :read_code
end
rule { can?(:developer_access) & namespace_catalog_available }.policy do
enable :read_namespace_catalog
end

View File

@ -22,7 +22,7 @@ module Integrations
end
expose :tags do |item|
item['tags'].map { |tag| strip_tags(tag['name']) }
item['tags'].blank? ? [] : item['tags'].map { |tag| strip_tags(tag['name']) }
end
private

View File

@ -13,14 +13,15 @@ class MergeRequests::HandleAssigneesChangeWorker
idempotent!
def perform(merge_request_id, user_id, old_assignee_ids, options = {})
merge_request = MergeRequest.find(merge_request_id)
user = User.find(user_id)
merge_request = MergeRequest.find_by_id(merge_request_id)
user = User.find_by_id(user_id)
return unless merge_request && user
old_assignees = User.id_in(old_assignee_ids)
::MergeRequests::HandleAssigneesChangeService
.new(project: merge_request.target_project, current_user: user)
.execute(merge_request, old_assignees, options)
rescue ActiveRecord::RecordNotFound
end
end

View File

@ -29,6 +29,8 @@
- 1
- - admin_emails
- 1
- - ai_active_context_code_repository_index
- 1
- - ai_active_context_code_saas_initial_indexing_event
- 1
- - ai_knowledge_graph_indexing_task

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ChangeWorkItemCustomLifecycleNameLimit < Gitlab::Database::Migration[2.3]
milestone '18.2'
disable_ddl_transaction!
CONSTRAINT_NAME = 'check_1feff2de99'
def up
remove_text_limit :work_item_custom_lifecycles, :name, constraint_name: CONSTRAINT_NAME
add_text_limit :work_item_custom_lifecycles, :name, 64, constraint_name: CONSTRAINT_NAME
end
def down
remove_text_limit :work_item_custom_lifecycles, :name, constraint_name: CONSTRAINT_NAME
add_text_limit :work_item_custom_lifecycles, :name, 255, constraint_name: CONSTRAINT_NAME
end
end

View File

@ -0,0 +1,18 @@
# frozen_string_literal: true
class ChangeWorkItemCustomStatusDescriptionLimit < Gitlab::Database::Migration[2.3]
milestone '18.2'
disable_ddl_transaction!
CONSTRAINT_NAME = 'check_8ea8b3c991'
def up
remove_text_limit :work_item_custom_statuses, :description, constraint_name: CONSTRAINT_NAME
add_text_limit :work_item_custom_statuses, :description, 128, constraint_name: CONSTRAINT_NAME
end
def down
remove_text_limit :work_item_custom_statuses, :description, constraint_name: CONSTRAINT_NAME
add_text_limit :work_item_custom_statuses, :description, 255, constraint_name: CONSTRAINT_NAME
end
end

View File

@ -0,0 +1 @@
443d16f76f1a3b90e71b697a443466779146c6f2a20946b58801dda73fd30d06

View File

@ -0,0 +1 @@
c14b80337285a053b5b2d7009adca6515caad99d477cc8c106765f3751938da2

View File

@ -26122,7 +26122,7 @@ CREATE TABLE work_item_custom_lifecycles (
name text NOT NULL,
created_by_id bigint,
updated_by_id bigint,
CONSTRAINT check_1feff2de99 CHECK ((char_length(name) <= 255))
CONSTRAINT check_1feff2de99 CHECK ((char_length(name) <= 64))
);
CREATE SEQUENCE work_item_custom_lifecycles_id_seq
@ -26148,7 +26148,7 @@ CREATE TABLE work_item_custom_statuses (
converted_from_system_defined_status_identifier smallint,
CONSTRAINT check_4789467800 CHECK ((char_length(color) <= 7)),
CONSTRAINT check_720a7c4d24 CHECK ((char_length(name) <= 32)),
CONSTRAINT check_8ea8b3c991 CHECK ((char_length(description) <= 255)),
CONSTRAINT check_8ea8b3c991 CHECK ((char_length(description) <= 128)),
CONSTRAINT check_ff2bac1606 CHECK ((category > 0))
);

View File

@ -30,7 +30,7 @@ The namespace is a user or group in GitLab, such as `gitlab.com/sidney-jones` or
Using the GitLab UI, the GitHub importer always imports from the
`github.com` domain. If you are importing from a self-hosted GitHub Enterprise Server domain, use the
[GitLab Import API](#use-the-api) GitHub endpoint.
[GitLab Import API](#use-the-api) GitHub endpoint with a GitLab access token with the `api` scope.
You can change the target namespace and target repository name before you import.

View File

@ -7252,6 +7252,9 @@ msgstr ""
msgid "An error occurred while getting merge request counts"
msgstr ""
msgid "An error occurred while getting work item user preference."
msgstr ""
msgid "An error occurred while initializing path locks"
msgstr ""
@ -59713,6 +59716,9 @@ msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again."
msgstr ""
msgid "Something went wrong while saving the preference."
msgstr ""
msgid "Something went wrong while setting %{issuableType} %{dateType} date."
msgstr ""
@ -70208,6 +70214,15 @@ msgstr ""
msgid "WorkItems|Ancestors not available"
msgstr ""
msgid "WorkItems|Display options"
msgstr ""
msgid "WorkItems|Open items in side panel"
msgstr ""
msgid "WorkItems|Your preferences"
msgstr ""
msgid "WorkItem| %{workItemType}s closed"
msgstr ""

View File

@ -326,5 +326,6 @@
"engines": {
"node": ">=12.22.1",
"yarn": "^1.10.0"
}
},
"packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
}

View File

@ -1,19 +1,27 @@
import { mountExtended } from 'helpers/vue_test_utils_helper';
import Vue from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
import PreviewItem from '~/batch_comments/components/preview_item.vue';
import store from '~/mr_notes/stores';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { createDraft } from '../mock_data';
jest.mock('~/behaviors/markdown/render_gfm');
jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
Vue.use(PiniaVuePlugin);
describe('Batch comments draft preview item component', () => {
let wrapper;
let pinia;
let draft;
beforeEach(() => {
store.reset();
store.getters.getDiscussion = jest.fn(() => null);
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
});
function createComponent(extra = {}, improvedReviewExperience = false) {
@ -23,9 +31,7 @@ describe('Batch comments draft preview item component', () => {
};
wrapper = mountExtended(PreviewItem, {
mocks: {
$store: store,
},
pinia,
propsData: { draft },
provide: {
glFeatures: { improvedReviewExperience },
@ -93,17 +99,18 @@ describe('Batch comments draft preview item component', () => {
describe('for thread', () => {
beforeEach(() => {
store.getters.getDiscussion.mockReturnValue({
id: '1',
notes: [
{
author: {
name: "Author 'Nick' Name",
useNotes().discussions = [
{
id: '1',
notes: [
{
author: {
name: "Author 'Nick' Name",
},
},
},
],
});
store.getters.isDiscussionResolved = jest.fn().mockReturnValue(false);
],
},
];
createComponent({ discussion_id: '1', resolve_discussion: true });
});

View File

@ -91,6 +91,7 @@ describe('Incident Tabs component', () => {
const findSummaryTab = () => wrapper.findByTestId('summary-tab');
const findTimelineTab = () => wrapper.findByTestId('timeline-tab');
const findAlertDetailsTab = () => wrapper.findByTestId('alert-details-tab');
const findMetricsTab = () => wrapper.findByTestId('metrics-tab');
const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar);
@ -227,5 +228,14 @@ describe('Incident Tabs component', () => {
it('does not render the timeline tab', () => {
expect(findTimelineTab().exists()).toBe(false);
});
it('does not render the alert details tab', () => {
mountComponent({ hasLinkedAlerts: true });
expect(findAlertDetailsTab().exists()).toBe(false);
});
it('does not render the metrics tab', () => {
expect(findMetricsTab().exists()).toBe(false);
});
});
});

View File

@ -1,20 +1,25 @@
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue';
import createStore from '~/notes/stores';
import mockDiffFile from 'jest/diffs/mock_data/diff_discussions';
import { useNotes } from '~/notes/store/legacy_notes';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { discussionMock } from '../mock_data';
Vue.use(PiniaVuePlugin);
describe('diff_discussion_header component', () => {
let store;
let pinia;
let wrapper;
const createComponent = ({ propsData = {} } = {}) => {
wrapper = shallowMountExtended(diffDiscussionHeader, {
store,
pinia,
propsData: {
discussion: discussionMock,
...propsData,
@ -24,8 +29,9 @@ describe('diff_discussion_header component', () => {
beforeEach(() => {
window.mrTabs = {};
store = createStore();
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
createComponent({ propsData: { discussion: discussionMock } });
});
@ -60,10 +66,6 @@ describe('diff_discussion_header component', () => {
let commitElement;
beforeEach(async () => {
store.state.diffs = {
projectPath: 'something',
};
createComponent({
propsData: {
discussion: {
@ -202,5 +204,18 @@ describe('diff_discussion_header component', () => {
});
expect(wrapper.findComponent(ToggleRepliesWidget).props('collapsed')).toBe(true);
});
it('toggles discussion', () => {
createComponent({
propsData: {
discussion: {
...discussionMock,
expanded: false,
},
},
});
wrapper.findComponent(ToggleRepliesWidget).vm.$emit('toggle');
expect(useNotes().toggleDiscussion).toHaveBeenCalledWith({ discussionId: discussionMock.id });
});
});
});

View File

@ -5,17 +5,16 @@ import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json';
import imageDiscussionFixture from 'test_fixtures/merge_requests/image_diff_discussion.json';
import { createStore } from '~/mr_notes/stores';
import DiffWithNote from '~/notes/components/diff_with_note.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(PiniaVuePlugin);
describe('diff_with_note', () => {
let store;
let pinia;
let wrapper;
@ -37,7 +36,6 @@ describe('diff_with_note', () => {
const createComponent = (propsData) => {
wrapper = shallowMount(DiffWithNote, {
propsData,
store,
pinia,
});
};
@ -45,15 +43,7 @@ describe('diff_with_note', () => {
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
store = createStore();
store.replaceState({
...store.state,
notes: {
noteableData: {
current_user: {},
},
},
});
useNotes().noteableData = { current_user: {} };
});
describe('text diff', () => {
@ -199,7 +189,7 @@ describe('diff_with_note', () => {
beforeEach(() => {
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} },
store,
pinia,
});
});
@ -218,7 +208,7 @@ describe('diff_with_note', () => {
wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} },
store,
pinia,
});
});

View File

@ -1,6 +1,8 @@
import { getByRole } from '@testing-library/dom';
import { shallowMount, mount } from '@vue/test-utils';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import DiscussionNotes from '~/notes/components/discussion_notes.vue';
import NoteableNote from '~/notes/components/noteable_note.vue';
import { SYSTEM_NOTE } from '~/notes/constants';
@ -8,6 +10,9 @@ import createStore from '~/notes/stores';
import PlaceholderNote from '~/vue_shared/components/notes/placeholder_note.vue';
import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_system_note.vue';
import SystemNote from '~/vue_shared/components/notes/system_note.vue';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useNotes } from '~/notes/store/legacy_notes';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data';
jest.mock('~/behaviors/markdown/render_gfm');
@ -20,14 +25,18 @@ const DISCUSSION_WITH_LINE_RANGE = {
},
};
Vue.use(PiniaVuePlugin);
describe('DiscussionNotes', () => {
let store;
let pinia;
let wrapper;
const getList = () => getByRole(wrapper.element, 'list');
const createComponent = (props, mountingMethod = shallowMount) => {
wrapper = mountingMethod(DiscussionNotes, {
store,
pinia,
propsData: {
discussion: discussionMock,
isExpanded: false,
@ -48,6 +57,9 @@ describe('DiscussionNotes', () => {
};
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
store = createStore();
store.dispatch('setNoteableData', noteableDataMock);
store.dispatch('setNotesData', notesDataMock);

View File

@ -1,7 +1,7 @@
import { GlCollapsibleListbox, GlListboxItem, GlButton } from '@gitlab/ui';
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { mockTracking } from 'helpers/tracking_helper';
import DiscussionFilter from '~/notes/components/mr_discussion_filter.vue';
@ -11,36 +11,19 @@ import {
MR_FILTER_TRACKING_USER_COMMENTS,
MR_FILTER_TRACKING_BOT_COMMENTS,
} from '~/notes/constants';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(Vuex);
Vue.use(PiniaVuePlugin);
describe('Merge request discussion filter component', () => {
let wrapper;
let store;
let updateMergeRequestFilters;
let setDiscussionSortDirection;
function createComponent(mergeRequestFilters = MR_FILTER_OPTIONS.map((f) => f.value)) {
updateMergeRequestFilters = jest.fn();
setDiscussionSortDirection = jest.fn();
store = new Vuex.Store({
modules: {
notes: {
state: {
mergeRequestFilters,
discussionSortOrder: 'asc',
},
actions: {
updateMergeRequestFilters,
setDiscussionSortDirection,
},
},
},
});
let pinia;
function createComponent() {
wrapper = mountExtended(DiscussionFilter, {
store,
pinia,
});
}
@ -48,6 +31,13 @@ describe('Merge request discussion filter component', () => {
return wrapper.findComponentByTestId(`listbox-item-${value}`);
}
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes().mergeRequestFilters = MR_FILTER_OPTIONS.map((f) => f.value);
useNotes().discussionSortOrder = 'asc';
});
afterEach(() => {
localStorage.removeItem('mr_activity_filters');
localStorage.removeItem('sort_direction_merge_request');
@ -59,7 +49,7 @@ describe('Merge request discussion filter component', () => {
createComponent();
expect(setDiscussionSortDirection).toHaveBeenCalledWith(expect.anything(), {
expect(useNotes().setDiscussionSortDirection).toHaveBeenCalledWith({
direction: 'desc',
});
});
@ -71,7 +61,7 @@ describe('Merge request discussion filter component', () => {
createComponent();
expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), ['comments']);
expect(useNotes().updateMergeRequestFilters).toHaveBeenCalledWith(['comments']);
});
});
@ -90,7 +80,7 @@ describe('Merge request discussion filter component', () => {
wrapper.findComponent(GlCollapsibleListbox).vm.$emit('hidden');
expect(updateMergeRequestFilters).toHaveBeenCalledWith(expect.anything(), [
expect(useNotes().updateMergeRequestFilters).toHaveBeenCalledWith([
'assignees_reviewers',
'bot_comments',
'comments',
@ -113,7 +103,7 @@ describe('Merge request discussion filter component', () => {
`('updates toggle text to $expectedText with $state', async ({ state, expectedText }) => {
createComponent();
store.state.notes.mergeRequestFilters = state;
useNotes().mergeRequestFilters = state;
await nextTick();
@ -193,7 +183,7 @@ describe('Merge request discussion filter component', () => {
`(
'has the correct attributes and props when the sort-order is "$sortOrder"',
async ({ sortOrder, expectedTitle, expectedIcon }) => {
store.state.notes.discussionSortOrder = sortOrder;
useNotes().discussionSortOrder = sortOrder;
await nextTick();
const sortDirectionButton = wrapper.findByTestId('mr-discussion-sort-direction');

View File

@ -1,14 +1,19 @@
import { GlFormSelect } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import { GlFormSelect, GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue';
import notesModule from '~/notes/stores/modules';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(PiniaVuePlugin);
describe('MultilineCommentForm', () => {
Vue.use(Vuex);
const setSelectedCommentPosition = jest.fn();
let wrapper;
let pinia;
const testLine = {
line_code: 'test',
type: 'test',
@ -16,63 +21,64 @@ describe('MultilineCommentForm', () => {
new_line: 'test',
};
const createWrapper = (props = {}, state) => {
setSelectedCommentPosition.mockReset();
const store = new Vuex.Store({
modules: { notes: notesModule() },
actions: { setSelectedCommentPosition },
});
if (state) store.replaceState({ ...store.state, ...state });
const createWrapper = (props = {}) => {
const propsData = {
line: { ...testLine },
commentLineOptions: [{ text: '1' }],
...props,
};
return mount(MultilineCommentForm, { propsData, store });
wrapper = shallowMount(MultilineCommentForm, { propsData, pinia, stubs: { GlSprintf } });
};
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
});
describe('created', () => {
it('sets commentLineStart to line', () => {
const line = { ...testLine };
const wrapper = createWrapper({ line });
createWrapper({ line });
expect(wrapper.vm.commentLineStart).toEqual(line);
expect(setSelectedCommentPosition).toHaveBeenCalled();
// we can't check for .attributes() because of GlFormSelect design
// all the attributes get converted to a string, so the line object becomes [object Object]
// we can test for the component internals instead which is as reliable as VTUs checks
expect(wrapper.findComponent(GlFormSelect).vm.$attrs.value).toEqual(line);
expect(useNotes().setSelectedCommentPosition).toHaveBeenCalled();
});
it('sets commentLineStart to lineRange', () => {
const lineRange = {
start: { ...testLine },
};
const wrapper = createWrapper({ lineRange });
createWrapper({ lineRange });
expect(wrapper.vm.commentLineStart).toEqual(lineRange.start);
expect(setSelectedCommentPosition).toHaveBeenCalled();
expect(wrapper.findComponent(GlFormSelect).vm.$attrs.value).toEqual(lineRange.start);
expect(useNotes().setSelectedCommentPosition).toHaveBeenCalled();
});
});
describe('destroyed', () => {
it('calls setSelectedCommentPosition', () => {
const wrapper = createWrapper();
createWrapper();
wrapper.destroy();
// Once during created, once during destroyed
expect(setSelectedCommentPosition).toHaveBeenCalledTimes(2);
expect(useNotes().setSelectedCommentPosition).toHaveBeenCalledTimes(2);
});
});
it('handles changing the start line', () => {
const line = { ...testLine };
const wrapper = createWrapper({ line });
createWrapper({ line });
const glSelect = wrapper.findComponent(GlFormSelect);
glSelect.vm.$emit('change', { ...testLine });
expect(wrapper.vm.commentLineStart).toEqual(line);
expect(wrapper.findComponent(GlFormSelect).vm.$attrs.value).toEqual(line);
expect(wrapper.emitted('input')).toHaveLength(1);
// Once during created, once during updateCommentLineStart
expect(setSelectedCommentPosition).toHaveBeenCalledTimes(2);
expect(useNotes().setSelectedCommentPosition).toHaveBeenCalledTimes(2);
});
});

View File

@ -5,7 +5,9 @@ import {
} from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
import { nextTick } from 'vue';
import Vue, { nextTick } from 'vue';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils';
@ -13,13 +15,17 @@ import noteActions from '~/notes/components/note_actions.vue';
import { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue';
import createStore from '~/notes/stores';
import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { userDataMock } from '../mock_data';
Vue.use(PiniaVuePlugin);
describe('noteActions', () => {
let wrapper;
let store;
let pinia;
let props;
let actions;
let axiosMock;
@ -37,20 +43,20 @@ describe('noteActions', () => {
noteableType,
isPromotionInProgress = true,
}) => {
store.dispatch('setUserData', {
useNotes().setUserData({
...userDataMock,
can_add_timeline_events: userCanAdd,
});
store.state.noteableData = {
...store.state.noteableData,
useNotes().noteableData = {
...useNotes().noteableData,
type: noteableType,
};
store.state.isPromoteCommentToTimelineEventInProgress = isPromotionInProgress;
useNotes().isPromoteCommentToTimelineEventInProgress = isPromotionInProgress;
};
const mountNoteActions = (propsData) => {
return shallowMount(noteActions, {
store,
pinia,
propsData,
stubs: {
GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
@ -65,7 +71,10 @@ describe('noteActions', () => {
};
beforeEach(() => {
store = createStore();
pinia = createTestingPinia({ plugins: [globalAccessorPlugin], stubActions: false });
useLegacyDiffs();
useNotes().toggleAwardRequest.mockResolvedValue();
useNotes().promoteCommentToTimelineEvent.mockResolvedValue();
props = {
accessLevel: 'Maintainer',
@ -98,7 +107,7 @@ describe('noteActions', () => {
describe('user is logged in', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
useNotes().setUserData(userDataMock);
wrapper = mountNoteActions(props);
});
@ -222,13 +231,13 @@ describe('noteActions', () => {
beforeEach(() => {
wrapper = mountNoteActions(props);
store.state.noteableData = {
useNotes().noteableData = {
current_user: {
can_set_issue_metadata: true,
},
};
store.state.userData = userDataMock;
store.state.noteableData.targetType = 'issue';
useNotes().userData = userDataMock;
useNotes().noteableData.targetType = 'issue';
});
afterEach(() => {
@ -244,13 +253,13 @@ describe('noteActions', () => {
wrapper = mountNoteActions(props, {
targetType: () => 'issue',
});
store.state.noteableData = {
useNotes().noteableData = {
current_user: {
can_update: true,
can_set_issue_metadata: false,
},
};
store.state.userData = userDataMock;
useNotes().userData = userDataMock;
});
afterEach(() => {
@ -282,7 +291,7 @@ describe('noteActions', () => {
describe('user is not logged in', () => {
beforeEach(() => {
// userData can be null https://gitlab.com/gitlab-org/gitlab/-/issues/379375
store.dispatch('setUserData', null);
useNotes().setUserData(null);
wrapper = mountNoteActions({
...props,
canDelete: false,
@ -333,7 +342,7 @@ describe('noteActions', () => {
describe('Draft notes', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canResolve: true, isDraft: true });
});
@ -386,14 +395,11 @@ describe('noteActions', () => {
});
it('when timeline-event-button emits click-promote-comment-to-event, dispatches action', () => {
jest.spyOn(store, 'dispatch').mockImplementation();
expect(store.dispatch).not.toHaveBeenCalled();
expect(useNotes().promoteCommentToTimelineEvent).not.toHaveBeenCalled();
findTimelineButton().vm.$emit('click-promote-comment-to-event');
expect(store.dispatch).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith('promoteCommentToTimelineEvent');
expect(useNotes().promoteCommentToTimelineEvent).toHaveBeenCalledTimes(1);
});
});
});
@ -403,7 +409,7 @@ describe('noteActions', () => {
describe('when user is not allowed to report abuse', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canReportAsAbuse: false });
});
@ -418,7 +424,7 @@ describe('noteActions', () => {
describe('when user is allowed to report abuse', () => {
beforeEach(() => {
store.dispatch('setUserData', userDataMock);
useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canReportAsAbuse: true });
});

View File

@ -1,7 +1,7 @@
import AxiosMockAdapter from 'axios-mock-adapter';
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { TEST_HOST } from 'helpers/test_constants';
import { mountExtended } from 'helpers/vue_test_utils_helper';
import { userDataMock } from 'jest/notes/mock_data';
@ -9,12 +9,15 @@ import EmojiPicker from '~/emoji/components/picker.vue';
import axios from '~/lib/utils/axios_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
import awardsNote from '~/notes/components/note_awards_list.vue';
import createStore from '~/notes/stores';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(Vuex);
Vue.use(PiniaVuePlugin);
describe('Note Awards List', () => {
let wrapper;
let pinia;
let mock;
const awardsMock = [
@ -42,42 +45,34 @@ describe('Note Awards List', () => {
const findAllEmojiAwards = () => wrapper.findAll('gl-emoji');
const findEmojiPicker = () => wrapper.findComponent(EmojiPicker);
const createComponent = (props = defaultProps, store = createStore()) => {
const createComponent = (props = defaultProps) => {
wrapper = mountExtended(awardsNote, {
store,
pinia,
propsData: {
...props,
},
});
};
describe('Note Awards functionality', () => {
const toggleAwardRequestSpy = jest.fn();
const fakeStore = () => {
return new Vuex.Store({
getters: {
getUserData: () => userDataMock,
},
actions: {
toggleAwardRequest: toggleAwardRequestSpy,
},
});
};
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes().setUserData(userDataMock);
useNotes().toggleAwardRequest.mockResolvedValue();
});
describe('Note Awards functionality', () => {
beforeEach(() => {
mock = new AxiosMockAdapter(axios);
mock.onPost(toggleAwardPathMock).reply(HTTP_STATUS_OK, '');
createComponent(
{
awards: awardsMock,
noteAuthorId: 2,
noteId: '545',
canAwardEmoji: true,
toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
},
fakeStore(),
);
createComponent({
awards: awardsMock,
noteAuthorId: 2,
noteId: '545',
canAwardEmoji: true,
toggleAwardPath: '/gitlab-org/gitlab-foss/notes/545/toggle_award_emoji',
});
});
afterEach(() => {
@ -100,7 +95,7 @@ describe('Note Awards List', () => {
await findAwardButton().vm.$emit('click');
const { toggleAwardPath, noteId } = defaultProps;
expect(toggleAwardRequestSpy).toHaveBeenCalledWith(expect.anything(), {
expect(useNotes().toggleAwardRequest).toHaveBeenCalledWith({
awardName: awardsMock[0].name,
endpoint: toggleAwardPath,
noteId,

View File

@ -1,14 +1,10 @@
import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import NoteBody from '~/notes/components/note_body.vue';
import NoteAwardsList from '~/notes/components/note_awards_list.vue';
import NoteForm from '~/notes/components/note_form.vue';
import createStore from '~/notes/stores';
import notes from '~/notes/stores/modules/index';
import Suggestions from '~/vue_shared/components/markdown/suggestions.vue';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { globalAccessorPlugin } from '~/pinia/plugins';
@ -24,23 +20,8 @@ describe('issue_note_body component', () => {
let wrapper;
let pinia;
const createComponent = ({
props = {},
noteableData = noteableDataMock,
notesData = notesDataMock,
store = null,
} = {}) => {
let mockStore;
if (!store) {
mockStore = createStore();
mockStore.dispatch('setNoteableData', noteableData);
mockStore.dispatch('setNotesData', notesData);
}
const createComponent = (props = {}) => {
wrapper = shallowMountExtended(NoteBody, {
store: mockStore || store,
pinia,
propsData: {
note,
@ -58,7 +39,8 @@ describe('issue_note_body component', () => {
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
useNotes().setNoteableData(noteableDataMock);
useNotes().setNotesData(notesDataMock);
useMrNotes();
createComponent();
});
@ -73,7 +55,7 @@ describe('issue_note_body component', () => {
describe('isInternalNote', () => {
beforeEach(() => {
createComponent({ props: { isInternalNote: true } });
createComponent({ isInternalNote: true });
});
});
@ -82,7 +64,9 @@ describe('issue_note_body component', () => {
beforeEach(() => {
createComponent({
props: { isEditing: true, autosaveKey, restoreFromAutosave: true },
isEditing: true,
autosaveKey,
restoreFromAutosave: true,
});
});
@ -98,7 +82,7 @@ describe('issue_note_body component', () => {
${false} | ${'Save comment'}
${true} | ${'Save internal note'}
`('renders save button with text "$buttonText"', ({ internal, buttonText }) => {
createComponent({ props: { note: { ...note, internal }, isEditing: true } });
createComponent({ note: { ...note, internal }, isEditing: true });
expect(wrapper.findComponent(NoteForm).props('saveButtonTitle')).toBe(buttonText);
});
@ -112,40 +96,20 @@ describe('issue_note_body component', () => {
describe('commitMessage', () => {
beforeEach(() => {
const mrMetadata = {
useMrNotes().mrMetadata = {
branch_name: 'branch',
project_path: '/path',
project_name: 'name',
username: 'user',
user_full_name: 'user userton',
};
const notesStore = notes();
notesStore.state.notes = {};
const store = new Vuex.Store({
modules: {
notes: notesStore,
page: {
namespaced: true,
state: {
mrMetadata,
},
},
},
});
useMrNotes().mrMetadata = mrMetadata;
useLegacyDiffs().defaultSuggestionCommitMessage =
'*** %{branch_name} %{project_path} %{project_name} %{username} %{user_full_name} %{file_paths} %{suggestions_count} %{files_count} %{co_authored_by}';
createComponent({
store,
props: {
note: { ...note, suggestions: [12345] },
canEdit: true,
file: { file_path: 'abc' },
},
note: { ...note, suggestions: [12345] },
canEdit: true,
file: { file_path: 'abc' },
});
});
@ -159,19 +123,6 @@ describe('issue_note_body component', () => {
});
describe('duo code review feedback', () => {
const createMockStoreWithDiscussion = (discussionId, discussionNotes) => {
return new Vuex.Store({
getters: {
getDiscussion: () => (id) => {
if (id === discussionId) {
return { notes: discussionNotes };
}
return {};
},
},
});
};
it.each`
userType | type | exists | existsText
${'duo_code_review_bot'} | ${null} | ${true} | ${'renders'}
@ -191,12 +142,9 @@ describe('issue_note_body component', () => {
user_type: userType,
},
};
const mockStore = createMockStoreWithDiscussion('discussion1', [duoNote]);
useNotes().discussions = [{ id: 'discussion1', notes: [duoNote] }];
createComponent({
props: { note: duoNote },
store: mockStore,
});
createComponent({ note: duoNote });
expect(wrapper.findByTestId('code-review-feedback').exists()).toBe(exists);
},
@ -213,41 +161,15 @@ describe('issue_note_body component', () => {
user_type: 'duo_code_review_bot',
},
};
const mockStore = createMockStoreWithDiscussion('discussion1', [note, duoNote]);
useNotes().discussions = [{ id: 'discussion1', notes: [note, duoNote] }];
createComponent({
props: { note: duoNote },
store: mockStore,
});
createComponent({ note: duoNote });
expect(wrapper.findByTestId('code-review-feedback').exists()).toBe(false);
});
});
describe('duo code review feedback text', () => {
const createMockStoreWithDiscussion = (discussionId, discussionNotes) => {
return new Vuex.Store({
getters: {
getDiscussion: () => (id) => {
if (id === discussionId) {
return { notes: discussionNotes };
}
return {};
},
suggestionsCount: () => 0,
getSuggestionsFilePaths: () => [],
},
modules: {
notes: {
state: { batchSuggestionsInfo: [] },
},
page: {
state: { failedToLoadMetadata: false },
},
},
});
};
const createDuoNote = (props = {}) => ({
...note,
id: '1',
@ -262,11 +184,10 @@ describe('issue_note_body component', () => {
it('renders feedback text for the first DiffNote from GitLabDuo', () => {
const duoNote = createDuoNote();
const mockStore = createMockStoreWithDiscussion('discussion1', [duoNote]);
useNotes().discussions = [{ id: 'discussion1', notes: [duoNote] }];
createComponent({
props: { note: duoNote },
store: mockStore,
note: duoNote,
});
const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
@ -275,9 +196,10 @@ describe('issue_note_body component', () => {
it('does not render feedback text for non-DiffNote from GitLabDuo', () => {
const duoNote = createDuoNote({ type: 'DiscussionNote' });
useNotes().discussions = [{ id: 'discussion1', notes: [duoNote] }];
createComponent({
props: { note: duoNote },
note: duoNote,
});
const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
@ -286,14 +208,10 @@ describe('issue_note_body component', () => {
it('does not render feedback text for follow-up DiffNote from GitLabDuo', () => {
const duoNote = createDuoNote({ id: '2' });
const mockStore = createMockStoreWithDiscussion('discussion1', [
{ id: '1' }, // First note has different ID
duoNote,
]);
useNotes().discussions = [{ id: 'discussion1', notes: [{ id: '1' }, duoNote] }];
createComponent({
props: { note: duoNote },
store: mockStore,
note: duoNote,
});
const feedbackDiv = wrapper.find('.gl-text-md.gl-mt-4.gl-text-gray-500');
@ -302,11 +220,10 @@ describe('issue_note_body component', () => {
it('shows default awards list with thumbsup and thumbsdown for first DiffNote from GitLabDuo', () => {
const duoNote = createDuoNote();
const mockStore = createMockStoreWithDiscussion('discussion1', [duoNote]);
useNotes().discussions = [{ id: 'discussion1', notes: [duoNote] }];
createComponent({
props: { note: duoNote },
store: mockStore,
note: duoNote,
});
const awardsList = wrapper.findComponent(NoteAwardsList);
@ -325,7 +242,7 @@ describe('issue_note_body component', () => {
};
createComponent({
props: { note: regularNote },
note: regularNote,
});
const awardsList = wrapper.findComponent(NoteAwardsList);

View File

@ -1,18 +1,19 @@
import Vue, { nextTick } from 'vue';
// eslint-disable-next-line no-restricted-imports
import Vuex from 'vuex';
import { PiniaVuePlugin } from 'pinia';
import { createTestingPinia } from '@pinia/testing';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import waitForPromises from 'helpers/wait_for_promises';
import NoteHeader from '~/notes/components/note_header.vue';
import ImportedBadge from '~/vue_shared/components/imported_badge.vue';
import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(Vuex);
const actions = {
setTargetNoteHash: jest.fn(),
};
Vue.use(PiniaVuePlugin);
describe('NoteHeader component', () => {
let wrapper;
let pinia;
const findActionText = () => wrapper.findComponent({ ref: 'actionText' });
const findTimestampLink = () => wrapper.findComponent({ ref: 'noteTimestampLink' });
@ -51,13 +52,17 @@ describe('NoteHeader component', () => {
const createComponent = (props) => {
wrapper = shallowMountExtended(NoteHeader, {
store: new Vuex.Store({
actions,
}),
pinia,
propsData: { ...props },
});
};
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
});
it('renders an author link if author is passed to props', () => {
createComponent({ author });
@ -106,14 +111,16 @@ describe('NoteHeader component', () => {
expect(findActionText().text()).toBe('Test action text');
});
it('calls an action when timestamp is clicked', () => {
it('calls an action when timestamp is clicked', async () => {
createComponent({
createdAt: '2017-08-02T10:51:58.559Z',
noteId: 123,
});
findTimestampLink().vm.$emit('click');
expect(actions.setTargetNoteHash).toHaveBeenCalled();
await waitForPromises();
expect(useNotes().setTargetNoteHash).toHaveBeenCalled();
});
});

View File

@ -73,7 +73,8 @@ describe('noteable_discussion component', () => {
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
useNotes().saveNote.mockResolvedValue();
useNotes().fetchDiscussionDiffLines.mockResolvedValue();
useBatchComments();
axiosMock = new MockAdapter(axios);
createComponent();

View File

@ -1,7 +1,7 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.vue';
import { EMPTY_TAG_LABEL } from '~/packages_and_registries/harbor_registry/constants';
import { mockArtifactDetail, MOCK_SHA_DIGEST } from '../../mock_data';
describe('Harbor Tags Header', () => {
@ -28,7 +28,11 @@ describe('Harbor Tags Header', () => {
beforeEach(() => {
mountComponent({
propsData: { artifactDetail: mockArtifactDetail, pageInfo: mockPageInfo, tagsLoading: false },
propsData: {
artifactDetail: mockArtifactDetail,
pageInfo: mockPageInfo,
tagsLoading: false,
},
});
});
@ -39,10 +43,54 @@ describe('Harbor Tags Header', () => {
});
describe('tags count', () => {
it('would has the correct text', async () => {
await nextTick();
it('displays the tags count', () => {
expect(findTagsCount().props('text')).toBe('1 tag');
});
describe('when pageInfo.total is NaN', () => {
const nanMockPageInfo = {
page: 1,
perPage: 20,
total: NaN,
totalPages: 1,
};
beforeEach(() => {
mountComponent({
propsData: {
artifactDetail: mockArtifactDetail,
pageInfo: nanMockPageInfo,
tagsLoading: false,
},
});
});
it('displays empty label when there are no tags', () => {
expect(findTagsCount().props('text')).toBe(EMPTY_TAG_LABEL);
});
});
describe('when pageInfo.total is 0', () => {
const nanMockPageInfo = {
page: 1,
perPage: 20,
total: 0,
totalPages: 1,
};
beforeEach(() => {
mountComponent({
propsData: {
artifactDetail: mockArtifactDetail,
pageInfo: nanMockPageInfo,
tagsLoading: false,
},
});
});
it('displays empty label when there are no tags', () => {
expect(findTagsCount().props('text')).toBe(EMPTY_TAG_LABEL);
});
});
});
});

View File

@ -11,7 +11,7 @@ import axios from '~/lib/utils/axios_utils';
import setWindowLocation from 'helpers/set_window_location_helper';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as urlUtils from '~/lib/utils/url_utility';
import * as actions from '~/search/store/actions';
import {
GROUPS_LOCAL_STORAGE_KEY,
PROJECTS_LOCAL_STORAGE_KEY,
@ -22,6 +22,7 @@ import {
import * as types from '~/search/store/mutation_types';
import createState from '~/search/store/state';
import * as storeUtils from '~/search/store/utils';
import * as actions from '~/search/store/actions';
import {
MOCK_QUERY,
MOCK_GROUPS,
@ -203,26 +204,12 @@ describe('Global Search Store Actions', () => {
fetchSidebarCountSpy.mockRestore();
});
it('should update URL, document title, and history', async () => {
it('should update URL, document title, and history', () => {
const getters = { currentScope: 'blobs' };
await actions.setQuery({ state, commit, getters }, payload);
expect(setUrlParams).toHaveBeenCalledWith(
{ ...state.query },
window.location.href,
false,
true,
);
expect(document.title).toBe(state.query.search);
expect(updateHistory).toHaveBeenCalledWith({
state: state.query,
title: state.query.search,
url: 'mocked-new-url',
replace: false,
});
return testAction(actions.setQuery, payload, { ...state, ...getters }, [
{ type: types.SET_QUERY, payload: { key: 'some-key', value: 'some-value' } },
]);
});
it('does not update URL or fetch sidebar counts when conditions are not met', async () => {
@ -268,6 +255,156 @@ describe('Global Search Store Actions', () => {
});
});
});
describe('zoekt search type with blob scope - page handling scenarios', () => {
let originalGon;
let commit;
let fetchSidebarCountSpy;
let modifySearchQuerySpy;
beforeEach(() => {
originalGon = window.gon;
commit = jest.fn();
fetchSidebarCountSpy = jest
.spyOn(actions, 'fetchSidebarCount')
.mockImplementation(() => Promise.resolve());
modifySearchQuerySpy = jest
.spyOn(storeUtils, 'modifySearchQuery')
.mockReturnValue('mocked-clean-url');
window.gon = { features: {} };
storeUtils.isSidebarDirty = jest.fn().mockReturnValue(false);
storeUtils.buildDocumentTitle = jest.fn().mockReturnValue('Built Document Title');
state = createState({
query: { ...MOCK_QUERY, search: 'test-search' },
navigation: { ...MOCK_NAVIGATION },
searchType: 'zoekt',
});
});
afterEach(() => {
window.gon = originalGon;
fetchSidebarCountSpy.mockRestore();
modifySearchQuerySpy.mockRestore();
});
describe('when only "page" attribute changes', () => {
it('should only update history without fetching sidebar counts', async () => {
const getters = { currentScope: 'blobs' };
const payload = { key: 'page', value: 2 };
await actions.setQuery({ state, commit, getters }, payload);
expect(setUrlParams).toHaveBeenCalledWith(
{ ...state.query },
window.location.href,
true,
true,
);
expect(updateHistory).toHaveBeenCalledWith({
state: state.query,
title: state.query.search,
url: 'mocked-new-url',
replace: true,
});
expect(fetchSidebarCountSpy).not.toHaveBeenCalled();
expect(commit).toHaveBeenCalledWith(types.SET_QUERY, payload);
});
});
describe('when "search" attribute changes and page attribute is not present', () => {
beforeEach(() => {
const res = { count: 666 };
mock.onGet().replyOnce(HTTP_STATUS_OK, res);
});
it('should update URL, title, history and fetch sidebar counts', async () => {
const getters = { currentScope: 'blobs' };
const payload = { key: 'search', value: 'new-search' };
state.query = { ...state.query };
delete state.query.page;
state.urlQuery = { ...state.urlQuery };
delete state.urlQuery.page;
await testAction(
actions.setQuery,
payload,
{ ...state, ...getters },
[{ type: types.SET_QUERY, payload: { key: 'search', value: 'new-search' } }],
[{ type: 'fetchSidebarCount' }],
);
});
});
describe('when "search" attribute changes but page is present and not equal to 1', () => {
it('should reset page to 1, update URL with clean URL, and fetch sidebar counts', () => {
const getters = { currentScope: 'blobs' };
const payload = { key: 'search', value: 'new-search' };
state.query = { ...state.query, page: 3 };
state.urlQuery = { ...state.urlQuery, page: 3 };
return testAction(
actions.setQuery,
payload,
{ ...state, ...getters },
[
{ type: types.SET_QUERY, payload: { key: 'search', value: 'new-search' } },
{ type: types.SET_QUERY, payload: { key: 'page', value: 1 } },
],
[{ type: 'fetchSidebarCount' }],
);
});
});
describe('when "search" attribute changes but page is present and equal to 1', () => {
it('should not modify URL for page, update history with original URL', async () => {
const getters = { currentScope: 'blobs' };
const payload = { key: 'search', value: 'new-search' };
state.query = { ...state.query, page: 1 };
state.urlQuery = { ...state.urlQuery, page: 1 };
await testAction(
actions.setQuery,
payload,
{ ...state, ...getters },
[{ type: types.SET_QUERY, payload: { key: 'search', value: 'new-search' } }],
[{ type: 'fetchSidebarCount' }],
);
expect(updateHistory).toHaveBeenCalled();
});
});
describe('when urlQuery has no page but state.query has page', () => {
it('should use original URL without modification', () => {
const getters = { currentScope: 'blobs' };
const payload = { key: 'search', value: 'new-search' };
state.query = { ...state.query, page: 2 };
state.urlQuery = { ...state.urlQuery };
delete state.urlQuery.page;
return testAction(
actions.setQuery,
payload,
{ ...state, ...getters },
[
{ type: types.SET_QUERY, payload: { key: 'search', value: 'new-search' } },
{ type: types.SET_QUERY, payload: { key: 'page', value: 1 } },
],
[{ type: 'fetchSidebarCount' }],
);
});
});
});
});
describe('applyQuery', () => {
@ -410,7 +547,7 @@ describe('Global Search Store Actions', () => {
it(`should ${expectedMutations.length === 0 ? 'NOT' : ''} dispatch ${
expectedMutations.length === 0 ? '' : 'the correct'
} mutations for ${scope}`, () => {
return testAction({ action, state, expectedMutations }).then(() => {
return testAction(action, undefined, state, expectedMutations, []).then(() => {
expect(logger.logError).toHaveBeenCalledTimes(errorLogs);
});
});

View File

@ -1,3 +1,4 @@
import { GlAnimatedChevronLgDownUpIcon } from '@gitlab/ui';
import { nextTick } from 'vue';
import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper';
@ -11,6 +12,7 @@ import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widge
import ReportListItem from '~/merge_requests/reports/components/report_list_item.vue';
import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
jest.mock('~/vue_merge_request_widget/components/widget/telemetry', () => ({
@ -28,6 +30,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
const findExpandedSection = () => wrapper.findByTestId('widget-extension-collapsed-section');
const findActionButtons = () => wrapper.findComponent(ActionButtons);
const findToggleButton = () => wrapper.findByTestId('toggle-button');
const findToggleChevron = () => findToggleButton().findComponent(GlAnimatedChevronLgDownUpIcon);
const findHelpPopover = () => wrapper.findComponent(HelpPopover);
const findDynamicScroller = () => wrapper.findByTestId('dynamic-content-scroller');
@ -282,6 +285,27 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => {
expect(findToggleButton().attributes('aria-label')).toBe('Show details');
});
it('displays the chevron correctly when toggle is clicked', async () => {
await createComponent({
propsData: {
isCollapsible: true,
},
slots: {
content: '<b>More complex content</b>',
},
});
// Vue compat doesn't know about component props if it extends other component
expect(
findToggleChevron().props('isOn') ?? parseBoolean(findToggleChevron().attributes('is-on')),
).toBe(false);
findToggleButton().vm.$emit('click');
await nextTick();
expect(
findToggleChevron().props('isOn') ?? parseBoolean(findToggleChevron().attributes('is-on')),
).toBe(true);
});
it('does not display the content slot until toggle is clicked', async () => {
await createComponent({
propsData: {

View File

@ -0,0 +1,143 @@
import { shallowMount } from '@vue/test-utils';
import Vue, { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import { GlDisclosureDropdown, GlToggle, GlDisclosureDropdownItem } from '@gitlab/ui';
import { createAlert } from '~/alert';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import WorkItemsListPreferences from '~/work_items/components/shared/work_item_list_preferences.vue';
import updateWorkItemsDisplaySettings from '~/work_items/graphql/update_user_preferences.mutation.graphql';
Vue.use(VueApollo);
jest.mock('~/alert');
describe('WorkItemsListPreferences', () => {
let wrapper;
let mockApolloProvider;
const successHandler = jest.fn().mockResolvedValue({
data: {
userPreferencesUpdate: {
__typename: 'UserPreferencesUpdatePayload',
userPreferences: {
__typename: 'UserPreferences',
workItemsDisplaySettings: { shouldOpenItemsInSidePanel: false },
},
errors: [],
},
},
});
const createComponent = ({ props = {}, provide = {}, mutationHandler = successHandler } = {}) => {
mockApolloProvider = createMockApollo([[updateWorkItemsDisplaySettings, mutationHandler]]);
wrapper = shallowMount(WorkItemsListPreferences, {
apolloProvider: mockApolloProvider,
propsData: {
displaySettings: { shouldOpenItemsInSidePanel: true },
...props,
},
provide: { isSignedIn: true, ...provide },
});
};
const findDropdown = () => wrapper.findComponent(GlDisclosureDropdown);
const findToggle = () => wrapper.findComponent(GlToggle);
const findDropdownItem = () => wrapper.findComponent(GlDisclosureDropdownItem);
describe('when user is signed in', () => {
it('renders dropdown with toggle', () => {
createComponent();
expect(findDropdown().exists()).toBe(true);
expect(findToggle().exists()).toBe(true);
});
it('renders toggle with correct initial value', () => {
createComponent();
expect(findToggle().props('value')).toBe(true);
});
it('handles empty displaySettings gracefully', () => {
createComponent({ props: { displaySettings: {} } });
expect(findToggle().props('value')).toBe(true); // defaults to true
});
describe('when toggle is clicked', () => {
it('saves preference and emits event on success', async () => {
createComponent();
findDropdownItem().vm.$emit('action');
await waitForPromises();
expect(successHandler).toHaveBeenCalledWith({
input: {
workItemsDisplaySettings: { shouldOpenItemsInSidePanel: false },
},
});
expect(wrapper.emitted('displaySettingsChanged')).toHaveLength(1);
expect(wrapper.emitted('displaySettingsChanged')[0][0]).toEqual({
shouldOpenItemsInSidePanel: false,
});
});
it('shows loading state while saving', async () => {
createComponent();
// Store the initial state
expect(findToggle().props('isLoading')).toBe(false);
findDropdownItem().vm.$emit('action');
await nextTick();
// Check loading state
expect(findToggle().props('isLoading')).toBe(true);
await waitForPromises();
// Check state after loading
expect(findToggle().props('isLoading')).toBe(false);
});
it('handles mutation errors gracefully', async () => {
const error = new Error('Network error');
const errorHandler = jest.fn().mockRejectedValue(error);
createComponent({ mutationHandler: errorHandler });
findDropdownItem().vm.$emit('action');
await waitForPromises();
expect(createAlert).toHaveBeenCalledWith({
message: 'Something went wrong while saving the preference.',
captureError: true,
error,
});
expect(wrapper.emitted('displaySettingsChanged')).toBeUndefined();
});
});
describe('dropdown visibility', () => {
beforeEach(() => {
createComponent();
});
it('shows tooltip when dropdown is closed', () => {
expect(wrapper.vm.tooltipText).toBe('Display options');
});
it('hides tooltip when dropdown is open', async () => {
findDropdown().vm.$emit('shown');
await nextTick();
expect(wrapper.vm.tooltipText).toBe('');
});
});
});
describe('when user is not signed in', () => {
it('does not render dropdown', () => {
createComponent({ provide: { isSignedIn: false } });
expect(findDropdown().exists()).toBe(false);
});
});
});

View File

@ -23,6 +23,7 @@ import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import { CREATED_DESC, UPDATED_DESC, urlSortParams } from '~/issues/list/constants';
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql';
import getUserWorkItemsDisplaySettingsPreferences from '~/work_items/graphql/get_user_preferences.query.graphql';
import { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, removeParams, updateHistory } from '~/lib/utils/url_utility';
import {
@ -45,6 +46,7 @@ import {
TOKEN_TYPE_UPDATED,
} from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue';
import WorkItemUserPreferences from '~/work_items/components/shared/work_item_list_preferences.vue';
import CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue';
import WorkItemsListApp from '~/work_items/pages/work_items_list_app.vue';
import getWorkItemStateCountsQuery from 'ee_else_ce/work_items/graphql/list/get_work_item_state_counts.query.graphql';
@ -94,6 +96,11 @@ describeSkipVue3(skipReason, () => {
.fn()
.mockResolvedValue(groupWorkItemStateCountsQueryResponse);
const mutationHandler = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
const mockPreferencesQueryHandler = jest.fn().mockResolvedValue({
data: {
currentUser: null,
},
});
const findIssuableList = () => wrapper.findComponent(IssuableList);
const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics);
@ -105,6 +112,7 @@ describeSkipVue3(skipReason, () => {
const findBulkEditStartButton = () => wrapper.find('[data-testid="bulk-edit-start-button"]');
const findBulkEditSidebar = () => wrapper.findComponent(WorkItemBulkEditSidebar);
const findWorkItemListHeading = () => wrapper.findComponent(WorkItemListHeading);
const findWorkItemUserPreferences = () => wrapper.findComponent(WorkItemUserPreferences);
const mountComponent = ({
provide = {},
@ -112,6 +120,7 @@ describeSkipVue3(skipReason, () => {
slimQueryHandler = defaultSlimQueryHandler,
countsQueryHandler = defaultCountsQueryHandler,
sortPreferenceMutationResponse = mutationHandler,
mockPreferencesHandler = mockPreferencesQueryHandler,
workItemsToggleEnabled = true,
workItemPlanningView = false,
props = {},
@ -131,6 +140,7 @@ describeSkipVue3(skipReason, () => {
[getWorkItemsSlimQuery, slimQueryHandler],
[getWorkItemStateCountsQuery, countsQueryHandler],
[setSortPreferenceMutation, sortPreferenceMutationResponse],
[getUserWorkItemsDisplaySettingsPreferences, mockPreferencesHandler],
...additionalHandlers,
]),
provide: {
@ -722,6 +732,47 @@ describeSkipVue3(skipReason, () => {
expect(findDrawer().exists()).toBe(true);
});
describe('display settings', () => {
it('updates displaySettings when displaySettingsChanged event is emitted', async () => {
mountComponent();
await waitForPromises();
const newSettings = { shouldOpenItemsInSidePanel: false };
findWorkItemUserPreferences().vm.$emit('displaySettingsChanged', newSettings);
await nextTick();
expect(findWorkItemUserPreferences().props('displaySettings')).toEqual(newSettings);
});
describe('workItemDrawerEnabled with display settings', () => {
it('returns false when shouldOpenItemsInSidePanel is false', async () => {
const mockHandler = jest.fn().mockResolvedValue({
data: {
currentUser: {
id: 'gid://gitlab/User/1',
userPreferences: {
workItemsDisplaySettings: { shouldOpenItemsInSidePanel: false },
},
},
},
});
mountComponent({
mockPreferencesHandler: mockHandler,
provide: {
glFeatures: { workItemViewForIssues: true },
isSignedIn: true,
},
});
await waitForPromises();
await nextTick();
expect(findIssuableList().props('preventRedirect')).toBe(false);
});
});
});
describe('selecting issues', () => {
const issue = workItemsQueryResponseCombined.data.namespace.workItems.nodes[0];
const payload = {

View File

@ -613,7 +613,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
:create_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
:create_cluster, :read_cluster, :update_cluster, :admin_cluster,
:create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment,
:download_code, :build_download_code
:download_code, :build_download_code, :read_code
]
end
@ -1530,6 +1530,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
let!(:deploy_keys_project) { create(:deploy_keys_project, project: project, deploy_key: deploy_key) }
it { is_expected.to be_allowed(:download_code) }
it { is_expected.to be_allowed(:read_code) }
it { is_expected.to be_disallowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) }
end
@ -1538,12 +1539,14 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
let!(:deploy_keys_project) { create(:deploy_keys_project, :write_access, project: project, deploy_key: deploy_key) }
it { is_expected.to be_allowed(:download_code) }
it { is_expected.to be_allowed(:read_code) }
it { is_expected.to be_allowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) }
end
context 'when the deploy key is not enabled in the project' do
it { is_expected.to be_disallowed(:download_code) }
it { is_expected.to be_disallowed(:read_code) }
it { is_expected.to be_disallowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) }
end
@ -3690,6 +3693,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
with_them do
it do
expect(subject.can?(:download_code)).to be(allowed)
expect(subject.can?(:read_code)).to be(allowed)
end
end
end
@ -3710,33 +3714,13 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
with_them do
it do
expect(subject.can?(:download_code)).to be(allowed)
expect(subject.can?(:read_code)).to be(allowed)
end
end
end
end
end
describe 'read_code' do
let(:current_user) { create(:user) }
before do
allow(subject).to receive(:allowed?).and_call_original
allow(subject).to receive(:allowed?).with(:download_code).and_return(can_download_code)
end
context 'when the current_user can download_code' do
let(:can_download_code) { true }
it { expect_allowed(:read_code) }
end
context 'when the current_user cannot download_code' do
let(:can_download_code) { false }
it { expect_disallowed(:read_code) }
end
end
describe 'read_namespace_catalog' do
let(:current_user) { owner }

View File

@ -49,6 +49,16 @@ RSpec.describe Integrations::HarborSerializers::ArtifactEntity, feature_category
})
end
context 'when artifact has no tags' do
before do
artifact['tags'] = nil
end
it 'returns an empty array for tags' do
expect(subject[:tags]).to eq([])
end
end
context 'with data that may contain path traversal attacks' do
before do
artifact['digest'] = './../../../../../etc/hosts'

View File

@ -4,11 +4,11 @@ require 'spec_helper'
RSpec.describe Commits::CherryPickService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) }
# * ddd0f15ae83993f5cb66a927a28673882e99100b (HEAD -> master, origin/master, origin/HEAD) Merge branch 'po-fix-test-en
# * ddd0f15 (HEAD -> master, origin/master, origin/HEAD) Merge branch 'po-fix-test-en
# |\
# | * 2d1db523e11e777e49377cfb22d368deec3f0793 Correct test_env.rb path for adding branch
# | * 2d1db52 Correct test_env.rb path for adding branch
# |/
# * 1e292f8fedd741b75372e19097c76d327140c312 Merge branch 'cherry-pick-ce369011' into 'master'
# * 1e292f8 Merge branch 'cherry-pick-ce369011' into 'master'
let_it_be(:merge_commit_sha) { 'ddd0f15ae83993f5cb66a927a28673882e99100b' }
let_it_be(:merge_base_sha) { '1e292f8fedd741b75372e19097c76d327140c312' }
@ -69,7 +69,16 @@ RSpec.describe Commits::CherryPickService, feature_category: :source_code_manage
it_behaves_like 'successful cherry-pick'
context 'when picking a merge-request' do
let!(:merge_request) { create(:merge_request, :simple, :merged, author: user, source_project: project, merge_commit_sha: merge_commit_sha) }
let!(:merge_request) do
create(
:merge_request,
:simple,
:merged,
author: user,
source_project: project,
merge_commit_sha: merge_commit_sha
)
end
it_behaves_like 'successful cherry-pick'

View File

@ -129,6 +129,22 @@ RSpec.shared_examples 'a harbor artifacts controller' do |args|
it_behaves_like 'responds with 200 status with json'
end
context 'with artifacts that have no tags' do
let(:mock_artifacts) { [super()[0].merge(tags: nil)] }
subject do
get harbor_artifact_url(container, repository_id), headers: json_header
end
it_behaves_like 'responds with 200 status with json'
it 'returns empty tags array' do
subject
expect(Gitlab::Json.parse(response.body).first['tags']).to be_empty
end
end
end
context 'with invalid params' do