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 { export default {
files: [ 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_comment_form.vue',
'app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue', 'app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_edit_note.vue',
'app/assets/javascripts/admin/statistics_panel/components/app.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/models/ee/merge_request.rb
/ee/app/services/merge_requests/ /ee/app/services/merge_requests/
/ee/app/services/ee/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/merge_requests/
/ee/app/workers/ee/merge_requests /ee/app/workers/ee/merge_requests
/ee/app/workers/merge_request_reset_approvals_worker.rb /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/issuable_export_csv_worker.rb'
- 'app/workers/issues/placement_worker.rb' - 'app/workers/issues/placement_worker.rb'
- 'app/workers/members_destroyer/unassign_issuables_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/merge_worker.rb'
- 'app/workers/namespaces/root_statistics_worker.rb' - 'app/workers/namespaces/root_statistics_worker.rb'
- 'app/workers/namespaces/schedule_aggregation_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/kubernetes/create_or_update_service_account_service_spec.rb'
- 'spec/services/clusters/management/validate_management_project_permissions_service_spec.rb' - 'spec/services/clusters/management/validate_management_project_permissions_service_spec.rb'
- 'spec/services/clusters/update_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/exclusive_lease_guard_spec.rb'
- 'spec/services/concerns/merge_requests/assigns_merge_params_spec.rb' - 'spec/services/concerns/merge_requests/assigns_merge_params_spec.rb'
- 'spec/services/concerns/rate_limited_service_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":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"},
{"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"}, {"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"},
{"name":"launchy","version":"2.5.2","platform":"ruby","checksum":"8aa0441655aec5514008e1d04892c2de3ba57bd337afb984568da091121a241b"}, {"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","version":"1.10.0","platform":"ruby","checksum":"2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2"},
{"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"}, {"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"},
{"name":"libyajl2","version":"2.1.0","platform":"ruby","checksum":"aa5df6c725776fc050c8418450de0f7c129cb7200b811907c4c0b3b5c0aea0ef"}, {"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":"open4","version":"1.3.4","platform":"ruby","checksum":"a1df037310624ecc1ea1d81264b11c83e96d0c3c1c6043108d37d396dcd0f4b1"},
{"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"}, {"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"},
{"name":"opensearch-ruby","version":"3.4.0","platform":"ruby","checksum":"0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d"}, {"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.3.0","platform":"java","checksum":"1755479b8f17a507f0d01020365b4ba96484c033ae88aef410f69d3240261657"},
{"name":"openssl","version":"3.2.0","platform":"ruby","checksum":"3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14"}, {"name":"openssl","version":"3.3.0","platform":"ruby","checksum":"ff3a573fc97ab30f69483fddc80029f91669bf36532859bd182d1836f45aee79"},
{"name":"openssl-signature_algorithm","version":"1.3.0","platform":"ruby","checksum":"a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80"}, {"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-api","version":"1.2.5","platform":"ruby","checksum":"ab3d9a0566cd2ee068ade40e840bc973383ab8568e693c0c5712f0c789122cc9"},
{"name":"opentelemetry-common","version":"0.21.0","platform":"ruby","checksum":"fe891a44583a20bc3217b324aec76d066504494951682d391cfd57d40cd01c98"}, {"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) language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
lefthook (1.11.13) lefthook (1.11.14)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
letter_opener_web (3.0.0) letter_opener_web (3.0.0)
@ -1324,7 +1324,7 @@ GEM
opensearch-ruby (3.4.0) opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3) faraday (>= 1.0, < 3)
multi_json (>= 1.0) multi_json (>= 1.0)
openssl (3.2.0) openssl (3.3.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.2.5) opentelemetry-api (1.2.5)

View File

@ -365,7 +365,7 @@
{"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"}, {"name":"kubeclient","version":"4.11.0","platform":"ruby","checksum":"4985fcd749fb8c364a668a8350a49821647f03aa52d9ee6cbc582beb8e883fcc"},
{"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"}, {"name":"language_server-protocol","version":"3.17.0.3","platform":"ruby","checksum":"3d5c58c02f44a20d972957a9febe386d7e7468ab3900ce6bd2b563dd910c6b3f"},
{"name":"launchy","version":"2.5.2","platform":"ruby","checksum":"8aa0441655aec5514008e1d04892c2de3ba57bd337afb984568da091121a241b"}, {"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","version":"1.10.0","platform":"ruby","checksum":"2ff33f2e3b5c3c26d1959be54b395c086ca6d44826e8bf41a14ff96fdf1bdbb2"},
{"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"}, {"name":"letter_opener_web","version":"3.0.0","platform":"ruby","checksum":"3f391efe0e8b9b24becfab5537dfb17a5cf5eb532038f947daab58cb4b749860"},
{"name":"libyajl2","version":"2.1.0","platform":"ruby","checksum":"aa5df6c725776fc050c8418450de0f7c129cb7200b811907c4c0b3b5c0aea0ef"}, {"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":"open4","version":"1.3.4","platform":"ruby","checksum":"a1df037310624ecc1ea1d81264b11c83e96d0c3c1c6043108d37d396dcd0f4b1"},
{"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"}, {"name":"openid_connect","version":"2.3.1","platform":"ruby","checksum":"5d808380cff80d78e3d3d54cfaebe2d6461d835c674faa29e2314a402c1b2182"},
{"name":"opensearch-ruby","version":"3.4.0","platform":"ruby","checksum":"0a8621686bed3c59b4c23e08cbaef873685a3fe4568e9d2703155ca92b8ca05d"}, {"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.3.0","platform":"java","checksum":"1755479b8f17a507f0d01020365b4ba96484c033ae88aef410f69d3240261657"},
{"name":"openssl","version":"3.2.0","platform":"ruby","checksum":"3c4bb8760977b4becd2819c6c2569bcf5c6f48b32b9f7a4ce1fd37f996378d14"}, {"name":"openssl","version":"3.3.0","platform":"ruby","checksum":"ff3a573fc97ab30f69483fddc80029f91669bf36532859bd182d1836f45aee79"},
{"name":"openssl-signature_algorithm","version":"1.3.0","platform":"ruby","checksum":"a3b40b5e8276162d4a6e50c7c97cdaf1446f9b2c3946a6fa2c14628e0c957e80"}, {"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-api","version":"1.2.5","platform":"ruby","checksum":"ab3d9a0566cd2ee068ade40e840bc973383ab8568e693c0c5712f0c789122cc9"},
{"name":"opentelemetry-common","version":"0.21.0","platform":"ruby","checksum":"fe891a44583a20bc3217b324aec76d066504494951682d391cfd57d40cd01c98"}, {"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) language_server-protocol (3.17.0.3)
launchy (2.5.2) launchy (2.5.2)
addressable (~> 2.8) addressable (~> 2.8)
lefthook (1.11.13) lefthook (1.11.14)
letter_opener (1.10.0) letter_opener (1.10.0)
launchy (>= 2.2, < 4) launchy (>= 2.2, < 4)
letter_opener_web (3.0.0) letter_opener_web (3.0.0)
@ -1318,7 +1318,7 @@ GEM
opensearch-ruby (3.4.0) opensearch-ruby (3.4.0)
faraday (>= 1.0, < 3) faraday (>= 1.0, < 3)
multi_json (>= 1.0) multi_json (>= 1.0)
openssl (3.2.0) openssl (3.3.0)
openssl-signature_algorithm (1.3.0) openssl-signature_algorithm (1.3.0)
openssl (> 2.0) openssl (> 2.0)
opentelemetry-api (1.2.5) opentelemetry-api (1.2.5)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -146,7 +146,7 @@ export const fetchSidebarCount = ({ commit, state }, skipBlobs) => {
return Promise.all(promises); 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 }); commit(types.SET_QUERY, { key, value });
if (SIDEBAR_PARAMS.includes(key)) { if (SIDEBAR_PARAMS.includes(key)) {
@ -157,13 +157,32 @@ export const setQuery = async ({ state, commit, getters }, { key, value }) => {
setDataToLS(LS_REGEX_HANDLE, value); setDataToLS(LS_REGEX_HANDLE, value);
} }
if (state.searchType === SEARCH_TYPE_ZOEKT && getters.currentScope === SCOPE_BLOB) { const isZoektSearch =
const newUrl = setUrlParams({ ...state.query }, window.location.href, false, true); 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); 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(); 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> <script>
import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { GlButton, GlTooltipDirective, GlAnimatedChevronLgDownUpIcon } from '@gitlab/ui';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants';
import StatusIcon from './mr_widget_status_icon.vue'; import StatusIcon from './mr_widget_status_icon.vue';
@ -8,6 +8,7 @@ import Actions from './action_buttons.vue';
export default { export default {
components: { components: {
GlButton, GlButton,
GlAnimatedChevronLgDownUpIcon,
StatusIcon, StatusIcon,
Actions, Actions,
}, },
@ -120,13 +121,14 @@ export default {
:title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" :title="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
:aria-label="collapsed ? expandDetailsTooltip : collapseDetailsTooltip" :aria-label="collapsed ? expandDetailsTooltip : collapseDetailsTooltip"
:aria-expanded="collapsed ? 'false' : 'true'" :aria-expanded="collapsed ? 'false' : 'true'"
:icon="collapsed ? 'chevron-lg-down' : 'chevron-lg-up'"
category="tertiary" category="tertiary"
size="small" size="small"
class="gl-align-top" class="btn-icon"
data-testid="widget-toggle" data-testid="widget-toggle"
@click="() => $emit('toggle')" @click="() => $emit('toggle')"
/> >
<gl-animated-chevron-lg-down-up-icon :is-on="!collapsed" />
</gl-button>
</div> </div>
</div> </div>
<button <button

View File

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

View File

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

View File

@ -355,7 +355,11 @@ export default {
@checked-input="handleAllIssuablesCheckedInput" @checked-input="handleAllIssuablesCheckedInput"
@onFilter="$emit('filter', $event)" @onFilter="$emit('filter', $event)"
@onSort="$emit('sort', $event)" @onSort="$emit('sort', $event)"
/> >
<template #user-preference>
<slot name="user-preference"></slot>
</template>
</filtered-search-bar>
<gl-alert <gl-alert
v-if="error" v-if="error"
variant="danger" 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 WorkItemHealthStatus from '../components/work_item_health_status.vue';
import WorkItemDrawer from '../components/work_item_drawer.vue'; import WorkItemDrawer from '../components/work_item_drawer.vue';
import WorkItemListHeading from '../components/work_item_list_heading.vue'; import WorkItemListHeading from '../components/work_item_list_heading.vue';
import WorkItemUserPreferences from '../components/shared/work_item_list_preferences.vue';
import { import {
BASE_ALLOWED_CREATE_TYPES, BASE_ALLOWED_CREATE_TYPES,
DETAIL_VIEW_QUERY_PARAM_NAME, DETAIL_VIEW_QUERY_PARAM_NAME,
@ -102,6 +103,7 @@ import {
WORK_ITEM_TYPE_NAME_KEY_RESULT, WORK_ITEM_TYPE_NAME_KEY_RESULT,
WORK_ITEM_TYPE_NAME_OBJECTIVE, WORK_ITEM_TYPE_NAME_OBJECTIVE,
} from '../constants'; } from '../constants';
import getUserWorkItemsDisplaySettingsPreferences from '../graphql/get_user_preferences.query.graphql';
const EmojiToken = () => const EmojiToken = () =>
import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'); import('~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue');
@ -135,6 +137,7 @@ export default {
CreateWorkItemModal, CreateWorkItemModal,
LocalBoard, LocalBoard,
WorkItemListHeading, WorkItemListHeading,
WorkItemUserPreferences,
}, },
mixins: [glFeatureFlagMixin()], mixins: [glFeatureFlagMixin()],
inject: [ inject: [
@ -204,9 +207,27 @@ export default {
initialLoadWasFiltered: false, initialLoadWasFiltered: false,
showLocalBoard: false, showLocalBoard: false,
namespaceId: null, namespaceId: null,
displaySettings: {},
}; };
}, },
apollo: { 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: { workItemsFull: {
query() { query() {
return getWorkItemsQuery; return getWorkItemsQuery;
@ -335,6 +356,8 @@ export default {
}); });
}, },
workItemDrawerEnabled() { workItemDrawerEnabled() {
const shouldOpenItemsInSidePanel = this.displaySettings?.shouldOpenItemsInSidePanel ?? true;
if (!shouldOpenItemsInSidePanel) return false;
if (this.glFeatures.workItemViewForIssues) { if (this.glFeatures.workItemViewForIssues) {
return true; return true;
} }
@ -950,6 +973,9 @@ export default {
this.isInitialLoadComplete = false; this.isInitialLoadComplete = false;
this.refetchItems({ refetchCounts: true }); this.refetchItems({ refetchCounts: true });
}, },
handleDisplaySettingsChanged(newDisplaySettings) {
this.displaySettings = newDisplaySettings;
},
}, },
}; };
</script> </script>
@ -1009,7 +1035,14 @@ export default {
@previous-page="handlePreviousPage" @previous-page="handlePreviousPage"
@sort="handleSort" @sort="handleSort"
@select-issuable="handleToggle" @select-issuable="handleToggle"
@displaySettingsChanged="handleDisplaySettingsChanged"
> >
<template #user-preference>
<work-item-user-preferences
:display-settings="displaySettings"
@displaySettingsChanged="handleDisplaySettingsChanged"
/>
</template>
<template v-if="!isPlanningViewsEnabled" #nav-actions> <template v-if="!isPlanningViewsEnabled" #nav-actions>
<div class="gl-flex gl-gap-3"> <div class="gl-flex gl-gap-3">
<gl-button <gl-button

View File

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

View File

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

View File

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

View File

@ -29,6 +29,8 @@
- 1 - 1
- - admin_emails - - admin_emails
- 1 - 1
- - ai_active_context_code_repository_index
- 1
- - ai_active_context_code_saas_initial_indexing_event - - ai_active_context_code_saas_initial_indexing_event
- 1 - 1
- - ai_knowledge_graph_indexing_task - - 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, name text NOT NULL,
created_by_id bigint, created_by_id bigint,
updated_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 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, converted_from_system_defined_status_identifier smallint,
CONSTRAINT check_4789467800 CHECK ((char_length(color) <= 7)), CONSTRAINT check_4789467800 CHECK ((char_length(color) <= 7)),
CONSTRAINT check_720a7c4d24 CHECK ((char_length(name) <= 32)), 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)) 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 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 `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. 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" msgid "An error occurred while getting merge request counts"
msgstr "" msgstr ""
msgid "An error occurred while getting work item user preference."
msgstr ""
msgid "An error occurred while initializing path locks" msgid "An error occurred while initializing path locks"
msgstr "" msgstr ""
@ -59713,6 +59716,9 @@ msgstr ""
msgid "Something went wrong while resolving this discussion. Please try again." msgid "Something went wrong while resolving this discussion. Please try again."
msgstr "" msgstr ""
msgid "Something went wrong while saving the preference."
msgstr ""
msgid "Something went wrong while setting %{issuableType} %{dateType} date." msgid "Something went wrong while setting %{issuableType} %{dateType} date."
msgstr "" msgstr ""
@ -70208,6 +70214,15 @@ msgstr ""
msgid "WorkItems|Ancestors not available" msgid "WorkItems|Ancestors not available"
msgstr "" msgstr ""
msgid "WorkItems|Display options"
msgstr ""
msgid "WorkItems|Open items in side panel"
msgstr ""
msgid "WorkItems|Your preferences"
msgstr ""
msgid "WorkItem| %{workItemType}s closed" msgid "WorkItem| %{workItemType}s closed"
msgstr "" msgstr ""

View File

@ -326,5 +326,6 @@
"engines": { "engines": {
"node": ">=12.22.1", "node": ">=12.22.1",
"yarn": "^1.10.0" "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 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'; import { createDraft } from '../mock_data';
jest.mock('~/behaviors/markdown/render_gfm'); jest.mock('~/behaviors/markdown/render_gfm');
jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores')); jest.mock('~/mr_notes/stores', () => jest.requireActual('helpers/mocks/mr_notes/stores'));
Vue.use(PiniaVuePlugin);
describe('Batch comments draft preview item component', () => { describe('Batch comments draft preview item component', () => {
let wrapper; let wrapper;
let pinia;
let draft; let draft;
beforeEach(() => { beforeEach(() => {
store.reset(); pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
store.getters.getDiscussion = jest.fn(() => null); useNotes();
}); });
function createComponent(extra = {}, improvedReviewExperience = false) { function createComponent(extra = {}, improvedReviewExperience = false) {
@ -23,9 +31,7 @@ describe('Batch comments draft preview item component', () => {
}; };
wrapper = mountExtended(PreviewItem, { wrapper = mountExtended(PreviewItem, {
mocks: { pinia,
$store: store,
},
propsData: { draft }, propsData: { draft },
provide: { provide: {
glFeatures: { improvedReviewExperience }, glFeatures: { improvedReviewExperience },
@ -93,7 +99,8 @@ describe('Batch comments draft preview item component', () => {
describe('for thread', () => { describe('for thread', () => {
beforeEach(() => { beforeEach(() => {
store.getters.getDiscussion.mockReturnValue({ useNotes().discussions = [
{
id: '1', id: '1',
notes: [ notes: [
{ {
@ -102,8 +109,8 @@ describe('Batch comments draft preview item component', () => {
}, },
}, },
], ],
}); },
store.getters.isDiscussionResolved = jest.fn().mockReturnValue(false); ];
createComponent({ discussion_id: '1', resolve_discussion: true }); createComponent({ discussion_id: '1', resolve_discussion: true });
}); });

View File

@ -91,6 +91,7 @@ describe('Incident Tabs component', () => {
const findSummaryTab = () => wrapper.findByTestId('summary-tab'); const findSummaryTab = () => wrapper.findByTestId('summary-tab');
const findTimelineTab = () => wrapper.findByTestId('timeline-tab'); const findTimelineTab = () => wrapper.findByTestId('timeline-tab');
const findAlertDetailsTab = () => wrapper.findByTestId('alert-details-tab'); const findAlertDetailsTab = () => wrapper.findByTestId('alert-details-tab');
const findMetricsTab = () => wrapper.findByTestId('metrics-tab');
const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable); const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable);
const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent);
const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar); const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar);
@ -227,5 +228,14 @@ describe('Incident Tabs component', () => {
it('does not render the timeline tab', () => { it('does not render the timeline tab', () => {
expect(findTimelineTab().exists()).toBe(false); 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 { GlAvatar, GlAvatarLink } from '@gitlab/ui';
import { createTestingPinia } from '@pinia/testing';
import { PiniaVuePlugin } from 'pinia';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue'; import diffDiscussionHeader from '~/notes/components/diff_discussion_header.vue';
import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.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 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'; import { discussionMock } from '../mock_data';
Vue.use(PiniaVuePlugin);
describe('diff_discussion_header component', () => { describe('diff_discussion_header component', () => {
let store; let pinia;
let wrapper; let wrapper;
const createComponent = ({ propsData = {} } = {}) => { const createComponent = ({ propsData = {} } = {}) => {
wrapper = shallowMountExtended(diffDiscussionHeader, { wrapper = shallowMountExtended(diffDiscussionHeader, {
store, pinia,
propsData: { propsData: {
discussion: discussionMock, discussion: discussionMock,
...propsData, ...propsData,
@ -24,8 +29,9 @@ describe('diff_discussion_header component', () => {
beforeEach(() => { beforeEach(() => {
window.mrTabs = {}; window.mrTabs = {};
store = createStore(); pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
createComponent({ propsData: { discussion: discussionMock } }); createComponent({ propsData: { discussion: discussionMock } });
}); });
@ -60,10 +66,6 @@ describe('diff_discussion_header component', () => {
let commitElement; let commitElement;
beforeEach(async () => { beforeEach(async () => {
store.state.diffs = {
projectPath: 'something',
};
createComponent({ createComponent({
propsData: { propsData: {
discussion: { discussion: {
@ -202,5 +204,18 @@ describe('diff_discussion_header component', () => {
}); });
expect(wrapper.findComponent(ToggleRepliesWidget).props('collapsed')).toBe(true); 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 { createTestingPinia } from '@pinia/testing';
import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json'; import discussionFixture from 'test_fixtures/merge_requests/diff_discussion.json';
import imageDiscussionFixture from 'test_fixtures/merge_requests/image_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 DiffWithNote from '~/notes/components/diff_with_note.vue';
import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue';
import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue';
import { globalAccessorPlugin } from '~/pinia/plugins'; import { globalAccessorPlugin } from '~/pinia/plugins';
import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs'; import { useLegacyDiffs } from '~/diffs/stores/legacy_diffs';
import { useNotes } from '~/notes/store/legacy_notes';
Vue.use(PiniaVuePlugin); Vue.use(PiniaVuePlugin);
describe('diff_with_note', () => { describe('diff_with_note', () => {
let store;
let pinia; let pinia;
let wrapper; let wrapper;
@ -37,7 +36,6 @@ describe('diff_with_note', () => {
const createComponent = (propsData) => { const createComponent = (propsData) => {
wrapper = shallowMount(DiffWithNote, { wrapper = shallowMount(DiffWithNote, {
propsData, propsData,
store,
pinia, pinia,
}); });
}; };
@ -45,15 +43,7 @@ describe('diff_with_note', () => {
beforeEach(() => { beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] }); pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs(); useLegacyDiffs();
store = createStore(); useNotes().noteableData = { current_user: {} };
store.replaceState({
...store.state,
notes: {
noteableData: {
current_user: {},
},
},
});
}); });
describe('text diff', () => { describe('text diff', () => {
@ -199,7 +189,7 @@ describe('diff_with_note', () => {
beforeEach(() => { beforeEach(() => {
wrapper = shallowMount(DiffWithNote, { wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} }, propsData: { discussion: fileDiscussion, diffFile: {} },
store, pinia,
}); });
}); });
@ -218,7 +208,7 @@ describe('diff_with_note', () => {
wrapper = shallowMount(DiffWithNote, { wrapper = shallowMount(DiffWithNote, {
propsData: { discussion: fileDiscussion, diffFile: {} }, propsData: { discussion: fileDiscussion, diffFile: {} },
store, pinia,
}); });
}); });

View File

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

View File

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

View File

@ -1,14 +1,19 @@
import { GlFormSelect } from '@gitlab/ui'; import { GlFormSelect, GlSprintf } from '@gitlab/ui';
import { mount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import Vue from 'vue'; import Vue from 'vue';
// eslint-disable-next-line no-restricted-imports import { PiniaVuePlugin } from 'pinia';
import Vuex from 'vuex'; import { createTestingPinia } from '@pinia/testing';
import MultilineCommentForm from '~/notes/components/multiline_comment_form.vue'; 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', () => { describe('MultilineCommentForm', () => {
Vue.use(Vuex); let wrapper;
const setSelectedCommentPosition = jest.fn(); let pinia;
const testLine = { const testLine = {
line_code: 'test', line_code: 'test',
type: 'test', type: 'test',
@ -16,63 +21,64 @@ describe('MultilineCommentForm', () => {
new_line: 'test', new_line: 'test',
}; };
const createWrapper = (props = {}, state) => { const createWrapper = (props = {}) => {
setSelectedCommentPosition.mockReset();
const store = new Vuex.Store({
modules: { notes: notesModule() },
actions: { setSelectedCommentPosition },
});
if (state) store.replaceState({ ...store.state, ...state });
const propsData = { const propsData = {
line: { ...testLine }, line: { ...testLine },
commentLineOptions: [{ text: '1' }], commentLineOptions: [{ text: '1' }],
...props, ...props,
}; };
return mount(MultilineCommentForm, { propsData, store }); wrapper = shallowMount(MultilineCommentForm, { propsData, pinia, stubs: { GlSprintf } });
}; };
beforeEach(() => {
pinia = createTestingPinia({ plugins: [globalAccessorPlugin] });
useLegacyDiffs();
useNotes();
});
describe('created', () => { describe('created', () => {
it('sets commentLineStart to line', () => { it('sets commentLineStart to line', () => {
const line = { ...testLine }; const line = { ...testLine };
const wrapper = createWrapper({ line }); createWrapper({ line });
expect(wrapper.vm.commentLineStart).toEqual(line); // we can't check for .attributes() because of GlFormSelect design
expect(setSelectedCommentPosition).toHaveBeenCalled(); // 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', () => { it('sets commentLineStart to lineRange', () => {
const lineRange = { const lineRange = {
start: { ...testLine }, start: { ...testLine },
}; };
const wrapper = createWrapper({ lineRange }); createWrapper({ lineRange });
expect(wrapper.vm.commentLineStart).toEqual(lineRange.start); expect(wrapper.findComponent(GlFormSelect).vm.$attrs.value).toEqual(lineRange.start);
expect(setSelectedCommentPosition).toHaveBeenCalled(); expect(useNotes().setSelectedCommentPosition).toHaveBeenCalled();
}); });
}); });
describe('destroyed', () => { describe('destroyed', () => {
it('calls setSelectedCommentPosition', () => { it('calls setSelectedCommentPosition', () => {
const wrapper = createWrapper(); createWrapper();
wrapper.destroy(); wrapper.destroy();
// Once during created, once during destroyed // Once during created, once during destroyed
expect(setSelectedCommentPosition).toHaveBeenCalledTimes(2); expect(useNotes().setSelectedCommentPosition).toHaveBeenCalledTimes(2);
}); });
}); });
it('handles changing the start line', () => { it('handles changing the start line', () => {
const line = { ...testLine }; const line = { ...testLine };
const wrapper = createWrapper({ line }); createWrapper({ line });
const glSelect = wrapper.findComponent(GlFormSelect); const glSelect = wrapper.findComponent(GlFormSelect);
glSelect.vm.$emit('change', { ...testLine }); 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); expect(wrapper.emitted('input')).toHaveLength(1);
// Once during created, once during updateCommentLineStart // 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'; } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter'; 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 { stubComponent } from 'helpers/stub_component';
import { TEST_HOST } from 'spec/test_constants'; import { TEST_HOST } from 'spec/test_constants';
import axios from '~/lib/utils/axios_utils'; 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 { NOTEABLE_TYPE_MAPPING } from '~/notes/constants';
import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue'; import TimelineEventButton from '~/notes/components/note_actions/timeline_event_button.vue';
import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.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 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'; import { userDataMock } from '../mock_data';
Vue.use(PiniaVuePlugin);
describe('noteActions', () => { describe('noteActions', () => {
let wrapper; let wrapper;
let store; let pinia;
let props; let props;
let actions; let actions;
let axiosMock; let axiosMock;
@ -37,20 +43,20 @@ describe('noteActions', () => {
noteableType, noteableType,
isPromotionInProgress = true, isPromotionInProgress = true,
}) => { }) => {
store.dispatch('setUserData', { useNotes().setUserData({
...userDataMock, ...userDataMock,
can_add_timeline_events: userCanAdd, can_add_timeline_events: userCanAdd,
}); });
store.state.noteableData = { useNotes().noteableData = {
...store.state.noteableData, ...useNotes().noteableData,
type: noteableType, type: noteableType,
}; };
store.state.isPromoteCommentToTimelineEventInProgress = isPromotionInProgress; useNotes().isPromoteCommentToTimelineEventInProgress = isPromotionInProgress;
}; };
const mountNoteActions = (propsData) => { const mountNoteActions = (propsData) => {
return shallowMount(noteActions, { return shallowMount(noteActions, {
store, pinia,
propsData, propsData,
stubs: { stubs: {
GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, { GlDisclosureDropdown: stubComponent(GlDisclosureDropdown, {
@ -65,7 +71,10 @@ describe('noteActions', () => {
}; };
beforeEach(() => { beforeEach(() => {
store = createStore(); pinia = createTestingPinia({ plugins: [globalAccessorPlugin], stubActions: false });
useLegacyDiffs();
useNotes().toggleAwardRequest.mockResolvedValue();
useNotes().promoteCommentToTimelineEvent.mockResolvedValue();
props = { props = {
accessLevel: 'Maintainer', accessLevel: 'Maintainer',
@ -98,7 +107,7 @@ describe('noteActions', () => {
describe('user is logged in', () => { describe('user is logged in', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('setUserData', userDataMock); useNotes().setUserData(userDataMock);
wrapper = mountNoteActions(props); wrapper = mountNoteActions(props);
}); });
@ -222,13 +231,13 @@ describe('noteActions', () => {
beforeEach(() => { beforeEach(() => {
wrapper = mountNoteActions(props); wrapper = mountNoteActions(props);
store.state.noteableData = { useNotes().noteableData = {
current_user: { current_user: {
can_set_issue_metadata: true, can_set_issue_metadata: true,
}, },
}; };
store.state.userData = userDataMock; useNotes().userData = userDataMock;
store.state.noteableData.targetType = 'issue'; useNotes().noteableData.targetType = 'issue';
}); });
afterEach(() => { afterEach(() => {
@ -244,13 +253,13 @@ describe('noteActions', () => {
wrapper = mountNoteActions(props, { wrapper = mountNoteActions(props, {
targetType: () => 'issue', targetType: () => 'issue',
}); });
store.state.noteableData = { useNotes().noteableData = {
current_user: { current_user: {
can_update: true, can_update: true,
can_set_issue_metadata: false, can_set_issue_metadata: false,
}, },
}; };
store.state.userData = userDataMock; useNotes().userData = userDataMock;
}); });
afterEach(() => { afterEach(() => {
@ -282,7 +291,7 @@ describe('noteActions', () => {
describe('user is not logged in', () => { describe('user is not logged in', () => {
beforeEach(() => { beforeEach(() => {
// userData can be null https://gitlab.com/gitlab-org/gitlab/-/issues/379375 // userData can be null https://gitlab.com/gitlab-org/gitlab/-/issues/379375
store.dispatch('setUserData', null); useNotes().setUserData(null);
wrapper = mountNoteActions({ wrapper = mountNoteActions({
...props, ...props,
canDelete: false, canDelete: false,
@ -333,7 +342,7 @@ describe('noteActions', () => {
describe('Draft notes', () => { describe('Draft notes', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('setUserData', userDataMock); useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canResolve: true, isDraft: true }); 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', () => { it('when timeline-event-button emits click-promote-comment-to-event, dispatches action', () => {
jest.spyOn(store, 'dispatch').mockImplementation(); expect(useNotes().promoteCommentToTimelineEvent).not.toHaveBeenCalled();
expect(store.dispatch).not.toHaveBeenCalled();
findTimelineButton().vm.$emit('click-promote-comment-to-event'); findTimelineButton().vm.$emit('click-promote-comment-to-event');
expect(store.dispatch).toHaveBeenCalledTimes(1); expect(useNotes().promoteCommentToTimelineEvent).toHaveBeenCalledTimes(1);
expect(store.dispatch).toHaveBeenCalledWith('promoteCommentToTimelineEvent');
}); });
}); });
}); });
@ -403,7 +409,7 @@ describe('noteActions', () => {
describe('when user is not allowed to report abuse', () => { describe('when user is not allowed to report abuse', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('setUserData', userDataMock); useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canReportAsAbuse: false }); wrapper = mountNoteActions({ ...props, canReportAsAbuse: false });
}); });
@ -418,7 +424,7 @@ describe('noteActions', () => {
describe('when user is allowed to report abuse', () => { describe('when user is allowed to report abuse', () => {
beforeEach(() => { beforeEach(() => {
store.dispatch('setUserData', userDataMock); useNotes().setUserData(userDataMock);
wrapper = mountNoteActions({ ...props, canReportAsAbuse: true }); wrapper = mountNoteActions({ ...props, canReportAsAbuse: true });
}); });

View File

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

View File

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

View File

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

View File

@ -1,7 +1,7 @@
import { nextTick } from 'vue';
import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue'; import TagsHeader from '~/packages_and_registries/harbor_registry/components/tags/tags_header.vue';
import TitleArea from '~/vue_shared/components/registry/title_area.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'; import { mockArtifactDetail, MOCK_SHA_DIGEST } from '../../mock_data';
describe('Harbor Tags Header', () => { describe('Harbor Tags Header', () => {
@ -28,7 +28,11 @@ describe('Harbor Tags Header', () => {
beforeEach(() => { beforeEach(() => {
mountComponent({ 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', () => { describe('tags count', () => {
it('would has the correct text', async () => { it('displays the tags count', () => {
await nextTick();
expect(findTagsCount().props('text')).toBe('1 tag'); 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 setWindowLocation from 'helpers/set_window_location_helper';
import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { HTTP_STATUS_INTERNAL_SERVER_ERROR, HTTP_STATUS_OK } from '~/lib/utils/http_status';
import * as urlUtils from '~/lib/utils/url_utility'; import * as urlUtils from '~/lib/utils/url_utility';
import * as actions from '~/search/store/actions';
import { import {
GROUPS_LOCAL_STORAGE_KEY, GROUPS_LOCAL_STORAGE_KEY,
PROJECTS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY,
@ -22,6 +22,7 @@ import {
import * as types from '~/search/store/mutation_types'; import * as types from '~/search/store/mutation_types';
import createState from '~/search/store/state'; import createState from '~/search/store/state';
import * as storeUtils from '~/search/store/utils'; import * as storeUtils from '~/search/store/utils';
import * as actions from '~/search/store/actions';
import { import {
MOCK_QUERY, MOCK_QUERY,
MOCK_GROUPS, MOCK_GROUPS,
@ -203,26 +204,12 @@ describe('Global Search Store Actions', () => {
fetchSidebarCountSpy.mockRestore(); fetchSidebarCountSpy.mockRestore();
}); });
it('should update URL, document title, and history', async () => { it('should update URL, document title, and history', () => {
const getters = { currentScope: 'blobs' }; const getters = { currentScope: 'blobs' };
await actions.setQuery({ state, commit, getters }, payload); return testAction(actions.setQuery, payload, { ...state, ...getters }, [
{ type: types.SET_QUERY, payload: { key: 'some-key', value: 'some-value' } },
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,
});
}); });
it('does not update URL or fetch sidebar counts when conditions are not met', async () => { 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', () => { describe('applyQuery', () => {
@ -410,7 +547,7 @@ describe('Global Search Store Actions', () => {
it(`should ${expectedMutations.length === 0 ? 'NOT' : ''} dispatch ${ it(`should ${expectedMutations.length === 0 ? 'NOT' : ''} dispatch ${
expectedMutations.length === 0 ? '' : 'the correct' expectedMutations.length === 0 ? '' : 'the correct'
} mutations for ${scope}`, () => { } mutations for ${scope}`, () => {
return testAction({ action, state, expectedMutations }).then(() => { return testAction(action, undefined, state, expectedMutations, []).then(() => {
expect(logger.logError).toHaveBeenCalledTimes(errorLogs); expect(logger.logError).toHaveBeenCalledTimes(errorLogs);
}); });
}); });

View File

@ -1,3 +1,4 @@
import { GlAnimatedChevronLgDownUpIcon } from '@gitlab/ui';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import * as Sentry from '~/sentry/sentry_browser_wrapper'; import * as Sentry from '~/sentry/sentry_browser_wrapper';
import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; 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 ReportListItem from '~/merge_requests/reports/components/report_list_item.vue';
import * as logger from '~/lib/logger'; import * as logger from '~/lib/logger';
import axios from '~/lib/utils/axios_utils'; import axios from '~/lib/utils/axios_utils';
import { parseBoolean } from '~/lib/utils/common_utils';
import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status';
jest.mock('~/vue_merge_request_widget/components/widget/telemetry', () => ({ 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 findExpandedSection = () => wrapper.findByTestId('widget-extension-collapsed-section');
const findActionButtons = () => wrapper.findComponent(ActionButtons); const findActionButtons = () => wrapper.findComponent(ActionButtons);
const findToggleButton = () => wrapper.findByTestId('toggle-button'); const findToggleButton = () => wrapper.findByTestId('toggle-button');
const findToggleChevron = () => findToggleButton().findComponent(GlAnimatedChevronLgDownUpIcon);
const findHelpPopover = () => wrapper.findComponent(HelpPopover); const findHelpPopover = () => wrapper.findComponent(HelpPopover);
const findDynamicScroller = () => wrapper.findByTestId('dynamic-content-scroller'); 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'); 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 () => { it('does not display the content slot until toggle is clicked', async () => {
await createComponent({ await createComponent({
propsData: { 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 { STATUS_CLOSED, STATUS_OPEN } from '~/issues/constants';
import { CREATED_DESC, UPDATED_DESC, urlSortParams } from '~/issues/list/constants'; import { CREATED_DESC, UPDATED_DESC, urlSortParams } from '~/issues/list/constants';
import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; 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 { scrollUp } from '~/lib/utils/scroll_utils';
import { getParameterByName, removeParams, updateHistory } from '~/lib/utils/url_utility'; import { getParameterByName, removeParams, updateHistory } from '~/lib/utils/url_utility';
import { import {
@ -45,6 +46,7 @@ import {
TOKEN_TYPE_UPDATED, TOKEN_TYPE_UPDATED,
} from '~/vue_shared/components/filtered_search_bar/constants'; } from '~/vue_shared/components/filtered_search_bar/constants';
import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; 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 CreateWorkItemModal from '~/work_items/components/create_work_item_modal.vue';
import WorkItemsListApp from '~/work_items/pages/work_items_list_app.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'; import getWorkItemStateCountsQuery from 'ee_else_ce/work_items/graphql/list/get_work_item_state_counts.query.graphql';
@ -94,6 +96,11 @@ describeSkipVue3(skipReason, () => {
.fn() .fn()
.mockResolvedValue(groupWorkItemStateCountsQueryResponse); .mockResolvedValue(groupWorkItemStateCountsQueryResponse);
const mutationHandler = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); const mutationHandler = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse);
const mockPreferencesQueryHandler = jest.fn().mockResolvedValue({
data: {
currentUser: null,
},
});
const findIssuableList = () => wrapper.findComponent(IssuableList); const findIssuableList = () => wrapper.findComponent(IssuableList);
const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics); const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics);
@ -105,6 +112,7 @@ describeSkipVue3(skipReason, () => {
const findBulkEditStartButton = () => wrapper.find('[data-testid="bulk-edit-start-button"]'); const findBulkEditStartButton = () => wrapper.find('[data-testid="bulk-edit-start-button"]');
const findBulkEditSidebar = () => wrapper.findComponent(WorkItemBulkEditSidebar); const findBulkEditSidebar = () => wrapper.findComponent(WorkItemBulkEditSidebar);
const findWorkItemListHeading = () => wrapper.findComponent(WorkItemListHeading); const findWorkItemListHeading = () => wrapper.findComponent(WorkItemListHeading);
const findWorkItemUserPreferences = () => wrapper.findComponent(WorkItemUserPreferences);
const mountComponent = ({ const mountComponent = ({
provide = {}, provide = {},
@ -112,6 +120,7 @@ describeSkipVue3(skipReason, () => {
slimQueryHandler = defaultSlimQueryHandler, slimQueryHandler = defaultSlimQueryHandler,
countsQueryHandler = defaultCountsQueryHandler, countsQueryHandler = defaultCountsQueryHandler,
sortPreferenceMutationResponse = mutationHandler, sortPreferenceMutationResponse = mutationHandler,
mockPreferencesHandler = mockPreferencesQueryHandler,
workItemsToggleEnabled = true, workItemsToggleEnabled = true,
workItemPlanningView = false, workItemPlanningView = false,
props = {}, props = {},
@ -131,6 +140,7 @@ describeSkipVue3(skipReason, () => {
[getWorkItemsSlimQuery, slimQueryHandler], [getWorkItemsSlimQuery, slimQueryHandler],
[getWorkItemStateCountsQuery, countsQueryHandler], [getWorkItemStateCountsQuery, countsQueryHandler],
[setSortPreferenceMutation, sortPreferenceMutationResponse], [setSortPreferenceMutation, sortPreferenceMutationResponse],
[getUserWorkItemsDisplaySettingsPreferences, mockPreferencesHandler],
...additionalHandlers, ...additionalHandlers,
]), ]),
provide: { provide: {
@ -722,6 +732,47 @@ describeSkipVue3(skipReason, () => {
expect(findDrawer().exists()).toBe(true); 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', () => { describe('selecting issues', () => {
const issue = workItemsQueryResponseCombined.data.namespace.workItems.nodes[0]; const issue = workItemsQueryResponseCombined.data.namespace.workItems.nodes[0];
const payload = { 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_environment, :read_environment, :update_environment, :admin_environment, :destroy_environment,
:create_cluster, :read_cluster, :update_cluster, :admin_cluster, :create_cluster, :read_cluster, :update_cluster, :admin_cluster,
:create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment, :create_deployment, :read_deployment, :update_deployment, :admin_deployment, :destroy_deployment,
:download_code, :build_download_code :download_code, :build_download_code, :read_code
] ]
end 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) } 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(:download_code) }
it { is_expected.to be_allowed(:read_code) }
it { is_expected.to be_disallowed(:push_code) } it { is_expected.to be_disallowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) } it { is_expected.to be_disallowed(:read_project) }
end 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) } 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(:download_code) }
it { is_expected.to be_allowed(:read_code) }
it { is_expected.to be_allowed(:push_code) } it { is_expected.to be_allowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) } it { is_expected.to be_disallowed(:read_project) }
end end
context 'when the deploy key is not enabled in the project' do 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(:download_code) }
it { is_expected.to be_disallowed(:read_code) }
it { is_expected.to be_disallowed(:push_code) } it { is_expected.to be_disallowed(:push_code) }
it { is_expected.to be_disallowed(:read_project) } it { is_expected.to be_disallowed(:read_project) }
end end
@ -3690,6 +3693,7 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
with_them do with_them do
it do it do
expect(subject.can?(:download_code)).to be(allowed) expect(subject.can?(:download_code)).to be(allowed)
expect(subject.can?(:read_code)).to be(allowed)
end end
end end
end end
@ -3710,33 +3714,13 @@ RSpec.describe ProjectPolicy, feature_category: :system_access do
with_them do with_them do
it do it do
expect(subject.can?(:download_code)).to be(allowed) expect(subject.can?(:download_code)).to be(allowed)
expect(subject.can?(:read_code)).to be(allowed)
end end
end end
end 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 describe 'read_namespace_catalog' do
let(:current_user) { owner } let(:current_user) { owner }

View File

@ -49,6 +49,16 @@ RSpec.describe Integrations::HarborSerializers::ArtifactEntity, feature_category
}) })
end 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 context 'with data that may contain path traversal attacks' do
before do before do
artifact['digest'] = './../../../../../etc/hosts' artifact['digest'] = './../../../../../etc/hosts'

View File

@ -4,11 +4,11 @@ require 'spec_helper'
RSpec.describe Commits::CherryPickService, feature_category: :source_code_management do RSpec.describe Commits::CherryPickService, feature_category: :source_code_management do
let(:project) { create(:project, :repository) } 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_commit_sha) { 'ddd0f15ae83993f5cb66a927a28673882e99100b' }
let_it_be(:merge_base_sha) { '1e292f8fedd741b75372e19097c76d327140c312' } 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' it_behaves_like 'successful cherry-pick'
context 'when picking a merge-request' do 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' 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' it_behaves_like 'responds with 200 status with json'
end 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 end
context 'with invalid params' do context 'with invalid params' do