diff options
Diffstat (limited to 'app/assets/javascripts')
615 files changed, 7846 insertions, 4532 deletions
diff --git a/app/assets/javascripts/add_context_commits_modal/store/actions.js b/app/assets/javascripts/add_context_commits_modal/store/actions.js index f085b0d0e5e..890db374160 100644 --- a/app/assets/javascripts/add_context_commits_modal/store/actions.js +++ b/app/assets/javascripts/add_context_commits_modal/store/actions.js @@ -1,5 +1,5 @@ import _ from 'lodash'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api from '~/api'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue index 3c46de7c2be..f0540ffa71e 100644 --- a/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_app.vue @@ -7,6 +7,7 @@ import ReportDetails from './report_details.vue'; import ReportedContent from './reported_content.vue'; import ActivityEventsList from './activity_events_list.vue'; import ActivityHistoryItem from './activity_history_item.vue'; +import AbuseReportNotes from './abuse_report_notes.vue'; const alertDefaults = { visible: false, @@ -24,6 +25,7 @@ export default { ReportedContent, ActivityEventsList, ActivityHistoryItem, + AbuseReportNotes, }, mixins: [glFeatureFlagsMixin()], props: { @@ -96,5 +98,10 @@ export default { /> </template> </activity-events-list> + + <abuse-report-notes + v-if="glFeatures.abuseReportNotes" + :abuse-report-id="abuseReport.report.globalId" + /> </section> </template> diff --git a/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue b/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue new file mode 100644 index 00000000000..80af7d7400a --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/abuse_report_notes.vue @@ -0,0 +1,92 @@ +<script> +import { uniqueId } from 'lodash'; +import { __ } from '~/locale'; +import { createAlert } from '~/alert'; +import SkeletonLoadingContainer from '~/vue_shared/components/notes/skeleton_note.vue'; +import { SKELETON_NOTES_COUNT } from '~/admin/abuse_report/constants'; +import abuseReportNotesQuery from '../graphql/notes/abuse_report_notes.query.graphql'; +import AbuseReportDiscussion from './notes/abuse_report_discussion.vue'; + +export default { + name: 'AbuseReportNotes', + SKELETON_NOTES_COUNT, + i18n: { + fetchError: __('An error occurred while fetching comments, please try again.'), + }, + components: { + SkeletonLoadingContainer, + AbuseReportDiscussion, + }, + props: { + abuseReportId: { + type: String, + required: true, + }, + }, + data() { + return { + addNoteKey: uniqueId(`abuse-report-add-note-${this.abuseReportId}`), + }; + }, + apollo: { + abuseReportNotes: { + query: abuseReportNotesQuery, + variables() { + return { + id: this.abuseReportId, + }; + }, + update(data) { + return data.abuseReport?.discussions || []; + }, + skip() { + return !this.abuseReportId; + }, + error() { + createAlert({ message: this.$options.i18n.fetchError }); + }, + }, + }, + computed: { + initialLoading() { + return this.$apollo.queries.abuseReportNotes.loading; + }, + notesArray() { + return this.abuseReportNotes?.nodes || []; + }, + }, + methods: { + getDiscussionKey(discussion) { + const discussionId = discussion.notes.nodes[0].id; + return discussionId.split('/')[discussionId.split('/').length - 1]; + }, + }, +}; +</script> + +<template> + <div> + <div class="issuable-discussion gl-mb-5 gl-clearfix!"> + <template v-if="initialLoading"> + <ul class="notes main-notes-list timeline"> + <skeleton-loading-container + v-for="index in $options.SKELETON_NOTES_COUNT" + :key="index" + class="note-skeleton" + /> + </ul> + </template> + + <template v-else> + <ul class="notes main-notes-list timeline"> + <abuse-report-discussion + v-for="discussion in notesArray" + :key="getDiscussionKey(discussion)" + :discussion="discussion.notes.nodes" + :abuse-report-id="abuseReportId" + /> + </ul> + </template> + </div> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue b/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue index 8c4c1da28b8..2206e600543 100644 --- a/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue +++ b/app/assets/javascripts/admin/abuse_report/components/activity_events_list.vue @@ -11,7 +11,7 @@ export default { <!-- The styles `issuable-discussion`, `timeline`, `main-notes-list` and `notes` used below are declared in app/assets/stylesheets/pages/notes.scss --> <section class="gl-pt-6 issuable-discussion"> - <h2 class="gl-font-lg gl-mt-0 gl-mb-2">{{ $options.i18n.activity }}</h2> + <h2 class="gl-font-size-h1 gl-mt-0 gl-mb-4">{{ $options.i18n.activity }}</h2> <ul class="timeline main-notes-list notes"> <slot name="history-items"></slot> </ul> diff --git a/app/assets/javascripts/admin/abuse_report/components/labels_select.vue b/app/assets/javascripts/admin/abuse_report/components/labels_select.vue index 747c9a1a947..d2d143f0460 100644 --- a/app/assets/javascripts/admin/abuse_report/components/labels_select.vue +++ b/app/assets/javascripts/admin/abuse_report/components/labels_select.vue @@ -11,7 +11,7 @@ import DropdownContentsCreateView from '~/sidebar/components/labels/labels_selec import DropdownHeader from '~/sidebar/components/labels/labels_select_widget/dropdown_header.vue'; import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; import DropdownWidget from '~/vue_shared/components/dropdown/dropdown_widget/dropdown_widget.vue'; -import abuseReportLabelsQuery from './graphql/abuse_report_labels.query.graphql'; +import abuseReportLabelsQuery from '../graphql/abuse_report_labels.query.graphql'; export default { components: { diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue new file mode 100644 index 00000000000..4d24471fa43 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_discussion.vue @@ -0,0 +1,104 @@ +<script> +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import DiscussionNotesRepliesWrapper from '~/notes/components/discussion_notes_replies_wrapper.vue'; +import ToggleRepliesWidget from '~/notes/components/toggle_replies_widget.vue'; +import AbuseReportNote from './abuse_report_note.vue'; + +export default { + name: 'AbuseReportDiscussion', + components: { + TimelineEntryItem, + DiscussionNotesRepliesWrapper, + ToggleRepliesWidget, + AbuseReportNote, + }, + props: { + abuseReportId: { + type: String, + required: true, + }, + discussion: { + type: Array, + required: true, + }, + }, + data() { + return { + isExpanded: true, + }; + }, + computed: { + note() { + return this.discussion[0]; + }, + noteId() { + return getIdFromGraphQLId(this.note.id); + }, + replies() { + if (this.discussion?.length > 1) { + return this.discussion.slice(1); + } + return null; + }, + hasReplies() { + return Boolean(this.replies?.length); + }, + discussionId() { + return this.discussion[0]?.discussion?.id || ''; + }, + }, + methods: { + toggleDiscussion() { + this.isExpanded = !this.isExpanded; + }, + }, +}; +</script> + +<template> + <abuse-report-note + v-if="!hasReplies" + :note="note" + :abuse-report-id="abuseReportId" + class="gl-mb-4" + /> + <timeline-entry-item v-else :data-note-id="noteId" class="note note-discussion gl-px-0"> + <div class="timeline-content"> + <div class="discussion"> + <div class="discussion-body"> + <div class="discussion-wrapper"> + <div class="discussion-notes"> + <ul class="notes"> + <abuse-report-note + :note="note" + :discussion-id="discussionId" + :abuse-report-id="abuseReportId" + class="gl-mb-4" + /> + <discussion-notes-replies-wrapper> + <toggle-replies-widget + v-if="hasReplies" + :collapsed="!isExpanded" + :replies="replies" + @toggle="toggleDiscussion({ discussionId })" + /> + <template v-if="isExpanded"> + <template v-for="reply in replies"> + <abuse-report-note + :key="reply.id" + :discussion-id="discussionId" + :note="reply" + :abuse-report-id="abuseReportId" + /> + </template> + </template> + </discussion-notes-replies-wrapper> + </ul> + </div> + </div> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue new file mode 100644 index 00000000000..6da3017e11e --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note.vue @@ -0,0 +1,81 @@ +<script> +import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import NoteBody from './abuse_report_note_body.vue'; + +export default { + name: 'AbuseReportNote', + directives: { + SafeHtml, + }, + components: { + GlAvatarLink, + GlAvatar, + TimelineEntryItem, + NoteHeader, + NoteBody, + }, + props: { + abuseReportId: { + type: String, + required: true, + }, + note: { + type: Object, + required: true, + }, + }, + computed: { + noteAnchorId() { + return `note_${getIdFromGraphQLId(this.note.id)}`; + }, + author() { + return this.note.author; + }, + authorId() { + return getIdFromGraphQLId(this.author.id); + }, + }, +}; +</script> + +<template> + <timeline-entry-item :id="noteAnchorId" class="note note-wrapper note-comment"> + <div :key="note.id" class="timeline-avatar gl-float-left"> + <gl-avatar-link + :href="author.webUrl" + :data-user-id="authorId" + :data-username="author.username" + class="js-user-link" + > + <gl-avatar + :src="author.avatarUrl" + :entity-name="author.username" + :alt="author.name" + :size="32" + /> + </gl-avatar-link> + </div> + <div class="timeline-content"> + <div data-testid="note-wrapper"> + <div class="note-header"> + <note-header + :author="author" + :created-at="note.createdAt" + :note-id="note.id" + :note-url="note.url" + > + <span v-if="note.createdAt" class="d-none d-sm-inline">·</span> + </note-header> + </div> + + <div class="timeline-discussion-body"> + <note-body ref="noteBody" :note="note" /> + </div> + </div> + </div> + </timeline-entry-item> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_body.vue b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_body.vue new file mode 100644 index 00000000000..ab3d7f5fa6c --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/components/notes/abuse_report_note_body.vue @@ -0,0 +1,48 @@ +<script> +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +export default { + name: 'AbuseReportNoteBody', + directives: { + SafeHtml, + }, + props: { + note: { + type: Object, + required: true, + }, + }, + watch: { + 'note.bodyHtml': { + immediate: true, + async handler(newVal, oldVal) { + if (newVal === oldVal) { + return; + } + await this.$nextTick(); + this.renderGFM(); + }, + }, + }, + methods: { + renderGFM() { + renderGFM(this.$refs['note-body']); + gl?.lazyLoader?.searchLazyImages(); + }, + }, + safeHtmlConfig: { + ADD_TAGS: ['use', 'gl-emoji', 'copy-code'], + }, +}; +</script> + +<template> + <div ref="note-body" class="note-body"> + <div + v-safe-html:[$options.safeHtmlConfig]="note.bodyHtml" + class="note-text md" + data-testid="abuse-report-note-body" + ></div> + </div> +</template> diff --git a/app/assets/javascripts/admin/abuse_report/components/report_details.vue b/app/assets/javascripts/admin/abuse_report/components/report_details.vue index 10e1dca7f91..89017e6cbd4 100644 --- a/app/assets/javascripts/admin/abuse_report/components/report_details.vue +++ b/app/assets/javascripts/admin/abuse_report/components/report_details.vue @@ -1,8 +1,8 @@ <script> import { __ } from '~/locale'; import { createAlert } from '~/alert'; +import abuseReportQuery from '../graphql/abuse_report.query.graphql'; import LabelsSelect from './labels_select.vue'; -import abuseReportQuery from './graphql/abuse_report.query.graphql'; export default { name: 'ReportDetails', diff --git a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue index 84d6f25ac05..99c8b3ece10 100644 --- a/app/assets/javascripts/admin/abuse_report/components/reported_content.vue +++ b/app/assets/javascripts/admin/abuse_report/components/reported_content.vue @@ -67,7 +67,7 @@ export default { <div class="gl-pb-3 gl-display-flex gl-justify-content-space-between gl-xs-flex-direction-column gl-align-items-center" > - <h2 class="gl-font-lg gl-mt-2 gl-mb-2"> + <h2 class="gl-font-size-h1 gl-mt-2 gl-mb-2"> {{ $options.i18n.reportTypes[reportType] }} </h2> @@ -128,7 +128,7 @@ export default { </gl-link> <time-ago-tooltip :time="report.reportedAt" - class="gl-ml-3 gl-text-secondary gl-xs-w-full" + class="gl-ml-3 gl-text-secondary gl-w-full gl-sm-w-auto" /> </div> </div> diff --git a/app/assets/javascripts/admin/abuse_report/constants.js b/app/assets/javascripts/admin/abuse_report/constants.js index f028408bed7..c56ea678b1d 100644 --- a/app/assets/javascripts/admin/abuse_report/constants.js +++ b/app/assets/javascripts/admin/abuse_report/constants.js @@ -111,3 +111,5 @@ export const HISTORY_ITEMS_I18N = { reportedByForCategory: s__('AbuseReport|Reported by %{name} for %{category}.'), deletedReporter: s__('AbuseReport|No user found'), }; + +export const SKELETON_NOTES_COUNT = 5; diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report.query.graphql b/app/assets/javascripts/admin/abuse_report/graphql/abuse_report.query.graphql index f5b075cb9af..640eec718f8 100644 --- a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report.query.graphql +++ b/app/assets/javascripts/admin/abuse_report/graphql/abuse_report.query.graphql @@ -1,5 +1,6 @@ query abuseReportQuery($id: AbuseReportID!) { abuseReport(id: $id) { + id labels { nodes { id diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql b/app/assets/javascripts/admin/abuse_report/graphql/abuse_report_labels.query.graphql index 4e724b4db2c..4e724b4db2c 100644 --- a/app/assets/javascripts/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql +++ b/app/assets/javascripts/admin/abuse_report/graphql/abuse_report_labels.query.graphql diff --git a/app/assets/javascripts/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql b/app/assets/javascripts/admin/abuse_report/graphql/create_abuse_report_label.mutation.graphql index 0781b8e634b..0781b8e634b 100644 --- a/app/assets/javascripts/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql +++ b/app/assets/javascripts/admin/abuse_report/graphql/create_abuse_report_label.mutation.graphql diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note.fragment.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note.fragment.graphql new file mode 100644 index 00000000000..84b57b4ed79 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note.fragment.graphql @@ -0,0 +1,30 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" +#import "./abuse_report_note_permissions.fragment.graphql" + +fragment AbuseReportNote on Note { + id + body + bodyHtml + createdAt + lastEditedAt + url + resolved + author { + ...Author + } + lastEditedBy { + ...Author + webPath + } + userPermissions { + ...AbuseReportNotePermissions + } + discussion { + id + notes { + nodes { + id + } + } + } +} diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql new file mode 100644 index 00000000000..01436436b93 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_note_permissions.fragment.graphql @@ -0,0 +1,3 @@ +fragment AbuseReportNotePermissions on NotePermissions { + adminNote +} diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql new file mode 100644 index 00000000000..3a13ac1f37a --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/abuse_report_notes.query.graphql @@ -0,0 +1,18 @@ +#import "./abuse_report_note.fragment.graphql" + +query abuseReportNotes($id: AbuseReportID!) { + abuseReport(id: $id) { + id + discussions { + nodes { + id + replyId + notes { + nodes { + ...AbuseReportNote + } + } + } + } + } +} diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql new file mode 100644 index 00000000000..53ac9468e08 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/create_abuse_report_note.mutation.graphql @@ -0,0 +1,18 @@ +#import "./abuse_report_note.fragment.graphql" + +mutation createAbuseReportNote($input: CreateNoteInput!) { + createNote(input: $input) { + note { + id + discussion { + id + notes { + nodes { + ...AbuseReportNote + } + } + } + } + errors + } +} diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/delete_abuse_report_note.fragment.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/delete_abuse_report_note.fragment.graphql new file mode 100644 index 00000000000..e8ff2933159 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/delete_abuse_report_note.fragment.graphql @@ -0,0 +1,8 @@ +mutation deleteAbuseReportNote($input: DestroyNoteInput!) { + destroyNote(input: $input) { + errors + note { + id + } + } +} diff --git a/app/assets/javascripts/admin/abuse_report/graphql/notes/update_abuse_report_note.mutation.graphql b/app/assets/javascripts/admin/abuse_report/graphql/notes/update_abuse_report_note.mutation.graphql new file mode 100644 index 00000000000..e11165074c9 --- /dev/null +++ b/app/assets/javascripts/admin/abuse_report/graphql/notes/update_abuse_report_note.mutation.graphql @@ -0,0 +1,10 @@ +#import "./abuse_report_note.fragment.graphql" + +mutation updateAbuseReportNote($input: UpdateNoteInput!) { + updateNote(input: $input) { + note { + ...AbuseReportNote + } + errors + } +} diff --git a/app/assets/javascripts/admin/background_migrations/index.js b/app/assets/javascripts/admin/background_migrations/index.js index 4ddd8f17c9a..890df17080d 100644 --- a/app/assets/javascripts/admin/background_migrations/index.js +++ b/app/assets/javascripts/admin/background_migrations/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Translate from '~/vue_shared/translate'; import BackgroundMigrationsDatabaseListbox from './components/database_listbox.vue'; diff --git a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue index 2c555aca3c0..753b1fb1819 100644 --- a/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue +++ b/app/assets/javascripts/admin/broadcast_messages/components/message_form.vue @@ -107,7 +107,7 @@ export default { targetSelected: '', targetPath: this.broadcastMessage.targetPath, targetAccessLevels: this.broadcastMessage.targetAccessLevels, - targetAccessLevelOptions: this.targetAccessLevelOptions.map(([text, value]) => ({ + targetAccessLevelCheckBoxGroupOptions: this.targetAccessLevelOptions.map(([text, value]) => ({ text, value, })), @@ -324,7 +324,10 @@ export default { :state="!isValidated || targetRolesValid" data-testid="target-roles-checkboxes" > - <gl-form-checkbox-group v-model="targetAccessLevels" :options="targetAccessLevelOptions" /> + <gl-form-checkbox-group + v-model="targetAccessLevels" + :options="targetAccessLevelCheckBoxGroupOptions" + /> </gl-form-group> <gl-form-group diff --git a/app/assets/javascripts/admin/users/components/actions/index.js b/app/assets/javascripts/admin/users/components/actions/index.js index 4e63a85df89..633bc4d8b15 100644 --- a/app/assets/javascripts/admin/users/components/actions/index.js +++ b/app/assets/javascripts/admin/users/components/actions/index.js @@ -9,6 +9,8 @@ import Reject from './reject.vue'; import Unban from './unban.vue'; import Unblock from './unblock.vue'; import Unlock from './unlock.vue'; +import Trust from './trust_user.vue'; +import Untrust from './untrust_user.vue'; export default { Activate, @@ -22,4 +24,6 @@ export default { Unblock, Unlock, Reject, + Trust, + Untrust, }; diff --git a/app/assets/javascripts/admin/users/components/actions/trust_user.vue b/app/assets/javascripts/admin/users/components/actions/trust_user.vue new file mode 100644 index 00000000000..41ff8d4120d --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/trust_user.vue @@ -0,0 +1,62 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = ` + <p>${s__('AdminUsers|When not being monitored for spam:')}</p> + <ul> + <li>${s__( + 'AdminUsers|The user can create issues, notes, snippets, and merge requests that appear to be spam without being blocked.', + )}</li> + </ul> + <p>${s__('AdminUsers|You can untrust this user in the future.')}</p> +`; + +export default { + components: { + GlDisclosureDropdownItem, + }, + props: { + username: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { + title: sprintf(s__('AdminUsers|Stop monitoring %{username} for possible spam?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.trust, + attributes: { variant: 'confirm' }, + }, + messageHtml, + }, + }); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> +</template> diff --git a/app/assets/javascripts/admin/users/components/actions/untrust_user.vue b/app/assets/javascripts/admin/users/components/actions/untrust_user.vue new file mode 100644 index 00000000000..da59833af07 --- /dev/null +++ b/app/assets/javascripts/admin/users/components/actions/untrust_user.vue @@ -0,0 +1,56 @@ +<script> +import { GlDisclosureDropdownItem } from '@gitlab/ui'; +import { sprintf, s__, __ } from '~/locale'; +import eventHub, { EVENT_OPEN_CONFIRM_MODAL } from '~/vue_shared/components/confirm_modal_eventhub'; +import { I18N_USER_ACTIONS } from '../../constants'; + +// TODO: To be replaced with <template> content in https://gitlab.com/gitlab-org/gitlab/-/issues/320922 +const messageHtml = `<p>${s__( + 'AdminUsers|You can trust this user in the future if necessary.', +)}</p>`; + +export default { + components: { + GlDisclosureDropdownItem, + }, + props: { + username: { + type: String, + required: true, + }, + path: { + type: String, + required: true, + }, + }, + methods: { + onClick() { + eventHub.$emit(EVENT_OPEN_CONFIRM_MODAL, { + path: this.path, + method: 'put', + modalAttributes: { + title: sprintf(s__('AdminUsers|Re-enable spam monitoring for %{username}?'), { + username: this.username, + }), + actionCancel: { + text: __('Cancel'), + }, + actionPrimary: { + text: I18N_USER_ACTIONS.untrust, + attributes: { variant: 'confirm' }, + }, + messageHtml, + }, + }); + }, + }, +}; +</script> + +<template> + <gl-disclosure-dropdown-item @action="onClick"> + <template #list-item> + <slot></slot> + </template> + </gl-disclosure-dropdown-item> +</template> diff --git a/app/assets/javascripts/admin/users/components/app.vue b/app/assets/javascripts/admin/users/components/app.vue index a3abd904a6b..b0caffb6ca6 100644 --- a/app/assets/javascripts/admin/users/components/app.vue +++ b/app/assets/javascripts/admin/users/components/app.vue @@ -1,9 +1,15 @@ <script> -import UsersTable from './users_table.vue'; +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; +import UsersTable from '~/vue_shared/components/users_table/users_table.vue'; +import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; +import UserActions from './user_actions.vue'; export default { components: { UsersTable, + UserActions, }, props: { users: { @@ -16,11 +22,64 @@ export default { required: true, }, }, + data() { + return { + groupCounts: {}, + }; + }, + apollo: { + groupCounts: { + query: getUsersGroupCountsQuery, + variables() { + return { + usernames: this.users.map((user) => user.username), + }; + }, + update(data) { + const nodes = data?.users?.nodes || []; + const parsedIds = convertNodeIdsFromGraphQLIds(nodes); + + return parsedIds.reduce((acc, { id, groupCount }) => { + acc[id] = groupCount || 0; + return acc; + }, {}); + }, + error(error) { + createAlert({ + message: this.$options.i18n.groupCountFetchError, + captureError: true, + error, + }); + }, + skip() { + return !this.users.length; + }, + }, + }, + computed: { + groupCountsLoading() { + return this.$apollo.queries.groupCounts.loading; + }, + }, + i18n: { + groupCountFetchError: s__( + 'AdminUsers|Could not load user group counts. Please refresh the page to try again.', + ), + }, }; </script> <template> <div> - <users-table :users="users" :paths="paths" /> + <users-table + :users="users" + :admin-user-path="paths.adminUser" + :group-counts="groupCounts" + :group-counts-loading="groupCountsLoading" + > + <template #user-actions="{ user }"> + <user-actions :user="user" :paths="paths" :show-button-labels="true" /> + </template> + </users-table> </div> </template> diff --git a/app/assets/javascripts/admin/users/constants.js b/app/assets/javascripts/admin/users/constants.js index 9cd61d6b1db..73383623aa2 100644 --- a/app/assets/javascripts/admin/users/constants.js +++ b/app/assets/javascripts/admin/users/constants.js @@ -1,9 +1,5 @@ import { s__, __ } from '~/locale'; -export const USER_AVATAR_SIZE = 32; - -export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; - export const I18N_USER_ACTIONS = { edit: __('Edit'), userAdministration: s__('AdminUsers|User administration'), @@ -19,4 +15,6 @@ export const I18N_USER_ACTIONS = { deleteWithContributions: s__('AdminUsers|Delete user and contributions'), ban: s__('AdminUsers|Ban user'), unban: s__('AdminUsers|Unban user'), + trust: s__('AdminUsers|Trust user'), + untrust: s__('AdminUsers|Untrust user'), }; diff --git a/app/assets/javascripts/alert.js b/app/assets/javascripts/alert.js index 4d724b17723..fd20d216385 100644 --- a/app/assets/javascripts/alert.js +++ b/app/assets/javascripts/alert.js @@ -1,7 +1,7 @@ -import * as Sentry from '@sentry/browser'; import Vue from 'vue'; import isEmpty from 'lodash/isEmpty'; import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __ } from '~/locale'; export const VARIANT_SUCCESS = 'success'; diff --git a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue index fb872243e5e..29156a624fd 100644 --- a/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue +++ b/app/assets/javascripts/alerts_settings/components/alerts_settings_form.vue @@ -13,8 +13,8 @@ import { GlTabs, GlTab, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { isEqual, isEmpty, omit } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { PROMO_URL, DOCS_URL_IN_EE_DIR } from 'jh_else_ce/lib/utils/url_utility'; import { diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js index 4fa88279fe0..d1c8d2c24e7 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/mutations.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { formatMedianValues } from '../utils'; -import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC } from '../constants'; +import { PAGINATION_SORT_DIRECTION_DESC, PAGINATION_SORT_FIELD_DURATION } from '../constants'; import * as types from './mutation_types'; export default { @@ -41,7 +41,7 @@ export default { Vue.set(state, 'pagination', { page, hasNextPage, - sort: sort || PAGINATION_SORT_FIELD_END_EVENT, + sort: sort || PAGINATION_SORT_FIELD_DURATION, direction: direction || PAGINATION_SORT_DIRECTION_DESC, }); }, diff --git a/app/assets/javascripts/analytics/cycle_analytics/store/state.js b/app/assets/javascripts/analytics/cycle_analytics/store/state.js index 3d9b56b043d..f387bf65093 100644 --- a/app/assets/javascripts/analytics/cycle_analytics/store/state.js +++ b/app/assets/javascripts/analytics/cycle_analytics/store/state.js @@ -1,5 +1,5 @@ import { - PAGINATION_SORT_FIELD_END_EVENT, + PAGINATION_SORT_FIELD_DURATION, PAGINATION_SORT_DIRECTION_DESC, } from '~/analytics/cycle_analytics/constants'; @@ -29,7 +29,7 @@ export default () => ({ pagination: { page: null, hasNextPage: false, - sort: PAGINATION_SORT_FIELD_END_EVENT, + sort: PAGINATION_SORT_FIELD_DURATION, direction: PAGINATION_SORT_DIRECTION_DESC, }, predefinedDateRange: null, diff --git a/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js b/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js deleted file mode 100644 index 91cb48e181b..00000000000 --- a/app/assets/javascripts/analytics/product_analytics/activity_charts_bundle.js +++ /dev/null @@ -1,28 +0,0 @@ -import Vue from 'vue'; -import ActivityChart from './components/activity_chart.vue'; - -export default () => { - const containers = document.querySelectorAll('.js-project-analytics-chart'); - - if (!containers) { - return false; - } - - return containers.forEach((container) => { - const { chartData } = container.dataset; - const formattedData = JSON.parse(chartData); - - return new Vue({ - el: container, - components: { - ActivityChart, - }, - provide: { - formattedData, - }, - render(createElement) { - return createElement('activity-chart'); - }, - }); - }); -}; diff --git a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue b/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue deleted file mode 100644 index 2be9ebda87a..00000000000 --- a/app/assets/javascripts/analytics/product_analytics/components/activity_chart.vue +++ /dev/null @@ -1,45 +0,0 @@ -<script> -import { GlColumnChart } from '@gitlab/ui/dist/charts'; -import { s__ } from '~/locale'; - -export default { - i18n: { - noDataMsg: s__( - 'ProductAnalytics|There is no data for this type of chart currently. Please see the Setup tab if you have not configured the product analytics tool already.', - ), - }, - components: { - GlColumnChart, - }, - inject: { - formattedData: { - default: {}, - }, - }, - computed: { - barSeriesData() { - return [ - { - name: 'full', - data: this.formattedData.keys.map((val, idx) => [val, this.formattedData.values[idx]]), - }, - ]; - }, - }, -}; -</script> - -<template> - <div class="gl-xs-w-full"> - <gl-column-chart - v-if="formattedData.keys" - :bars="barSeriesData" - :x-axis-title="__('Value')" - :y-axis-title="__('Number of events')" - :x-axis-type="'category'" - /> - <p v-else data-testid="noActivityChartData"> - {{ $options.i18n.noDataMsg }} - </p> - </div> -</template> diff --git a/app/assets/javascripts/analytics/shared/components/metric_tile.vue b/app/assets/javascripts/analytics/shared/components/metric_tile.vue index 54dbe329c7a..9e0262b5175 100644 --- a/app/assets/javascripts/analytics/shared/components/metric_tile.vue +++ b/app/assets/javascripts/analytics/shared/components/metric_tile.vue @@ -44,6 +44,7 @@ export default { :animation-decimal-places="decimalPlaces" :class="{ 'gl-hover-cursor-pointer': hasLinks }" tabindex="0" + use-delimiters @click="clickHandler(metric)" /> <metric-popover :metric="metric" :target="metric.identifier" /> diff --git a/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue index 8d7761694d1..247c147609b 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/usage_trends_count_chart.vue @@ -1,8 +1,8 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlLineChart } from '@gitlab/ui/dist/charts'; -import * as Sentry from '@sentry/browser'; import { some, every } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { differenceInMonths, formatDateAsMonth, diff --git a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue index 06b83c87985..47a34ec8b4d 100644 --- a/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue +++ b/app/assets/javascripts/analytics/usage_trends/components/users_chart.vue @@ -1,9 +1,9 @@ <script> import { GlAlert } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; -import * as Sentry from '@sentry/browser'; import produce from 'immer'; import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { formatDateAsMonth } from '~/lib/utils/datetime_utility'; import { __ } from '~/locale'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; diff --git a/app/assets/javascripts/api/bulk_imports_api.js b/app/assets/javascripts/api/bulk_imports_api.js index d636cfdff0b..248f5601705 100644 --- a/app/assets/javascripts/api/bulk_imports_api.js +++ b/app/assets/javascripts/api/bulk_imports_api.js @@ -2,6 +2,21 @@ import { buildApiUrl } from '~/api/api_utils'; import axios from '~/lib/utils/axios_utils'; const BULK_IMPORT_ENTITIES_PATH = '/api/:version/bulk_imports/entities'; +const BULK_IMPORT_ENTITIES_FAILURES_PATH = + '/api/:version/bulk_imports/:id/entities/:entity_id/failures'; export const getBulkImportsHistory = (params) => axios.get(buildApiUrl(BULK_IMPORT_ENTITIES_PATH), { params }); + +export const getBulkImportFailures = (id, entityId, { page, perPage }) => { + const failuresPath = buildApiUrl(BULK_IMPORT_ENTITIES_FAILURES_PATH) + .replace(':id', encodeURIComponent(id)) + .replace(':entity_id', encodeURIComponent(entityId)); + + return axios.get(failuresPath, { + params: { + page, + per_page: perPage, + }, + }); +}; diff --git a/app/assets/javascripts/badges/components/badge_form.vue b/app/assets/javascripts/badges/components/badge_form.vue index 2be59f00773..19da1253a17 100644 --- a/app/assets/javascripts/badges/components/badge_form.vue +++ b/app/assets/javascripts/badges/components/badge_form.vue @@ -277,11 +277,7 @@ export default { > {{ saveText }} </gl-button> - <gl-button - :type="cancelButtonType" - data-qa-selector="cancel_badge_button" - @click="handleCancel" - > + <gl-button :type="cancelButtonType" @click="handleCancel"> {{ __('Cancel') }} </gl-button> </div> diff --git a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue index fac45f32464..b5cb1862b45 100644 --- a/app/assets/javascripts/batch_comments/components/submit_dropdown.vue +++ b/app/assets/javascripts/batch_comments/components/submit_dropdown.vue @@ -1,39 +1,69 @@ <script> -import { GlDropdown, GlButton, GlIcon, GlForm, GlFormCheckbox } from '@gitlab/ui'; +import { + GlDisclosureDropdown, + GlButton, + GlIcon, + GlForm, + GlFormCheckbox, + GlFormRadioGroup, +} from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapGetters, mapActions, mapState } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __ } from '~/locale'; import { createAlert } from '~/alert'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; import { scrollToElement } from '~/lib/utils/common_utils'; +import { fetchPolicies } from '~/lib/graphql'; import { CLEAR_AUTOSAVE_ENTRY_EVENT } from '~/vue_shared/constants'; import markdownEditorEventHub from '~/vue_shared/components/markdown/eventhub'; import { trackSavedUsingEditor } from '~/vue_shared/components/markdown/tracking'; +import userCanApproveQuery from '../queries/can_approve.query.graphql'; export default { + apollo: { + userPermissions: { + fetchPolicy: fetchPolicies.NETWORK_ONLY, + query: userCanApproveQuery, + variables() { + return { + projectPath: this.projectPath.replace(/^\//, ''), + iid: `${this.getNoteableData.iid}`, + }; + }, + update: (data) => data.project?.mergeRequest?.userPermissions, + skip() { + return !this.dropdownVisible; + }, + }, + }, components: { - GlDropdown, + GlDisclosureDropdown, GlButton, GlIcon, GlForm, + GlFormRadioGroup, GlFormCheckbox, MarkdownEditor, ApprovalPassword: () => import('ee_component/batch_comments/components/approval_password.vue'), SummarizeMyReview: () => import('ee_component/batch_comments/components/summarize_my_review.vue'), }, + mixins: [glFeatureFlagsMixin()], inject: { canSummarize: { default: false }, }, data() { return { isSubmitting: false, + dropdownVisible: false, noteData: { noteable_type: '', noteable_id: '', note: '', approve: false, approval_password: '', + reviewer_state: 'reviewed', }, formFieldProps: { id: 'review-note-body', @@ -42,17 +72,51 @@ export default { 'aria-label': __('Comment'), 'data-testid': 'comment-textarea', }, + userPermissions: {}, }; }, computed: { ...mapGetters(['getNotesData', 'getNoteableData', 'noteableType', 'getCurrentUserLastNote']), ...mapState('batchComments', ['shouldAnimateReviewButton']), + ...mapState('diffs', ['projectPath']), autocompleteDataSources() { return gl.GfmAutoComplete?.dataSources; }, autosaveKey() { return `submit_review_dropdown/${this.getNoteableData.id}`; }, + radioGroupOptions() { + return [ + { + html: [ + __('Comment'), + `<p class="help-text"> + ${__('Submit general feedback without explicit approval.')} + </p>`, + ].join('<br />'), + value: 'reviewed', + }, + { + html: [ + __('Approve'), + `<p class="help-text"> + ${__('Submit feedback and approve these changes.')} + </p>`, + ].join('<br />'), + value: 'approved', + disabled: !this.userPermissions.canApprove, + }, + { + html: [ + __('Request changes'), + `<p class="help-text"> + ${__('Submit feedback that should be addressed before merging.')} + </p>`, + ].join('<br />'), + value: 'requested_changes', + }, + ]; + }, }, watch: { 'noteData.approve': function noteDataApproveWatch() { @@ -60,21 +124,21 @@ export default { this.repositionDropdown(); }); }, + dropdownVisible(val) { + if (!val) { + this.userPermissions = {}; + } + }, + userPermissions: { + handler() { + this.repositionDropdown(); + }, + deep: true, + }, }, mounted() { this.noteData.noteable_type = this.noteableType; this.noteData.noteable_id = this.getNoteableData.id; - - // We override the Bootstrap Vue click outside behaviour - // to allow for clicking in the autocomplete dropdowns - // without this override the submit dropdown will close - // whenever a item in the autocomplete dropdown is clicked - const originalClickOutHandler = this.$refs.submitDropdown.$refs.dropdown.clickOutHandler; - this.$refs.submitDropdown.$refs.dropdown.clickOutHandler = (e) => { - if (!e.target.closest('.atwho-container')) { - originalClickOutHandler(e); - } - }; }, methods: { ...mapActions('batchComments', ['publishReview']), @@ -113,86 +177,115 @@ export default { updateNote(note) { this.noteData.note = note; }, + onBeforeClose({ originalEvent: { target }, preventDefault }) { + if ( + target && + [document.querySelector('.atwho-container'), document.querySelector('.dz-hidden-input')] + .filter(Boolean) + .some((el) => el.contains(target)) + ) { + preventDefault(); + } + }, + setDropdownVisible(val) { + this.dropdownVisible = val; + }, }, restrictedToolbarItems: ['full-screen'], }; </script> <template> - <gl-dropdown + <gl-disclosure-dropdown ref="submitDropdown" - right - dropup + placement="right" class="submit-review-dropdown" :class="{ 'submit-review-dropdown-animated': shouldAnimateReviewButton }" data-testid="submit-review-dropdown" - variant="info" - category="primary" + fluid-width + @beforeClose="onBeforeClose" + @shown="setDropdownVisible(true)" + @hidden="setDropdownVisible(false)" > - <template #button-content> - {{ __('Finish review') }} - <gl-icon class="dropdown-chevron" name="chevron-up" /> + <template #toggle> + <gl-button variant="info" category="primary"> + {{ __('Finish review') }} + <gl-icon class="dropdown-chevron" name="chevron-up" /> + </gl-button> </template> - <gl-form data-testid="submit-gl-form" @submit.prevent="submitReview"> - <div class="gl-display-flex gl-mb-4 gl-align-items-center"> - <label for="review-note-body" class="gl-mb-0"> - {{ __('Summary comment (optional)') }} - </label> - <summarize-my-review - v-if="canSummarize" - :id="getNoteableData.id" - class="gl-ml-auto" - @input="updateNote" - /> - </div> - <div class="common-note-form gfm-form"> - <markdown-editor - ref="markdownEditor" - v-model="noteData.note" - class="js-no-autosize" - :is-submitting="isSubmitting" - :render-markdown-path="getNoteableData.preview_note_path" - :markdown-docs-path="getNotesData.markdownDocsPath" - :form-field-props="formFieldProps" - enable-autocomplete - :autocomplete-data-sources="autocompleteDataSources" - :disabled="isSubmitting" - :restricted-tool-bar-items="$options.restrictedToolbarItems" - :force-autosize="false" - :autosave-key="autosaveKey" - supports-quick-actions - @input="$emit('input', $event)" - @keydown.meta.enter="submitReview" - @keydown.ctrl.enter="submitReview" - /> - </div> - <template v-if="getNoteableData.current_user.can_approve"> - <gl-form-checkbox - v-model="noteData.approve" - data-testid="approve_merge_request" + <template #default> + <gl-form + class="submit-review-dropdown-form gl-p-4" + data-testid="submit-gl-form" + @submit.prevent="submitReview" + > + <div class="gl-display-flex gl-mb-4 gl-align-items-center"> + <label for="review-note-body" class="gl-mb-0"> + {{ __('Summary comment (optional)') }} + </label> + <summarize-my-review + v-if="canSummarize" + :id="getNoteableData.id" + class="gl-ml-auto" + @input="updateNote" + /> + </div> + <div class="common-note-form gfm-form"> + <markdown-editor + ref="markdownEditor" + v-model="noteData.note" + class="js-no-autosize" + :is-submitting="isSubmitting" + :render-markdown-path="getNoteableData.preview_note_path" + :markdown-docs-path="getNotesData.markdownDocsPath" + :form-field-props="formFieldProps" + enable-autocomplete + :autocomplete-data-sources="autocompleteDataSources" + :disabled="isSubmitting" + :restricted-tool-bar-items="$options.restrictedToolbarItems" + :force-autosize="false" + :autosave-key="autosaveKey" + supports-quick-actions + @input="$emit('input', $event)" + @keydown.meta.enter="submitReview" + @keydown.ctrl.enter="submitReview" + /> + </div> + <gl-form-radio-group + v-if="glFeatures.mrRequestChanges" + v-model="noteData.reviewer_state" + :options="radioGroupOptions" class="gl-mt-4" - > - {{ __('Approve merge request') }} - </gl-form-checkbox> + data-testid="reviewer_states" + /> + <template v-else-if="userPermissions.canApprove"> + <gl-form-checkbox + v-model="noteData.approve" + data-testid="approve_merge_request" + class="gl-mt-4" + > + {{ __('Approve merge request') }} + </gl-form-checkbox> + </template> <approval-password - v-if="getNoteableData.require_password_to_approve" - v-show="noteData.approve" + v-if="userPermissions.canApprove && getNoteableData.require_password_to_approve" + v-show="noteData.approve || noteData.reviewer_state === 'approved'" v-model="noteData.approval_password" class="gl-mt-3" data-testid="approve_password" /> - </template> - <div class="gl-display-flex gl-justify-content-start gl-mt-4"> - <gl-button - :loading="isSubmitting" - variant="confirm" - type="submit" - class="js-no-auto-disable" - data-testid="submit-review-button" - > - {{ __('Submit review') }} - </gl-button> - </div> - </gl-form> - </gl-dropdown> + <div class="gl-display-flex gl-justify-content-start gl-mt-4"> + <gl-button + :loading="isSubmitting" + variant="confirm" + type="submit" + class="js-no-auto-disable" + data-testid="submit-review-button" + > + {{ __('Submit review') }} + </gl-button> + </div> + </gl-form> + </template> + </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql b/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql new file mode 100644 index 00000000000..f0c9ef7b3c8 --- /dev/null +++ b/app/assets/javascripts/batch_comments/queries/can_approve.query.graphql @@ -0,0 +1,11 @@ +query userCanApprove($projectPath: ID!, $iid: String!) { + project(fullPath: $projectPath) { + id + mergeRequest(iid: $iid) { + id + userPermissions { + canApprove + } + } + } +} diff --git a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js index 070ce38c8aa..d97f11a0acd 100644 --- a/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js +++ b/app/assets/javascripts/batch_comments/stores/modules/batch_comments/actions.js @@ -72,22 +72,20 @@ export const fetchDrafts = ({ commit, getters, state, dispatch }) => }), ); -export const publishSingleDraft = ({ commit, dispatch, getters }, draftId) => { +export const publishSingleDraft = ({ commit, getters }, draftId) => { commit(types.REQUEST_PUBLISH_DRAFT, draftId); service .publishDraft(getters.getNotesData.draftsPublishPath, draftId) - .then(() => dispatch('updateDiscussionsAfterPublish')) .then(() => commit(types.RECEIVE_PUBLISH_DRAFT_SUCCESS, draftId)) .catch(() => commit(types.RECEIVE_PUBLISH_DRAFT_ERROR, draftId)); }; -export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => { +export const publishReview = ({ commit, getters }, noteData = {}) => { commit(types.REQUEST_PUBLISH_REVIEW); return service .publish(getters.getNotesData.draftsPublishPath, noteData) - .then(() => dispatch('updateDiscussionsAfterPublish')) .then(() => commit(types.RECEIVE_PUBLISH_REVIEW_SUCCESS)) .catch((e) => { commit(types.RECEIVE_PUBLISH_REVIEW_ERROR); @@ -96,18 +94,6 @@ export const publishReview = ({ commit, dispatch, getters }, noteData = {}) => { }); }; -export const updateDiscussionsAfterPublish = async ({ dispatch, getters, rootGetters }) => { - await dispatch( - 'fetchDiscussions', - { path: getters.getNotesData.discussionsPath }, - { root: true }, - ); - - dispatch('diffs/assignDiscussionsToDiff', rootGetters.discussionsStructuredByLineCode, { - root: true, - }); -}; - export const updateDraft = ( { commit, getters }, { note, noteText, resolveDiscussion, position, flashContainer, callback, errorCallback }, diff --git a/app/assets/javascripts/behaviors/index.js b/app/assets/javascripts/behaviors/index.js index dc9153e61f7..84ff8fa7f33 100644 --- a/app/assets/javascripts/behaviors/index.js +++ b/app/assets/javascripts/behaviors/index.js @@ -3,7 +3,6 @@ import './autosize'; import initCollapseSidebarOnWindowResize from './collapse_sidebar_on_window_resize'; import initCopyToClipboard from './copy_to_clipboard'; import installGlEmojiElement from './gl_emoji'; -import { loadStartupCSS } from './load_startup_css'; import initCopyAsGFM from './markdown/copy_as_gfm'; import './quick_submit'; import './requires_input'; @@ -13,8 +12,6 @@ import { initGlobalAlerts } from './global_alerts'; import './toggler_behavior'; import './preview_markdown'; -loadStartupCSS(); - installGlEmojiElement(); initCopyAsGFM(); diff --git a/app/assets/javascripts/behaviors/load_startup_css.js b/app/assets/javascripts/behaviors/load_startup_css.js deleted file mode 100644 index dbe9ff8b6e7..00000000000 --- a/app/assets/javascripts/behaviors/load_startup_css.js +++ /dev/null @@ -1,15 +0,0 @@ -export const loadStartupCSS = () => { - // We need to fallback to dispatching `load` in case our event listener was added too late - // or the browser environment doesn't load media=print. - // Do this on `window.load` so that the default deferred behavior takes precedence. - // https://gitlab.com/gitlab-org/gitlab/-/issues/239357 - window.addEventListener( - 'load', - () => { - document - .querySelectorAll('link[media=print]') - .forEach((x) => x.dispatchEvent(new Event('load'))); - }, - { once: true }, - ); -}; diff --git a/app/assets/javascripts/behaviors/shortcuts/keybindings.js b/app/assets/javascripts/behaviors/shortcuts/keybindings.js index e8c486f6e74..941662635ea 100644 --- a/app/assets/javascripts/behaviors/shortcuts/keybindings.js +++ b/app/assets/javascripts/behaviors/shortcuts/keybindings.js @@ -381,6 +381,12 @@ export const PROJECT_FILES_GO_TO_PERMALINK = { defaultKeys: ['y'], }; +export const PROJECT_FILES_GO_TO_COMPARE = { + id: 'projectFiles.goToCompare', + description: __('Compare Branches'), + defaultKeys: ['shift+c'], +}; + export const ISSUABLE_COMMENT_OR_REPLY = { id: 'issuables.commentReply', description: __('Comment/Reply (quoting selected text)'), @@ -606,6 +612,7 @@ const PROJECT_FILES_SHORTCUTS_GROUP = { PROJECT_FILES_OPEN_SELECTION, PROJECT_FILES_GO_BACK, PROJECT_FILES_GO_TO_PERMALINK, + PROJECT_FILES_GO_TO_COMPARE, ], }; diff --git a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js index d9dc3aae808..4691a4228e6 100644 --- a/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js +++ b/app/assets/javascripts/behaviors/shortcuts/shortcuts_navigation.js @@ -18,6 +18,7 @@ import { GO_TO_PROJECT_KUBERNETES, GO_TO_PROJECT_ENVIRONMENTS, GO_TO_PROJECT_WEBIDE, + PROJECT_FILES_GO_TO_COMPARE, NEW_ISSUE, } from './keybindings'; import Shortcuts from './shortcuts'; @@ -43,6 +44,7 @@ export default class ShortcutsNavigation extends Shortcuts { [GO_TO_PROJECT_SNIPPETS, () => findAndFollowLink('.shortcuts-snippets')], [GO_TO_PROJECT_KUBERNETES, () => findAndFollowLink('.shortcuts-kubernetes')], [GO_TO_PROJECT_ENVIRONMENTS, () => findAndFollowLink('.shortcuts-environments')], + [PROJECT_FILES_GO_TO_COMPARE, () => findAndFollowLink('.shortcuts-compare')], [GO_TO_PROJECT_WEBIDE, ShortcutsNavigation.navigateToWebIDE], [NEW_ISSUE, () => findAndFollowLink('.shortcuts-new-issue')], ]); diff --git a/app/assets/javascripts/blob/components/blob_header.vue b/app/assets/javascripts/blob/components/blob_header.vue index 699a0491183..5411881a8d2 100644 --- a/app/assets/javascripts/blob/components/blob_header.vue +++ b/app/assets/javascripts/blob/components/blob_header.vue @@ -5,7 +5,7 @@ import userInfoQuery from '../queries/user_info.query.graphql'; import applicationInfoQuery from '../queries/application_info.query.graphql'; import BlobFilepath from './blob_header_filepath.vue'; import ViewerSwitcher from './blob_header_viewer_switcher.vue'; -import { SIMPLE_BLOB_VIEWER } from './constants'; +import { SIMPLE_BLOB_VIEWER, BLAME_VIEWER } from './constants'; import TableOfContents from './table_contents.vue'; export default { @@ -85,6 +85,11 @@ export default { required: false, default: '', }, + showBlameToggle: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -93,9 +98,6 @@ export default { }; }, computed: { - showViewerSwitcher() { - return !this.hideViewerSwitcher && Boolean(this.blob.simpleViewer && this.blob.richViewer); - }, showDefaultActions() { return !this.hideDefaultActions; }, @@ -114,7 +116,7 @@ export default { }, watch: { viewer(newVal, oldVal) { - if (!this.hideViewerSwitcher && newVal !== oldVal) { + if (newVal !== BLAME_VIEWER && newVal !== oldVal) { this.$emit('viewer-changed', newVal); } }, @@ -138,7 +140,14 @@ export default { </div> <div class="gl-display-flex gl-flex-wrap file-actions"> - <viewer-switcher v-if="showViewerSwitcher" v-model="viewer" :doc-icon="blobSwitcherDocIcon" /> + <viewer-switcher + v-if="!hideViewerSwitcher" + v-model="viewer" + :doc-icon="blobSwitcherDocIcon" + :show-blame-toggle="showBlameToggle" + :show-viewer-toggles="Boolean(blob.simpleViewer && blob.richViewer)" + v-on="$listeners" + /> <web-ide-link v-if="showWebIdeLink" diff --git a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue index 7351df0f93b..9b5b77ebebe 100644 --- a/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue +++ b/app/assets/javascripts/blob/components/blob_header_viewer_switcher.vue @@ -5,6 +5,8 @@ import { RICH_BLOB_VIEWER_TITLE, SIMPLE_BLOB_VIEWER, SIMPLE_BLOB_VIEWER_TITLE, + BLAME_VIEWER, + BLAME_TITLE, } from './constants'; export default { @@ -26,6 +28,16 @@ export default { default: 'document', required: false, }, + showViewerToggles: { + type: Boolean, + required: false, + default: false, + }, + showBlameToggle: { + type: Boolean, + required: false, + default: false, + }, }, computed: { isSimpleViewer() { @@ -34,9 +46,16 @@ export default { isRichViewer() { return this.value === RICH_BLOB_VIEWER; }, + isBlameViewer() { + return this.value === BLAME_VIEWER; + }, }, methods: { switchToViewer(viewer) { + if (viewer === BLAME_VIEWER) { + this.$emit('blame'); + } + if (viewer !== this.value) { this.$emit('input', viewer); } @@ -46,11 +65,14 @@ export default { RICH_BLOB_VIEWER, SIMPLE_BLOB_VIEWER_TITLE, RICH_BLOB_VIEWER_TITLE, + BLAME_TITLE, + BLAME_VIEWER, }; </script> <template> <gl-button-group class="js-blob-viewer-switcher mx-2"> <gl-button + v-if="showViewerToggles" v-gl-tooltip.hover :aria-label="$options.SIMPLE_BLOB_VIEWER_TITLE" :title="$options.SIMPLE_BLOB_VIEWER_TITLE" @@ -63,6 +85,7 @@ export default { @click="switchToViewer($options.SIMPLE_BLOB_VIEWER)" /> <gl-button + v-if="showViewerToggles" v-gl-tooltip.hover :aria-label="$options.RICH_BLOB_VIEWER_TITLE" :title="$options.RICH_BLOB_VIEWER_TITLE" @@ -74,5 +97,16 @@ export default { data-viewer="rich" @click="switchToViewer($options.RICH_BLOB_VIEWER)" /> + <gl-button + v-if="showBlameToggle" + v-gl-tooltip.hover + :title="$options.BLAME_TITLE" + :selected="isBlameViewer" + category="primary" + variant="default" + data-test-id="blame-toggle" + @click="switchToViewer($options.BLAME_VIEWER)" + >{{ __('Blame') }}</gl-button + > </gl-button-group> </template> diff --git a/app/assets/javascripts/blob/components/constants.js b/app/assets/javascripts/blob/components/constants.js index adac4d6408d..bccab09c7a2 100644 --- a/app/assets/javascripts/blob/components/constants.js +++ b/app/assets/javascripts/blob/components/constants.js @@ -11,6 +11,9 @@ export const SIMPLE_BLOB_VIEWER_TITLE = __('Display source'); export const RICH_BLOB_VIEWER = 'rich'; export const RICH_BLOB_VIEWER_TITLE = __('Display rendered file'); +export const BLAME_VIEWER = 'blame'; +export const BLAME_TITLE = __('Display blame info'); + export const BLOB_RENDER_EVENT_LOAD = 'force-content-fetch'; export const BLOB_RENDER_EVENT_SHOW_SOURCE = 'force-switch-viewer'; diff --git a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue index 51c69590796..379d5e38197 100644 --- a/app/assets/javascripts/blob/filepath_form/components/template_selector.vue +++ b/app/assets/javascripts/blob/filepath_form/components/template_selector.vue @@ -2,6 +2,7 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import SuggestGitlabCiYml from '~/blob/suggest_gitlab_ci_yml/components/popover.vue'; import { __ } from '~/locale'; +import { DEFAULT_CI_CONFIG_PATH, CI_CONFIG_PATH_EXTENSION } from '~/lib/utils/constants'; const templateSelectors = [ { @@ -12,8 +13,8 @@ const templateSelectors = [ }, { key: 'gitlab_ci_ymls', - name: '.gitlab-ci.yml', - pattern: /(.gitlab-ci.yml)/, + name: DEFAULT_CI_CONFIG_PATH, + pattern: CI_CONFIG_PATH_EXTENSION, type: 'gitlab_ci_ymls', }, { diff --git a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue index f3c542c467a..0cc75d28e0b 100644 --- a/app/assets/javascripts/blob/pipeline_tour_success_modal.vue +++ b/app/assets/javascripts/blob/pipeline_tour_success_modal.vue @@ -94,14 +94,12 @@ export default { <gl-modal visible size="sm" modal-id="success-pipeline-modal-id-not-used"> <template #modal-title> {{ $options.i18n.modalTitle }} - <gl-emoji class="gl-vertical-align-baseline font-size-inherit gl-mr-1" data-name="tada" /> + <gl-emoji class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" data-name="tada" /> </template> <p> <gl-sprintf :message="$options.i18n.bodyMessage"> <template #codeQualityLink="{ content }"> - <gl-link :href="codeQualityLink" target="_blank" class="font-size-inherit">{{ - content - }}</gl-link> + <gl-link :href="codeQualityLink" target="_blank">{{ content }}</gl-link> </template> </gl-sprintf> </p> diff --git a/app/assets/javascripts/boards/boards_util.js b/app/assets/javascripts/boards/boards_util.js index bf77aa4996c..fd36eea95eb 100644 --- a/app/assets/javascripts/boards/boards_util.js +++ b/app/assets/javascripts/boards/boards_util.js @@ -204,7 +204,8 @@ export function moveItemListHelper(item, fromList, toList) { export function moveItemVariables({ iid, - epicId, + itemId, + epicId = null, fromListId, toListId, moveBeforeId, @@ -225,6 +226,7 @@ export function moveItemVariables({ }; } return { + itemId, epicId, boardId, moveBeforeId, diff --git a/app/assets/javascripts/boards/components/board_add_new_column_form.vue b/app/assets/javascripts/boards/components/board_add_new_column_form.vue index 419d0b41d69..a3c0553d17c 100644 --- a/app/assets/javascripts/boards/components/board_add_new_column_form.vue +++ b/app/assets/javascripts/boards/components/board_add_new_column_form.vue @@ -43,7 +43,6 @@ export default { <div class="board-add-new-list board gl-display-inline-block gl-h-full gl-vertical-align-top gl-white-space-normal gl-flex-shrink-0 gl-rounded-base gl-px-3" data-testid="board-add-new-column" - data-qa-selector="board_add_new_list" > <div class="board-inner gl-display-flex gl-flex-direction-column gl-relative gl-h-full gl-rounded-base gl-bg-gray-50" diff --git a/app/assets/javascripts/boards/components/board_card_inner.vue b/app/assets/javascripts/boards/components/board_card_inner.vue index c10ff2e08da..a7f46dc9325 100644 --- a/app/assets/javascripts/boards/components/board_card_inner.vue +++ b/app/assets/javascripts/boards/components/board_card_inner.vue @@ -139,8 +139,11 @@ export default { } return false; }, + hasChildren() { + return this.totalIssuesCount + this.totalEpicsCount > 0; + }, shouldRenderEpicCountables() { - return this.isEpicBoard && this.item.hasIssues; + return this.isEpicBoard && this.hasChildren; }, shouldRenderEpicProgress() { return this.totalWeight > 0; @@ -396,7 +399,7 @@ export default { <issue-due-date v-if="item.dueDate" :date="item.dueDate" - :closed="item.closed || Boolean(item.closedAt)" + :closed="Boolean(item.closedAt)" /> <issue-time-estimate v-if="item.timeEstimate" :estimate="item.timeEstimate" /> <issue-card-weight diff --git a/app/assets/javascripts/boards/components/board_content.vue b/app/assets/javascripts/boards/components/board_content.vue index 554f3bfa416..a6ff1653c17 100644 --- a/app/assets/javascripts/boards/components/board_content.vue +++ b/app/assets/javascripts/boards/components/board_content.vue @@ -249,7 +249,6 @@ export default { <transition name="slide" @after-enter="afterFormEnters"> <board-add-new-column v-if="addColumnFormVisible" - class="gl-xs-w-full!" :board-id="boardId" :list-query-variables="listQueryVariables" :lists="boardListsById" diff --git a/app/assets/javascripts/boards/components/board_list.vue b/app/assets/javascripts/boards/components/board_list.vue index 2693a6bb5ea..ca10cbbad5e 100644 --- a/app/assets/javascripts/boards/components/board_list.vue +++ b/app/assets/javascripts/boards/components/board_list.vue @@ -100,9 +100,6 @@ export default { filters: this.filterParams, }; }, - context: { - isSingleRequest: true, - }, skip() { return this.isEpicBoard; }, @@ -123,9 +120,6 @@ export default { update(data) { return data[this.boardType].board.lists.nodes[0]; }, - context: { - isSingleRequest: true, - }, error(error) { setError({ error, @@ -149,9 +143,6 @@ export default { update(data) { return data[this.boardType].board.lists.nodes[0]; }, - context: { - isSingleRequest: true, - }, error(error) { setError({ error, @@ -400,7 +391,7 @@ export default { this.updateIssueOrderInProgress = true; await this.moveBoardItem( { - epicId: itemId, + itemId, iid: itemIid, fromListId: from.dataset.listId, toListId: to.dataset.listId, @@ -428,11 +419,11 @@ export default { return items.some((item) => item.iid === itemIid); }, async moveBoardItem(variables, newIndex) { - const { fromListId, toListId, iid } = variables; + const { fromListId, toListId, iid, itemId } = variables; this.toListId = toListId; await this.$nextTick(); // we need this next tick to retrieve `toList` from Apollo cache - const itemToMove = this.boardListItems.find((item) => item.iid === iid); + const itemToMove = this.boardListItems.find((item) => item.id === itemId); if (this.shouldCloneCard && this.isItemInTheList(iid)) { return; @@ -445,6 +436,7 @@ export default { ...moveItemVariables({ ...variables, isIssue: !this.isEpicBoard, + epicId: itemId, // for Epic Boards boardId: this.boardId, itemToMove, }), @@ -532,7 +524,8 @@ export default { variables: { ...moveItemVariables({ iid: item.iid, - epicId: item.id, + itemId: item.id, + epicId: item.id, // for Epic Boards fromListId: this.currentList.id, toListId: this.currentList.id, isIssue: !this.isEpicBoard, diff --git a/app/assets/javascripts/boards/components/board_list_header.vue b/app/assets/javascripts/boards/components/board_list_header.vue index 0235edd69ac..bedb3a75a70 100644 --- a/app/assets/javascripts/boards/components/board_list_header.vue +++ b/app/assets/javascripts/boards/components/board_list_header.vue @@ -223,9 +223,6 @@ export default { variables() { return this.countQueryVariables; }, - context: { - isSingleRequest: true, - }, error(error) { setError({ error, diff --git a/app/assets/javascripts/boards/components/new_board_button.vue b/app/assets/javascripts/boards/components/new_board_button.vue index f7914c636cc..96cf0fadd6a 100644 --- a/app/assets/javascripts/boards/components/new_board_button.vue +++ b/app/assets/javascripts/boards/components/new_board_button.vue @@ -38,7 +38,7 @@ export default { <template #control> </template> <template #candidate> <div v-if="canShowCreateButton" class="gl-ml-1 gl-mr-3 gl-display-flex gl-align-items-center"> - <gl-button data-qa-selector="new_board_button" @click.prevent="showDialog"> + <gl-button @click.prevent="showDialog"> {{ createButtonText }} </gl-button> </div> diff --git a/app/assets/javascripts/boards/constants.js b/app/assets/javascripts/boards/constants.js index cb607e5220e..acf01a8c528 100644 --- a/app/assets/javascripts/boards/constants.js +++ b/app/assets/javascripts/boards/constants.js @@ -132,6 +132,7 @@ export const listIssuablesQueries = { optimisticResponse: { assignees: { nodes: [], __typename: 'UserCoreConnection' }, confidential: false, + closedAt: null, dueDate: null, emailsDisabled: false, hidden: false, diff --git a/app/assets/javascripts/boards/graphql/cache_updates.js b/app/assets/javascripts/boards/graphql/cache_updates.js index ea099e02181..bd58f445493 100644 --- a/app/assets/javascripts/boards/graphql/cache_updates.js +++ b/app/assets/javascripts/boards/graphql/cache_updates.js @@ -1,5 +1,6 @@ -import * as Sentry from '@sentry/browser'; import produce from 'immer'; +import { toNumber } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { defaultClient } from '~/graphql_shared/issuable_client'; import listQuery from 'ee_else_ce/boards/graphql/board_lists_deferred.query.graphql'; import { listsDeferredQuery } from 'ee_else_ce/boards/constants'; @@ -83,7 +84,9 @@ export function updateIssueCountAndWeight({ boardList: { ...boardList, issuesCount: boardList.issuesCount + 1, - ...(issue.weight ? { totalIssueWeight: boardList.totalIssueWeight + issue.weight } : {}), + ...(issue.weight + ? { totalIssueWeight: toNumber(boardList.totalIssueWeight) + issue.weight } + : {}), }, }), ); diff --git a/app/assets/javascripts/boards/stores/actions.js b/app/assets/javascripts/boards/stores/actions.js index 3e7d7a7a8d3..97e40c8cc39 100644 --- a/app/assets/javascripts/boards/stores/actions.js +++ b/app/assets/javascripts/boards/stores/actions.js @@ -1,5 +1,5 @@ -import * as Sentry from '@sentry/browser'; import { sortBy } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { ListType, inactiveId, @@ -148,9 +148,6 @@ export default { query: listsQuery[issuableType].query, variables, ...(resetLists ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}), - context: { - isSingleRequest: true, - }, }) .then(({ data }) => { const { lists, hideBacklogList } = data[boardType].board; @@ -439,9 +436,6 @@ export default { return gqlClient .query({ query: listsIssuesQuery, - context: { - isSingleRequest: true, - }, variables, ...(!fetchNext ? { fetchPolicy: fetchPolicies.NO_CACHE } : {}), }) diff --git a/app/assets/javascripts/boards/stores/index.js b/app/assets/javascripts/boards/stores/index.js index ee0a5e27d9a..fd562df1df7 100644 --- a/app/assets/javascripts/boards/stores/index.js +++ b/app/assets/javascripts/boards/stores/index.js @@ -8,12 +8,13 @@ import state from 'ee_else_ce/boards/stores/state'; Vue.use(Vuex); -export const createStore = () => - new Vuex.Store({ - state, - getters, - actions, - mutations, - }); +export const storeOptions = { + state, + getters, + actions, + mutations, +}; + +export const createStore = (options = storeOptions) => new Vuex.Store(options); export default createStore(); diff --git a/app/assets/javascripts/branches/components/branch_more_actions.vue b/app/assets/javascripts/branches/components/branch_more_actions.vue index c646dab2760..ee47f6af2f8 100644 --- a/app/assets/javascripts/branches/components/branch_more_actions.vue +++ b/app/assets/javascripts/branches/components/branch_more_actions.vue @@ -74,7 +74,6 @@ export default { class: 'js-delete-branch-button gl-text-red-500!', 'aria-label': this.deleteBranchText, 'data-testid': 'delete-branch-button', - 'data-qa-selector': 'delete_branch_button', }, }); } diff --git a/app/assets/javascripts/branches/components/delete_branch_modal.vue b/app/assets/javascripts/branches/components/delete_branch_modal.vue index d5631337cec..0200a30cbdf 100644 --- a/app/assets/javascripts/branches/components/delete_branch_modal.vue +++ b/app/assets/javascripts/branches/components/delete_branch_modal.vue @@ -182,7 +182,6 @@ export default { ref="deleteBranchButton" :disabled="deleteButtonDisabled" variant="danger" - data-qa-selector="delete_branch_confirmation_button" data-testid="delete-branch-confirmation-button" @click="submitForm" >{{ buttonText }}</gl-button diff --git a/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue index 89582e64f3a..55ff647e25f 100644 --- a/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue +++ b/app/assets/javascripts/ci/admin/jobs_table/admin_jobs_table_app.vue @@ -84,9 +84,6 @@ export default { update(data) { return data?.jobs?.count || 0; }, - context: { - isSingleRequest: true, - }, error() { this.error = this.$options.i18n.jobsCountErrorMsg; }, diff --git a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue index d8f9eb65236..de37aa431e6 100644 --- a/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue +++ b/app/assets/javascripts/ci/artifacts/components/job_artifacts_table.vue @@ -10,7 +10,7 @@ import { GlFormCheckbox, GlTooltipDirective, } from '@gitlab/ui'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -68,7 +68,7 @@ export default { GlPagination, GlFormCheckbox, TimeAgo, - CiBadgeLink, + CiIcon, JobCheckbox, ArtifactsBulkDelete, BulkDeleteModal, @@ -442,7 +442,7 @@ export default { <template #cell(job)="{ item }"> <div class="gl-display-inline-flex gl-align-items-center gl-mb-3 gl-gap-3"> <span data-testid="job-artifacts-job-status"> - <ci-badge-link :status="item.detailedStatus" size="sm" :show-text="false" /> + <ci-icon :status="item.detailedStatus" /> </span> <gl-link :href="item.webPath"> {{ item.name }} diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue index 85dfa12c756..fbc7ddf5c91 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_components.vue @@ -1,11 +1,13 @@ <script> -import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; +import { GlButton, GlEmptyState, GlLoadingIcon, GlTableLite } from '@gitlab/ui'; import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; import getCiCatalogResourceComponents from '../../graphql/queries/get_ci_catalog_resource_components.query.graphql'; export default { components: { + GlButton, + GlEmptyState, GlLoadingIcon, GlTableLite, }, @@ -37,6 +39,9 @@ export default { }, }, computed: { + isMetadataMissing() { + return !this.components || this.components?.length === 0; + }, isLoading() { return this.$apollo.queries.components.loading; }, @@ -70,6 +75,12 @@ export default { }, ], i18n: { + copyText: __('Copy value'), + copyAriaText: __('Copy to clipboard'), + emptyStateTitle: s__('CiCatalogComponent|Component details not available'), + emptyStateDesc: s__( + 'CiCatalogComponent|This tab displays auto-collected information about the components in the repository, but no information was found.', + ), inputTitle: s__('CiCatalogComponent|Inputs'), fetchError: s__("CiCatalogComponent|There was an error fetching this resource's components"), }, @@ -79,6 +90,11 @@ export default { <template> <div> <gl-loading-icon v-if="isLoading" size="lg" /> + <gl-empty-state + v-else-if="isMetadataMissing" + :title="$options.i18n.emptyStateTitle" + :description="$options.i18n.emptyStateDesc" + /> <template v-else> <div v-for="component in components" @@ -88,7 +104,24 @@ export default { > <h3 class="gl-font-size-h2" data-testid="component-name">{{ component.name }}</h3> <p class="gl-mt-5">{{ component.description }}</p> - <pre class="gl-w-85p gl-py-4">{{ generateSnippet(component.path) }}</pre> + <div class="gl-display-flex"> + <pre + class="gl-w-85p gl-py-4 gl-display-flex gl-justify-content-space-between gl-m-0 gl-border-r-none" + ><span>{{ generateSnippet(component.path) }}</span> + </pre> + <div class="gl--flex-center gl-bg-gray-10 gl-border gl-border-l-none"> + <gl-button + class="gl-p-4! gl-mr-3!" + category="tertiary" + icon="copy-to-clipboard" + size="small" + :title="$options.i18n.copyText" + :data-clipboard-text="generateSnippet(component.path)" + data-testid="copy-to-clipboard" + :aria-label="$options.i18n.copyAriaText" + /> + </div> + </div> <div class="gl-mt-5"> <b class="gl-display-block gl-mb-4"> {{ $options.i18n.inputTitle }}</b> <gl-table-lite :items="component.inputs.nodes" :fields="$options.fields"> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue index c0feb52c185..026a30988fd 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_details.vue @@ -30,12 +30,12 @@ export default { <template> <gl-tabs> - <gl-tab v-if="glFeatures.ciCatalogComponentsTab" :title="$options.i18n.tabs.components" lazy> - <ci-resource-components :resource-id="resourceId" - /></gl-tab> <gl-tab :title="$options.i18n.tabs.readme" lazy> <ci-resource-readme :resource-id="resourceId" /> </gl-tab> + <gl-tab v-if="glFeatures.ciCatalogComponentsTab" :title="$options.i18n.tabs.components" lazy> + <ci-resource-components :resource-id="resourceId" + /></gl-tab> </gl-tabs> </template> <style></style> diff --git a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue index 6673785ffd2..29009c14e1b 100644 --- a/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue +++ b/app/assets/javascripts/ci/catalog/components/details/ci_resource_header.vue @@ -2,13 +2,13 @@ import { GlAvatar, GlAvatarLink, GlBadge } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { isNumeric } from '~/lib/utils/number_utils'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CiResourceAbout from './ci_resource_about.vue'; import CiResourceHeaderSkeletonLoader from './ci_resource_header_skeleton_loader.vue'; export default { components: { - CiBadgeLink, + CiIcon, CiResourceAbout, CiResourceHeaderSkeletonLoader, GlAvatar, @@ -102,12 +102,11 @@ export default { {{ versionBadgeText }} </gl-badge> </span> - <ci-badge-link + <ci-icon v-if="hasPipelineStatus" - class="gl-mt-2" :status="pipelineStatus" - size="sm" - show-text + show-status-text + class="gl-mt-2" /> </div> </div> diff --git a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue index 487215875c0..db84eaa82c2 100644 --- a/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue +++ b/app/assets/javascripts/ci/catalog/components/list/catalog_header.vue @@ -4,12 +4,22 @@ import { __, s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; import { CATALOG_FEEDBACK_DISMISSED_KEY } from '../../constants'; +const defaultTitle = __('CI/CD Catalog'); +const defaultDescription = s__( + 'CiCatalog|Discover CI configuration resources for a seamless CI/CD experience.', +); + export default { components: { GlBanner, GlLink, }, - inject: ['pageTitle', 'pageDescription'], + inject: { + pageTitle: { default: defaultTitle }, + pageDescription: { + default: defaultDescription, + }, + }, data() { return { isFeedbackBannerDismissed: localStorage.getItem(CATALOG_FEEDBACK_DISMISSED_KEY) === 'true', @@ -50,7 +60,7 @@ export default { </gl-banner> <h1 class="gl-font-size-h-display">{{ pageTitle }}</h1> <p> - <span>{{ pageDescription }}</span> + <span data-testid="description">{{ pageDescription }}</span> <gl-link :href="$options.learnMorePath" target="_blank">{{ $options.i18n.learnMore }}</gl-link> diff --git a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue index 63243539575..080955b4322 100644 --- a/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue +++ b/app/assets/javascripts/ci/catalog/components/list/ci_resources_list_item.vue @@ -48,9 +48,6 @@ export default { starCount() { return this.resource?.starCount || 0; }, - forksCount() { - return this.resource?.forksCount || 0; - }, hasReleasedVersion() { return Boolean(this.latestVersion?.releasedAt); }, @@ -111,14 +108,12 @@ export default { <gl-icon name="star" :size="14" class="gl-mr-1" /> <span class="gl-mr-3">{{ starCount }}</span> </span> - <span class="gl--flex-center" data-testid="stats-forks"> - <gl-icon name="fork" :size="14" class="gl-mr-1" /> - <span>{{ forksCount }}</span> - </span> </span> </div> </div> - <div class="gl-display-flex gl-sm-flex-direction-column gl-justify-content-space-between"> + <div + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-justify-content-space-between" + > <span class="gl-display-flex gl-flex-basis-two-thirds gl-font-sm">{{ resource.description }}</span> diff --git a/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue new file mode 100644 index 00000000000..5e8727a3ed0 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/components/pages/ci_resources_page.vue @@ -0,0 +1,112 @@ +<script> +import { createAlert } from '~/alert'; +import { s__ } from '~/locale'; +import CatalogHeader from '~/ci/catalog/components/list/catalog_header.vue'; +import CatalogListSkeletonLoader from '~/ci/catalog/components/list/catalog_list_skeleton_loader.vue'; +import CiResourcesList from '~/ci/catalog/components/list/ci_resources_list.vue'; +import EmptyState from '~/ci/catalog/components/list/empty_state.vue'; +import { ciCatalogResourcesItemsCount } from '~/ci/catalog/graphql/settings'; +import getCatalogResources from '../../graphql/queries/get_ci_catalog_resources.query.graphql'; + +export default { + components: { + CatalogHeader, + CatalogListSkeletonLoader, + CiResourcesList, + EmptyState, + }, + data() { + return { + catalogResources: [], + currentPage: 1, + totalCount: 0, + pageInfo: {}, + }; + }, + apollo: { + catalogResources: { + query: getCatalogResources, + variables() { + return { + first: ciCatalogResourcesItemsCount, + }; + }, + update(data) { + return data?.ciCatalogResources?.nodes || []; + }, + result({ data }) { + const { pageInfo } = data?.ciCatalogResources || {}; + this.pageInfo = pageInfo; + this.totalCount = data?.ciCatalogResources?.count || 0; + }, + error(e) { + createAlert({ message: e.message || this.$options.i18n.fetchError, variant: 'danger' }); + }, + }, + }, + computed: { + hasResources() { + return this.catalogResources.length > 0; + }, + isLoading() { + return this.$apollo.queries.catalogResources.loading; + }, + }, + methods: { + async handlePrevPage() { + try { + await this.$apollo.queries.catalogResources.fetchMore({ + variables: { + before: this.pageInfo.startCursor, + last: ciCatalogResourcesItemsCount, + first: null, + }, + }); + + this.currentPage -= 1; + } catch (e) { + // Ensure that the current query is properly stoped if an error occurs. + this.$apollo.queries.catalogResources.stop(); + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + } + }, + async handleNextPage() { + try { + await this.$apollo.queries.catalogResources.fetchMore({ + variables: { + after: this.pageInfo.endCursor, + }, + }); + + this.currentPage += 1; + } catch (e) { + // Ensure that the current query is properly stoped if an error occurs. + this.$apollo.queries.catalogResources.stop(); + + createAlert({ message: e?.message || this.$options.i18n.fetchError, variant: 'danger' }); + } + }, + }, + i18n: { + fetchError: s__('CiCatalog|There was an error fetching CI/CD Catalog resources.'), + }, +}; +</script> +<template> + <div> + <catalog-header /> + <catalog-list-skeleton-loader v-if="isLoading" class="gl-w-full gl-mt-3" /> + <empty-state v-else-if="!hasResources" /> + <ci-resources-list + v-else + :current-page="currentPage" + :page-info="pageInfo" + :prev-text="__('Prev')" + :next-text="__('Next')" + :resources="catalogResources" + :total-count="totalCount" + @onPrevPage="handlePrevPage" + @onNextPage="handleNextPage" + /> + </div> +</template> diff --git a/app/assets/javascripts/ci/catalog/global_catalog.vue b/app/assets/javascripts/ci/catalog/global_catalog.vue new file mode 100644 index 00000000000..76eac11a122 --- /dev/null +++ b/app/assets/javascripts/ci/catalog/global_catalog.vue @@ -0,0 +1,10 @@ +<script> +import CiCatalogHome from './components/ci_catalog_home.vue'; + +export default { + components: { CiCatalogHome }, +}; +</script> +<template> + <ci-catalog-home /> +</template> diff --git a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql index f4d1bb0eaaf..a86db4c1b03 100644 --- a/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql +++ b/app/assets/javascripts/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql @@ -4,7 +4,6 @@ fragment CatalogResourceFields on CiCatalogResource { name description starCount - forksCount latestVersion { id tagName diff --git a/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql new file mode 100644 index 00000000000..aae29edef5e --- /dev/null +++ b/app/assets/javascripts/ci/catalog/graphql/queries/get_ci_catalog_resources.query.graphql @@ -0,0 +1,16 @@ +#import "~/ci/catalog/graphql/fragments/catalog_resource.fragment.graphql" + +query getCatalogResources($after: String, $before: String, $first: Int = 20, $last: Int) { + ciCatalogResources(after: $after, before: $before, first: $first, last: $last) { + pageInfo { + startCursor + endCursor + hasNextPage + hasPreviousPage + } + count + nodes { + ...CatalogResourceFields + } + } +} diff --git a/app/assets/javascripts/ci/catalog/index.js b/app/assets/javascripts/ci/catalog/index.js new file mode 100644 index 00000000000..5815245506c --- /dev/null +++ b/app/assets/javascripts/ci/catalog/index.js @@ -0,0 +1,37 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { cacheConfig, resolvers } from '~/ci/catalog/graphql/settings'; + +import GlobalCatalog from './global_catalog.vue'; +import CiResourcesPage from './components/pages/ci_resources_page.vue'; +import { createRouter } from './router'; + +export const initCatalog = (selector = '#js-ci-cd-catalog') => { + const el = document.querySelector(selector); + if (!el) { + return null; + } + + const { dataset } = el; + const { ciCatalogPath } = dataset; + + Vue.use(VueApollo); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers, cacheConfig), + }); + + return new Vue({ + el, + name: 'GlobalCatalog', + router: createRouter(ciCatalogPath, CiResourcesPage), + apolloProvider, + provide: { + ciCatalogPath, + }, + render(h) { + return h(GlobalCatalog); + }, + }); +}; diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue index a32c5f476fb..ccfe773b01f 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_drawer.vue @@ -190,6 +190,11 @@ export default { deep: true, }, }, + beforeMount() { + // reset to default environments list every time we open the drawer + // and re-render the environments scope dropdown + this.$emit('search-environment-scope', ''); + }, mounted() { if (this.isProtectedByDefault && !this.isEditing) { this.variable = { ...this.variable, protected: true }; @@ -371,7 +376,6 @@ export default { :label-text="$options.i18n.key" class="gl-border-none gl-pb-0! gl-mb-n5" data-testid="ci-variable-key" - data-qa-selector="ci_variable_key_field" /> <gl-form-group :label="$options.i18n.value" @@ -388,7 +392,6 @@ export default { rows="3" max-rows="10" data-testid="ci-variable-value" - data-qa-selector="ci_variable_value_field" spellcheck="false" /> <p @@ -419,15 +422,14 @@ export default { variant="danger" category="secondary" class="gl-mr-3" - data-testid="ci-variable-delete-btn" + data-testid="ci-variable-delete-button" >{{ $options.i18n.deleteVariable }}</gl-button > <gl-button category="primary" variant="confirm" :disabled="!canSubmit" - data-testid="ci-variable-confirm-btn" - data-qa-selector="ci_variable_save_button" + data-testid="ci-variable-confirm-button" @click="submit" >{{ modalActionText }} </gl-button> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue deleted file mode 100644 index cc664d76267..00000000000 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_modal.vue +++ /dev/null @@ -1,511 +0,0 @@ -<script> -import { - GlAlert, - GlButton, - GlCollapse, - GlFormCheckbox, - GlFormCombobox, - GlFormGroup, - GlFormSelect, - GlFormInput, - GlFormTextarea, - GlIcon, - GlLink, - GlModal, - GlSprintf, -} from '@gitlab/ui'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { getCookie, setCookie } from '~/lib/utils/common_utils'; -import { __ } from '~/locale'; -import Tracking from '~/tracking'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; - -import { - allEnvironments, - AWS_TOKEN_CONSTANTS, - ADD_CI_VARIABLE_MODAL_ID, - AWS_TIP_DISMISSED_COOKIE_NAME, - AWS_TIP_TITLE, - AWS_TIP_MESSAGE, - CONTAINS_VARIABLE_REFERENCE_MESSAGE, - defaultVariableState, - ENVIRONMENT_SCOPE_LINK_TITLE, - EVENT_LABEL, - EVENT_ACTION, - EXPANDED_VARIABLES_NOTE, - EDIT_VARIABLE_ACTION, - FLAG_LINK_TITLE, - VARIABLE_ACTIONS, - variableOptions, -} from '../constants'; -import CiEnvironmentsDropdown from './ci_environments_dropdown.vue'; -import { awsTokens, awsTokenList } from './ci_variable_autocomplete_tokens'; - -const trackingMixin = Tracking.mixin({ label: EVENT_LABEL }); - -export default { - components: { - CiEnvironmentsDropdown, - GlAlert, - GlButton, - GlCollapse, - GlFormCheckbox, - GlFormCombobox, - GlFormGroup, - GlFormSelect, - GlFormInput, - GlFormTextarea, - GlIcon, - GlLink, - GlModal, - GlSprintf, - }, - mixins: [glFeatureFlagsMixin(), trackingMixin], - inject: [ - 'containsVariableReferenceLink', - 'environmentScopeLink', - 'isProtectedByDefault', - 'maskedEnvironmentVariablesLink', - 'maskableRawRegex', - 'maskableRegex', - ], - props: { - areEnvironmentsLoading: { - type: Boolean, - required: true, - }, - areScopedVariablesAvailable: { - type: Boolean, - required: false, - default: false, - }, - environments: { - type: Array, - required: false, - default: () => [], - }, - hideEnvironmentScope: { - type: Boolean, - required: false, - default: false, - }, - mode: { - type: String, - required: true, - validator(val) { - return VARIABLE_ACTIONS.includes(val); - }, - }, - selectedVariable: { - type: Object, - required: false, - default: () => {}, - }, - variables: { - type: Array, - required: false, - default: () => [], - }, - }, - data() { - return { - newEnvironments: [], - isTipDismissed: getCookie(AWS_TIP_DISMISSED_COOKIE_NAME) === 'true', - validationErrorEventProperty: '', - variable: { ...defaultVariableState, ...this.selectedVariable }, - }; - }, - computed: { - canMask() { - const regex = RegExp(this.useRawMaskableRegexp ? this.maskableRawRegex : this.maskableRegex); - return regex.test(this.variable.value); - }, - canSubmit() { - return this.variableValidationState && this.variable.key !== ''; - }, - containsVariableReference() { - const regex = /\$/; - return regex.test(this.variable.value) && this.isExpanded; - }, - displayMaskedError() { - return !this.canMask && this.variable.masked; - }, - isEditing() { - return this.mode === EDIT_VARIABLE_ACTION; - }, - isExpanded() { - return !this.isRaw; - }, - isRaw() { - return this.variable.raw; - }, - isTipVisible() { - return !this.isTipDismissed && AWS_TOKEN_CONSTANTS.includes(this.variable.key); - }, - maskedFeedback() { - return this.displayMaskedError - ? __('This variable value does not meet the masking requirements.') - : ''; - }, - maskedState() { - if (this.displayMaskedError) { - return false; - } - return true; - }, - modalActionText() { - return this.isEditing ? __('Update variable') : __('Add variable'); - }, - tokenValidationFeedback() { - const tokenSpecificFeedback = this.$options.tokens?.[this.variable.key]?.invalidMessage; - if (!this.tokenValidationState && tokenSpecificFeedback) { - return tokenSpecificFeedback; - } - return ''; - }, - tokenValidationState() { - const validator = this.$options.tokens?.[this.variable.key]?.validation; - - if (validator) { - return validator(this.variable.value); - } - - return true; - }, - useRawMaskableRegexp() { - return this.isRaw; - }, - variableValidationFeedback() { - return `${this.tokenValidationFeedback} ${this.maskedFeedback}`; - }, - variableValidationState() { - return this.variable.value === '' || (this.tokenValidationState && this.maskedState); - }, - variableValueHelpText() { - return this.variable.masked - ? __('Value must meet regular expression requirements to be masked.') - : ''; - }, - }, - watch: { - variable: { - handler() { - this.trackVariableValidationErrors(); - }, - deep: true, - }, - }, - methods: { - addVariable() { - this.$emit('add-variable', this.variable); - }, - deleteVariable() { - this.$emit('delete-variable', this.variable); - }, - updateVariable() { - this.$emit('update-variable', this.variable); - }, - dismissTip() { - setCookie(AWS_TIP_DISMISSED_COOKIE_NAME, 'true', { expires: 90 }); - this.isTipDismissed = true; - }, - deleteVarAndClose() { - this.deleteVariable(); - this.hideModal(); - }, - hideModal() { - this.$refs.modal.hide(); - }, - onShow() { - this.setVariableProtectedByDefault(); - }, - resetModalHandler() { - this.resetVariableData(); - this.resetValidationErrorEvents(); - - this.$emit('close-form'); - }, - resetVariableData() { - this.variable = { ...defaultVariableState }; - }, - setEnvironmentScope(scope) { - this.variable = { ...this.variable, environmentScope: scope }; - }, - setVariableRaw(expanded) { - this.variable = { ...this.variable, raw: !expanded }; - }, - setVariableProtected() { - this.variable = { ...this.variable, protected: true }; - }, - updateOrAddVariable() { - if (this.isEditing) { - this.updateVariable(); - } else { - this.addVariable(); - } - this.hideModal(); - }, - setVariableProtectedByDefault() { - if (this.isProtectedByDefault && !this.isEditing) { - this.setVariableProtected(); - } - }, - trackVariableValidationErrors() { - const property = this.getTrackingErrorProperty(); - if (!this.validationErrorEventProperty && property) { - this.track(EVENT_ACTION, { property }); - this.validationErrorEventProperty = property; - } - }, - getTrackingErrorProperty() { - let property; - if (this.variable.value?.length && !property) { - if (this.displayMaskedError && this.maskableRegex?.length) { - const supportedChars = this.maskableRegex.replace('^', '').replace(/{(\d,)}\$/, ''); - const regex = new RegExp(supportedChars, 'g'); - property = this.variable.value.replace(regex, ''); - } - if (this.containsVariableReference) { - property = '$'; - } - } - - return property; - }, - resetValidationErrorEvents() { - this.validationErrorEventProperty = ''; - }, - }, - i18n: { - awsTipTitle: AWS_TIP_TITLE, - awsTipMessage: AWS_TIP_MESSAGE, - containsVariableReferenceMessage: CONTAINS_VARIABLE_REFERENCE_MESSAGE, - defaultScope: allEnvironments.text, - environmentScopeLinkTitle: ENVIRONMENT_SCOPE_LINK_TITLE, - expandedVariablesNote: EXPANDED_VARIABLES_NOTE, - flagsLinkTitle: FLAG_LINK_TITLE, - }, - flagLink: helpPagePath('ci/variables/index', { - anchor: 'define-a-cicd-variable-in-the-ui', - }), - oidcLink: helpPagePath('ci/cloud_services/index', { - anchor: 'oidc-authorization-with-your-cloud-provider', - }), - modalId: ADD_CI_VARIABLE_MODAL_ID, - tokens: awsTokens, - tokenList: awsTokenList, - variableOptions, -}; -</script> - -<template> - <gl-modal - ref="modal" - :modal-id="$options.modalId" - :title="modalActionText" - static - lazy - @hidden="resetModalHandler" - @shown="onShow" - > - <gl-collapse :visible="isTipVisible"> - <gl-alert - :title="$options.i18n.awsTipTitle" - variant="warning" - class="gl-mb-5" - data-testid="aws-guidance-tip" - @dismiss="dismissTip" - > - <gl-sprintf :message="$options.i18n.awsTipMessage"> - <template #link="{ content }"> - <gl-link :href="$options.oidcLink"> - {{ content }} - </gl-link> - </template> - </gl-sprintf> - </gl-alert> - </gl-collapse> - <form> - <gl-form-combobox - v-model="variable.key" - :token-list="$options.tokenList" - :label-text="__('Key')" - data-testid="pipeline-form-ci-variable-key" - data-qa-selector="ci_variable_key_field" - /> - - <gl-form-group - :label="__('Value')" - label-for="ci-variable-value" - :state="variableValidationState" - :description="variableValueHelpText" - :invalid-feedback="variableValidationFeedback" - > - <gl-form-textarea - id="ci-variable-value" - ref="valueField" - v-model="variable.value" - :state="variableValidationState" - rows="3" - max-rows="10" - data-testid="pipeline-form-ci-variable-value" - data-qa-selector="ci_variable_value_field" - class="gl-font-monospace!" - spellcheck="false" - /> - <p v-if="isRaw" class="gl-mt-2 gl-mb-0 text-secondary" data-testid="raw-variable-tip"> - {{ __('Variable value will be evaluated as raw string.') }} - </p> - </gl-form-group> - - <div class="gl-display-flex"> - <gl-form-group :label="__('Type')" label-for="ci-variable-type" class="gl-w-half gl-mr-5"> - <gl-form-select - id="ci-variable-type" - v-model="variable.variableType" - :options="$options.variableOptions" - /> - </gl-form-group> - - <template v-if="!hideEnvironmentScope"> - <gl-form-group - label-for="ci-variable-env" - class="gl-w-half" - data-testid="environment-scope" - > - <template #label> - <div class="gl-display-flex gl-align-items-center"> - <span class="gl-mr-2"> - {{ __('Environment scope') }} - </span> - <gl-link - class="gl-display-flex" - :title="$options.i18n.environmentScopeLinkTitle" - :href="environmentScopeLink" - target="_blank" - data-testid="environment-scope-link" - > - <gl-icon name="question-o" :size="14" /> - </gl-link> - </div> - </template> - <ci-environments-dropdown - v-if="areScopedVariablesAvailable" - :are-environments-loading="areEnvironmentsLoading" - :selected-environment-scope="variable.environmentScope" - :environments="environments" - @select-environment="setEnvironmentScope" - @search-environment-scope="$emit('search-environment-scope', $event)" - /> - - <gl-form-input v-else :value="$options.i18n.defaultScope" class="gl-w-full" readonly /> - </gl-form-group> - </template> - </div> - - <gl-form-group> - <template #label> - <div class="gl-display-flex gl-align-items-center"> - <span class="gl-mr-2"> - {{ __('Flags') }} - </span> - <gl-link - class="gl-display-flex" - :title="$options.i18n.flagsLinkTitle" - :href="$options.flagLink" - target="_blank" - > - <gl-icon name="question-o" :size="14" /> - </gl-link> - </div> - </template> - <gl-form-checkbox - v-model="variable.protected" - class="gl-mb-0" - data-testid="ci-variable-protected-checkbox" - :data-is-protected-checked="variable.protected" - > - {{ __('Protect variable') }} - <p class="gl-mt-2 text-secondary"> - {{ __('Export variable to pipelines running on protected branches and tags only.') }} - </p> - </gl-form-checkbox> - <gl-form-checkbox - ref="masked-ci-variable" - v-model="variable.masked" - data-testid="ci-variable-masked-checkbox" - > - {{ __('Mask variable') }} - <p class="gl-mt-2 text-secondary"> - <gl-sprintf - :message=" - __( - 'Mask this variable in job logs if it meets %{linkStart}regular expression requirements%{linkEnd}.', - ) - " - > - <template #link="{ content }" - ><gl-link target="_blank" :href="maskedEnvironmentVariablesLink">{{ - content - }}</gl-link> - </template> - </gl-sprintf> - </p> - </gl-form-checkbox> - <gl-form-checkbox - ref="expanded-ci-variable" - :checked="isExpanded" - data-testid="ci-variable-expanded-checkbox" - @change="setVariableRaw" - > - {{ __('Expand variable reference') }} - <p class="gl-mt-2 gl-mb-0 gl-text-secondary"> - <gl-sprintf :message="$options.i18n.expandedVariablesNote"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - </gl-sprintf> - </p> - </gl-form-checkbox> - </gl-form-group> - </form> - - <gl-alert - v-if="containsVariableReference" - :title="__('Value might contain a variable reference')" - :dismissible="false" - variant="warning" - data-testid="contains-variable-reference" - > - <gl-sprintf :message="$options.i18n.containsVariableReferenceMessage"> - <template #code="{ content }"> - <code>{{ content }}</code> - </template> - <template #docsLink="{ content }"> - <gl-link :href="containsVariableReferenceLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-alert> - <template #modal-footer> - <gl-button @click="hideModal">{{ __('Cancel') }}</gl-button> - <gl-button - v-if="isEditing" - ref="deleteCiVariable" - variant="danger" - category="secondary" - @click="deleteVarAndClose" - >{{ __('Delete variable') }}</gl-button - > - <gl-button - ref="updateOrAddVariable" - :disabled="!canSubmit" - variant="confirm" - category="primary" - data-testid="ciUpdateOrAddVariableBtn" - data-qa-selector="ci_variable_save_button" - @click="updateOrAddVariable" - >{{ modalActionText }} - </gl-button> - </template> - </gl-modal> -</template> diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue index f2d81b3f271..99270d36df7 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_settings.vue @@ -3,13 +3,11 @@ import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { ADD_VARIABLE_ACTION, EDIT_VARIABLE_ACTION, VARIABLE_ACTIONS } from '../constants'; import CiVariableDrawer from './ci_variable_drawer.vue'; import CiVariableTable from './ci_variable_table.vue'; -import CiVariableModal from './ci_variable_modal.vue'; export default { components: { CiVariableDrawer, CiVariableTable, - CiVariableModal, }, mixins: [glFeatureFlagsMixin()], props: { @@ -65,15 +63,6 @@ export default { showForm() { return VARIABLE_ACTIONS.includes(this.mode); }, - useDrawerForm() { - return this.glFeatures?.ciVariableDrawer; - }, - showDrawer() { - return this.showForm && this.useDrawerForm; - }, - showModal() { - return this.showForm && !this.useDrawerForm; - }, }, methods: { addVariable(variable) { @@ -116,23 +105,8 @@ export default { @delete-variable="deleteVariable" @sort-changed="(val) => $emit('sort-changed', val)" /> - <ci-variable-modal - v-if="showModal" - :are-environments-loading="areEnvironmentsLoading" - :are-scoped-variables-available="areScopedVariablesAvailable" - :environments="environments" - :hide-environment-scope="hideEnvironmentScope" - :variables="variables" - :mode="mode" - :selected-variable="selectedVariable" - @add-variable="addVariable" - @delete-variable="deleteVariable" - @close-form="closeForm" - @update-variable="updateVariable" - @search-environment-scope="$emit('search-environment-scope', $event)" - /> <ci-variable-drawer - v-if="showDrawer" + v-if="showForm" :are-environments-loading="areEnvironmentsLoading" :are-scoped-variables-available="areScopedVariablesAvailable" :environments="environments" diff --git a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue index 3d62313815c..86287d586ec 100644 --- a/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue +++ b/app/assets/javascripts/ci/ci_variable_list/components/ci_variable_table.vue @@ -16,7 +16,6 @@ import { import { __, s__, sprintf } from '~/locale'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { - ADD_CI_VARIABLE_MODAL_ID, DEFAULT_EXCEEDS_VARIABLE_LIMIT_TEXT, EXCEEDS_VARIABLE_LIMIT_TEXT, MAXIMUM_VARIABLE_LIMIT_REACHED, @@ -25,7 +24,6 @@ import { import { convertEnvironmentScope } from '../utils'; export default { - modalId: ADD_CI_VARIABLE_MODAL_ID, defaultFields: [ { key: 'key', @@ -243,10 +241,8 @@ export default { >{{ valuesButtonText }}</gl-button > <gl-button - v-gl-modal-directive="$options.modalId" size="small" :disabled="exceedsVariableLimit" - data-qa-selector="add_ci_variable_button" data-testid="add-ci-variable-button" @click="setSelectedVariable()" >{{ $options.i18n.addButton }}</gl-button @@ -375,12 +371,11 @@ export default { <template v-if="!isInheritedGroupVars" #cell(actions)="{ item }"> <div class="gl-display-flex gl-justify-content-end gl-mt-n2 gl-mb-n2"> <gl-button - v-gl-modal-directive="$options.modalId" icon="pencil" size="small" class="gl-mr-3" :aria-label="$options.i18n.editButton" - data-qa-selector="edit_ci_variable_button" + data-testid="edit-ci-variable-button" @click="setSelectedVariable(item.index)" /> <gl-button @@ -390,7 +385,6 @@ export default { icon="remove" size="small" :aria-label="$options.i18n.deleteButton" - data-qa-selector="delete_ci_variable_button" /> <gl-modal ref="modal" diff --git a/app/assets/javascripts/ci/ci_variable_list/constants.js b/app/assets/javascripts/ci/ci_variable_list/constants.js index fc37b62299d..d85827b8220 100644 --- a/app/assets/javascripts/ci/ci_variable_list/constants.js +++ b/app/assets/javascripts/ci/ci_variable_list/constants.js @@ -1,6 +1,5 @@ import { __, s__, sprintf } from '~/locale'; -export const ADD_CI_VARIABLE_MODAL_ID = 'add-ci-variable'; export const ENVIRONMENT_QUERY_LIMIT = 30; export const SORT_DIRECTIONS = { @@ -45,7 +44,6 @@ export const AWS_TIP_MESSAGE = s__( 'CiVariable|GitLab CI/CD supports OpenID Connect (OIDC) to give your build and deployment jobs access to cloud credentials and services. %{linkStart}How do I configure OIDC for my cloud provider?%{linkEnd}', ); -export const EVENT_LABEL = 'ci_variable_modal'; export const DRAWER_EVENT_LABEL = 'ci_variable_drawer'; export const EVENT_ACTION = 'validation_error'; diff --git a/app/assets/javascripts/ci/common/pipelines_table.vue b/app/assets/javascripts/ci/common/pipelines_table.vue index 13b5120654a..d63d2d1713e 100644 --- a/app/assets/javascripts/ci/common/pipelines_table.vue +++ b/app/assets/javascripts/ci/common/pipelines_table.vue @@ -13,8 +13,6 @@ import PipelineUrl from '../pipelines_page/components/pipeline_url.vue'; import PipelineStatusBadge from '../pipelines_page/components/pipeline_status_badge.vue'; const HIDE_TD_ON_MOBILE = 'gl-display-none! gl-lg-display-table-cell!'; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-100! gl-p-5! gl-border-b-1!'; /** * Pipelines Table @@ -77,7 +75,6 @@ export default { { key: 'status', label: s__('Pipeline|Status'), - thClass: DEFAULT_TH_CLASSES, columnClass: 'gl-w-15p', tdClass: this.tdClasses, thAttr: { 'data-testid': 'status-th' }, @@ -85,7 +82,6 @@ export default { { key: 'pipeline', label: __('Pipeline'), - thClass: DEFAULT_TH_CLASSES, tdClass: `${this.tdClasses}`, columnClass: 'gl-w-30p', thAttr: { 'data-testid': 'pipeline-th' }, @@ -93,7 +89,6 @@ export default { { key: 'triggerer', label: s__('Pipeline|Created by'), - thClass: DEFAULT_TH_CLASSES, tdClass: `${this.tdClasses} ${HIDE_TD_ON_MOBILE}`, columnClass: 'gl-w-15p', thAttr: { 'data-testid': 'triggerer-th' }, @@ -101,14 +96,12 @@ export default { { key: 'stages', label: s__('Pipeline|Stages'), - thClass: DEFAULT_TH_CLASSES, tdClass: this.tdClasses, columnClass: 'gl-w-quarter', thAttr: { 'data-testid': 'stages-th' }, }, { key: 'actions', - thClass: DEFAULT_TH_CLASSES, tdClass: this.tdClasses, columnClass: 'gl-w-20p', thAttr: { 'data-testid': 'actions-th' }, @@ -137,8 +130,7 @@ export default { return cleanLeadingSeparator(item.project.full_path); }, failedJobsCount(pipeline) { - // Remove `pipeline?.failed_builds?.length` when we remove `ci_fix_performance_pipelines_json_endpoint`. - return pipeline?.failed_builds_count || pipeline?.failed_builds?.length || 0; + return pipeline?.failed_builds_count || 0; }, onRefreshPipelinesTable() { this.$emit('refresh-pipelines-table'); diff --git a/app/assets/javascripts/ci/common/private/job_action_component.vue b/app/assets/javascripts/ci/common/private/job_action_component.vue index b0fa724d450..c266e061513 100644 --- a/app/assets/javascripts/ci/common/private/job_action_component.vue +++ b/app/assets/javascripts/ci/common/private/job_action_component.vue @@ -119,6 +119,7 @@ export default { ref="button" :class="cssClass" :disabled="isDisabled" + size="small" class="js-ci-action gl-ci-action-icon-container ci-action-icon-container ci-action-icon-wrapper gl-display-flex gl-align-items-center gl-justify-content-center" data-testid="ci-action-button" @click.stop="onClickAction" @@ -129,8 +130,17 @@ export default { class="gl-display-flex gl-align-items-center gl-justify-content-center gl-h-full" data-testid="ci-action-icon-tooltip-wrapper" > - <gl-loading-icon v-if="isLoading" size="sm" class="js-action-icon-loading" /> - <gl-icon v-else :name="actionIcon" class="gl-mr-0!" :aria-label="actionIcon" /> + <gl-loading-icon + v-if="isLoading" + size="sm" + class="gl-button-icon gl-m-2 js-action-icon-loading" + /> + <gl-icon + v-else + :name="actionIcon" + class="gl-button-icon gl-p-1 gl-mr-0!" + :aria-label="actionIcon" + /> </div> </gl-button> </template> diff --git a/app/assets/javascripts/ci/common/private/job_links_layer.vue b/app/assets/javascripts/ci/common/private/job_links_layer.vue index 59260ca3f81..9b3647e9c55 100644 --- a/app/assets/javascripts/ci/common/private/job_links_layer.vue +++ b/app/assets/javascripts/ci/common/private/job_links_layer.vue @@ -1,5 +1,6 @@ <script> import { memoize } from 'lodash'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { reportToSentry } from '~/ci/utils'; import { parseData } from '~/ci/pipeline_details/utils/parsing_utils'; import LinksInner from '~/ci/pipeline_details/graph/components/links_inner.vue'; @@ -16,6 +17,7 @@ export default { components: { LinksInner, }, + mixins: [glFeatureFlagMixin()], props: { containerMeasurements: { type: Object, @@ -50,6 +52,9 @@ export default { showLinkedLayers() { return this.showLinks && !this.containerZero; }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -68,7 +73,10 @@ export default { <slot></slot> </links-inner> <div v-else> - <div class="gl-display-flex gl-relative"> + <div + class="gl-display-flex gl-relative" + :class="{ 'gl-flex-wrap gl-sm-flex-nowrap': isNewPipelineGraph }" + > <slot></slot> </div> </div> diff --git a/app/assets/javascripts/ci/common/private/job_name_component.vue b/app/assets/javascripts/ci/common/private/job_name_component.vue index 1c7f5a7476d..b4e831d69d4 100644 --- a/app/assets/javascripts/ci/common/private/job_name_component.vue +++ b/app/assets/javascripts/ci/common/private/job_name_component.vue @@ -30,7 +30,7 @@ export default { </script> <template> <span class="mw-100 gl-display-flex gl-align-items-center gl-flex-grow-1"> - <ci-icon :size="iconSize" :status="status" class="gl-line-height-0" /> + <ci-icon :size="iconSize" :status="status" :show-tooltip="false" class="gl-line-height-0" /> <span class="gl-text-truncate mw-70p gl-pl-3 gl-display-inline-block"> {{ name }} </span> diff --git a/app/assets/javascripts/ci/constants.js b/app/assets/javascripts/ci/constants.js index 5b60528f521..138a44a8dd0 100644 --- a/app/assets/javascripts/ci/constants.js +++ b/app/assets/javascripts/ci/constants.js @@ -37,4 +37,5 @@ export const TRACKING_CATEGORIES = { search: 'pipelines_filtered_search', failed: 'pipeline_failed_jobs_tab', tests: 'pipeline_tests_tab', + listbox: 'pipeline_id_iid_listbox', }; diff --git a/app/assets/javascripts/ci/job_details/components/job_header.vue b/app/assets/javascripts/ci/job_details/components/job_header.vue index 00d15f87064..1aa83a94bc5 100644 --- a/app/assets/javascripts/ci/job_details/components/job_header.vue +++ b/app/assets/javascripts/ci/job_details/components/job_header.vue @@ -4,12 +4,12 @@ import SafeHtml from '~/vue_shared/directives/safe_html'; import { isGid, getIdFromGraphQLId } from '~/graphql_shared/utils'; import { glEmojiTag } from '~/emoji'; import { __, sprintf } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; export default { components: { - CiBadgeLink, + CiIcon, TimeagoTooltip, GlButton, GlAvatarLink, @@ -113,7 +113,7 @@ export default { </div> </div> <section class="header-main-content gl-display-flex gl-align-items-center gl-mr-3"> - <ci-badge-link class="gl-mr-3" :status="status" /> + <ci-icon class="gl-mr-3" :status="status" show-status-text /> <template v-if="shouldRenderTriggeredLabel">{{ __('Started') }}</template> <template v-else>{{ __('Created') }}</template> diff --git a/app/assets/javascripts/ci/job_details/components/log/line_header.vue b/app/assets/javascripts/ci/job_details/components/log/line_header.vue index 658a94e6af4..d36701323da 100644 --- a/app/assets/javascripts/ci/job_details/components/log/line_header.vue +++ b/app/assets/javascripts/ci/job_details/components/log/line_header.vue @@ -17,7 +17,8 @@ export default { }, isClosed: { type: Boolean, - required: true, + required: false, + default: false, }, path: { type: String, diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue index 8e87f118fa4..4ec9044a21c 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/job_container_item.vue @@ -63,11 +63,11 @@ export default { <gl-icon v-if="isActive" name="arrow-right" + :show-tooltip="false" class="icon-arrow-right gl-absolute gl-display-block" - :size="14" /> - <ci-icon :status="job.status" class="gl-mr-3" :size="14" /> + <ci-icon :status="job.status" :show-tooltip="false" class="gl-mr-3" /> <span class="gl-text-truncate gl-w-full">{{ jobName }}</span> diff --git a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue index 7744395734f..e229abcbe12 100644 --- a/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue +++ b/app/assets/javascripts/ci/job_details/components/sidebar/stages_dropdown.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlDisclosureDropdown, GlSprintf } from '@gitlab/ui'; import { isEmpty } from 'lodash'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { Mousetrap } from '~/lib/mousetrap'; import { s__ } from '~/locale'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; @@ -14,7 +14,7 @@ export default { GlDisclosureDropdown, GlLink, GlSprintf, - CiBadgeLink, + CiIcon, }, props: { pipeline: { @@ -94,7 +94,10 @@ export default { </script> <template> <div class="dropdown"> - <div class="gl-display-flex gl-flex-wrap gl-gap-2 js-pipeline-info" data-testid="pipeline-info"> + <div + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-gap-2 js-pipeline-info" + data-testid="pipeline-info" + > <gl-sprintf :message="pipelineInfo"> <template #bold="{ content }"> <span class="gl-display-flex gl-font-weight-bold">{{ content }}</span> @@ -108,9 +111,9 @@ export default { > </template> <template #status> - <ci-badge-link + <ci-icon :status="pipeline.details.status" - size="sm" + show-status-text data-testid="pipeline-status-link" /> </template> @@ -125,7 +128,7 @@ export default { <template #ref> <gl-link :href="pipeline.ref.path" - class="link-commit ref-name gl-mt-1" + class="link-commit ref-name" data-testid="source-ref-link" >{{ pipeline.ref.name }}</gl-link ><clipboard-button diff --git a/app/assets/javascripts/ci/job_details/job_app.vue b/app/assets/javascripts/ci/job_details/job_app.vue index 119f8259be7..e0708289b43 100644 --- a/app/assets/javascripts/ci/job_details/job_app.vue +++ b/app/assets/javascripts/ci/job_details/job_app.vue @@ -307,7 +307,7 @@ export default { @scrollJobLogBottom="scrollBottom" @searchResults="setSearchResults" /> - <log :job-log="jobLog" :is-complete="isJobLogComplete" :search-results="searchResults" /> + <log :search-results="searchResults" /> </div> <!-- EO job log --> diff --git a/app/assets/javascripts/ci/job_details/store/actions.js b/app/assets/javascripts/ci/job_details/store/actions.js index fa23589f7d6..6f538e3b3d4 100644 --- a/app/assets/javascripts/ci/job_details/store/actions.js +++ b/app/assets/javascripts/ci/job_details/store/actions.js @@ -175,7 +175,7 @@ export const fetchJobLog = ({ dispatch, state }) => } }) .catch((e) => { - if (e.response.status === HTTP_STATUS_FORBIDDEN) { + if (e.response?.status === HTTP_STATUS_FORBIDDEN) { dispatch('receiveJobLogUnauthorizedError'); } else { reportToSentry('job_actions', e); diff --git a/app/assets/javascripts/ci/job_details/store/utils.js b/app/assets/javascripts/ci/job_details/store/utils.js index b18a3fa162d..c8b33638821 100644 --- a/app/assets/javascripts/ci/job_details/store/utils.js +++ b/app/assets/javascripts/ci/job_details/store/utils.js @@ -117,28 +117,31 @@ export const getNextLineNumber = (acc) => { * @returns Array parsed log lines */ export const logLinesParser = (lines = [], prevLogLines = [], hash = '') => - lines.reduce((acc, line) => { - const lineNumber = getNextLineNumber(acc); - - const last = acc[acc.length - 1]; - - // If the object is an header, we parse it into another structure - if (line.section_header) { - acc.push(parseHeaderLine(line, lineNumber, hash)); - } else if (isCollapsibleSection(acc, last, line)) { - // if the object belongs to a nested section, we append it to the new `lines` array of the - // previously formatted header - last.lines.push(parseLine(line, lineNumber)); - } else if (line.section_duration) { - // if the line has section_duration, we look for the correct header to add it - addDurationToHeader(acc, line); - } else { - // otherwise it's a regular line - acc.push(parseLine(line, lineNumber)); - } + lines.reduce( + (acc, line) => { + const lineNumber = getNextLineNumber(acc); + + const last = acc[acc.length - 1]; + + // If the object is an header, we parse it into another structure + if (line.section_header) { + acc.push(parseHeaderLine(line, lineNumber, hash)); + } else if (isCollapsibleSection(acc, last, line)) { + // if the object belongs to a nested section, we append it to the new `lines` array of the + // previously formatted header + last.lines.push(parseLine(line, lineNumber)); + } else if (line.section_duration) { + // if the line has section_duration, we look for the correct header to add it + addDurationToHeader(acc, line); + } else { + // otherwise it's a regular line + acc.push(parseLine(line, lineNumber)); + } - return acc; - }, prevLogLines); + return acc; + }, + [...prevLogLines], + ); /** * Finds the repeated offset, removes the old one diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue index fbdfc7c9c6a..b97243cf2ca 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/job_cell.vue @@ -136,8 +136,8 @@ export default { v-if="triggered" variant="info" :size="$options.badgeSize" - data-testid="triggered-job-badge" - >{{ s__('Job|triggered') }} + data-testid="trigger-token-job-badge" + >{{ s__('Job|trigger token') }} </gl-badge> <gl-badge v-if="showAllowedToFailBadge" diff --git a/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue index a2b6a430138..efa74d86bd6 100644 --- a/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue +++ b/app/assets/javascripts/ci/jobs_page/components/job_cells/status_cell.vue @@ -1,14 +1,14 @@ <script> import { GlIcon } from '@gitlab/ui'; import { formatTime } from '~/lib/utils/datetime_utility'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; export default { iconSize: 12, components: { - CiBadgeLink, + CiIcon, GlIcon, TimeAgoTooltip, }, @@ -38,7 +38,7 @@ export default { <template> <div> - <ci-badge-link :status="job.detailedStatus" /> + <ci-icon :status="job.detailedStatus" show-status-text /> <div class="gl-font-sm gl-text-secondary gl-mt-2 gl-ml-3"> <div v-if="duration" data-testid="job-duration"> <gl-icon name="timer" :size="$options.iconSize" data-testid="duration-icon" /> diff --git a/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue index 03e0f2dadc8..09bbb7afbca 100644 --- a/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue +++ b/app/assets/javascripts/ci/jobs_page/jobs_page_app.vue @@ -58,9 +58,6 @@ export default { }, jobsCount: { query: GetJobsCount, - context: { - isSingleRequest: true, - }, variables() { return { fullPath: this.fullPath, diff --git a/app/assets/javascripts/ci/pipeline_details/constants.js b/app/assets/javascripts/ci/pipeline_details/constants.js index 70b758ae6b0..51d0e980e78 100644 --- a/app/assets/javascripts/ci/pipeline_details/constants.js +++ b/app/assets/javascripts/ci/pipeline_details/constants.js @@ -2,7 +2,6 @@ import { __, s__ } from '~/locale'; export const CANCEL_REQUEST = 'CANCEL_REQUEST'; export const SUPPORTED_FILTER_PARAMETERS = ['username', 'ref', 'status', 'source']; -export const SCHEDULE_ORIGIN = 'schedule'; export const NEEDS_PROPERTY = 'needs'; export const EXPLICIT_NEEDS_PROPERTY = 'previousStageJobsOrNeeds'; diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue index f098d790736..3da2f27c1b9 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_component.vue @@ -4,6 +4,7 @@ import { generateColumnsFromLayersListMemoized, keepLatestDownstreamPipelines, } from '~/ci/pipeline_details/utils/parsing_utils'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import LinksLayer from '../../../common/private/job_links_layer.vue'; import { DOWNSTREAM, MAIN, UPSTREAM, ONE_COL_WIDTH, STAGE_VIEW } from '../constants'; import { validateConfigPaths } from '../utils'; @@ -19,6 +20,7 @@ export default { LinkedPipelinesColumn, StageColumnComponent, }, + mixins: [glFeatureFlagMixin()], props: { configPaths: { type: Object, @@ -132,6 +134,9 @@ export default { upstreamPipelines() { return this.hasUpstreamPipelines ? this.pipeline.upstream : []; }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, }, errorCaptured(err, _vm, info) { reportToSentry(this.$options.name, `error: ${err}, info: ${info}`); @@ -178,10 +183,15 @@ export default { <div class="js-pipeline-graph"> <div ref="mainPipelineContainer" - class="gl-display-flex gl-position-relative gl-bg-gray-10 gl-white-space-nowrap" + class="pipeline-graph gl-display-flex gl-position-relative gl-white-space-nowrap gl-rounded-lg" :class="{ - 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isLinkedPipeline, + 'gl-bg-gray-10': !isNewPipelineGraph, + 'gl-pipeline-min-h gl-py-5 gl-overflow-auto': !isNewPipelineGraph && !isLinkedPipeline, + 'pipeline-graph-container gl-bg-gray-10 gl-pipeline-min-h gl-align-items-flex-start gl-pt-3 gl-pb-8 gl-mt-3 gl-overflow-auto': + isNewPipelineGraph && !isLinkedPipeline, + 'gl-bg-gray-50 gl-sm-ml-5': isNewPipelineGraph && isLinkedPipeline, }" + data-testid="pipeline-container" > <linked-graph-wrapper> <template #upstream> @@ -199,7 +209,7 @@ export default { /> </template> <template #main> - <div :id="containerId" :ref="containerId"> + <div :id="containerId" :ref="containerId" class="pipeline-links-container"> <links-layer :pipeline-data="layout" :pipeline-id="pipeline.id" @@ -238,7 +248,7 @@ export default { <template #downstream> <linked-pipelines-column v-if="showDownstreamPipelines" - class="gl-mr-6" + :class="{ 'gl-sm-ml-3': isNewPipelineGraph }" :config-paths="configPaths" :linked-pipelines="downstreamPipelines" :column-title="__('Downstream')" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue index fb7dcb300f1..114b224fbe7 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/graph_view_selector.vue @@ -1,11 +1,11 @@ <script> import { GlAlert, GlButton, GlButtonGroup, GlLoadingIcon, GlToggle } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { __, s__ } from '~/locale'; import { STAGE_VIEW, LAYER_VIEW } from '../constants'; export default { name: 'GraphViewSelector', - components: { GlAlert, GlButton, @@ -13,7 +13,7 @@ export default { GlLoadingIcon, GlToggle, }, - + mixins: [glFeatureFlagMixin()], props: { showLinks: { type: Boolean, @@ -77,6 +77,9 @@ export default { }; }); }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, }, watch: { /* @@ -138,7 +141,13 @@ export default { <template> <div> - <div class="gl-relative gl-display-flex gl-align-items-center gl-w-max-content gl-my-4"> + <div + class="gl-relative gl-display-flex gl-align-items-center gl-my-4" + :class="{ + 'gl-w-max-content': !isNewPipelineGraph, + 'gl-flex-wrap gl-sm-flex-nowrap': isNewPipelineGraph, + }" + > <gl-loading-icon v-if="isSwitcherLoading" data-testid="switcher-loading-state" @@ -161,7 +170,10 @@ export default { <gl-toggle v-model="showLinksActive" data-testid="show-links-toggle" - class="gl-mx-4" + :class="{ + 'gl-mx-4': !isNewPipelineGraph, + 'gl-sm-ml-4 gl-mt-4 gl-sm-mt-0': isNewPipelineGraph, + }" :label="$options.i18n.linksLabelText" :is-loading="isToggleLoading" label-position="left" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue index bb36ac8b6ab..c6340e6787a 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/job_item.vue @@ -5,7 +5,7 @@ import delayedJobMixin from '~/ci/mixins/delayed_job_mixin'; import { helpPagePath } from '~/helpers/help_page_helper'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, s__, sprintf } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import ActionComponent from '../../../common/private/job_action_component.vue'; import JobNameComponent from '../../../common/private/job_name_component.vue'; import { BRIDGE_KIND, RETRY_ACTION_TITLE, SINGLE_JOB, SKIP_RETRY_MODAL_KEY } from '../constants'; @@ -58,7 +58,7 @@ export default { hoverClass: 'gl-shadow-x0-y0-b3-s1-blue-500', components: { ActionComponent, - CiBadgeLink, + CiIcon, GlBadge, GlForm, GlFormCheckbox, @@ -329,7 +329,7 @@ export default { @mouseout="hideTooltips" > <div class="gl-display-flex gl-align-items-center gl-flex-grow-1"> - <ci-badge-link :status="job.status" size="md" :show-text="false" :use-link="false" /> + <ci-icon :status="job.status" :use-link="false" /> <div class="gl-pl-3 gl-pr-3 gl-display-flex gl-flex-direction-column gl-pipeline-job-width"> <div class="gl-text-truncate gl-pr-9 gl-line-height-normal">{{ job.name }}</div> <div diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue index fb2280d971a..0d72373a0f5 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_graph_wrapper.vue @@ -1,5 +1,20 @@ +<script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + +export default { + mixins: [glFeatureFlagMixin()], + computed: { + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, + }, +}; +</script> <template> - <div class="gl-display-flex"> + <div + class="gl-display-flex" + :class="{ 'gl-flex-wrap gl-sm-flex-nowrap gl-w-full': isNewPipelineGraph }" + > <slot name="upstream"></slot> <slot name="main"></slot> <slot name="downstream"></slot> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue index 5960eea5b4f..26521f87426 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipeline.vue @@ -7,13 +7,14 @@ import { GlTooltip, GlTooltipDirective, } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { TYPENAME_CI_PIPELINE } from '~/graphql_shared/constants'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; import { __, sprintf } from '~/locale'; import CancelPipelineMutation from '~/ci/pipeline_details/graphql/mutations/cancel_pipeline.mutation.graphql'; import RetryPipelineMutation from '~/ci/pipeline_details/graphql/mutations/retry_pipeline.mutation.graphql'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { reportToSentry } from '~/ci/utils'; import { ACTION_FAILURE, DOWNSTREAM, UPSTREAM } from '../constants'; @@ -22,13 +23,14 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - CiBadgeLink, + CiIcon, GlBadge, GlButton, GlLink, GlLoadingIcon, GlTooltip, }, + mixins: [glFeatureFlagMixin()], styles: { actionSizeClasses: ['gl-h-7 gl-w-7'], flatLeftBorder: ['gl-rounded-bottom-left-none!', 'gl-rounded-top-left-none!'], @@ -115,9 +117,6 @@ export default { downstreamTitle() { return this.childPipeline ? this.sourceJobName : this.pipeline.project.name; }, - flexDirection() { - return this.isUpstream ? 'gl-flex-direction-row-reverse' : 'gl-flex-direction-row'; - }, graphqlPipelineId() { return convertToGraphQLId(TYPENAME_CI_PIPELINE, this.pipeline.id); }, @@ -176,6 +175,9 @@ export default { return `${this.downstreamTitle} #${this.pipeline.id} - ${this.pipelineStatus.label} - ${this.sourceJobInfo}`; }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, }, errorCaptured(err, _vm, info) { reportToSentry('linked_pipeline', `error: ${err}, info: ${info}`); @@ -231,9 +233,15 @@ export default { <template> <div ref="linkedPipeline" - class="gl-h-full gl-display-flex! gl-px-2" - :class="flexDirection" + class="linked-pipeline-container gl-h-full gl-display-flex!" + :class="{ + 'gl-flex-direction-row-reverse': isUpstream, + 'gl-flex-direction-row': !isUpstream, + 'gl-px-2': !isNewPipelineGraph, + 'gl-w-full gl-sm-w-auto': isNewPipelineGraph, + }" data-testid="linked-pipeline-container" + :aria-expanded="expanded" @mouseover="onDownstreamHovered" @mouseleave="onDownstreamHoverLeave" > @@ -242,17 +250,15 @@ export default { </gl-tooltip> <div class="gl-bg-white gl-border gl-p-3 gl-rounded-lg gl-w-full" :class="cardClasses"> <div class="gl-display-flex gl-gap-x-3"> - <ci-badge-link + <ci-icon v-if="!pipelineIsLoading" :status="pipelineStatus" - size="md" - :show-text="false" :use-link="false" class="gl-align-self-start" /> <div v-else class="gl-pr-3"><gl-loading-icon size="sm" inline /></div> <div - class="gl-display-flex gl-downstream-pipeline-job-width gl-flex-direction-column gl-line-height-normal" + class="gl-display-flex gl-flex-direction-column gl-line-height-normal gl-downstream-pipeline-job-width" > <span class="gl-text-truncate" data-testid="downstream-title-content"> {{ downstreamTitle }} diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue index 2de7e43c9b1..395770826d8 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/linked_pipelines_column.vue @@ -1,4 +1,5 @@ <script> +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import getPipelineDetails from 'shared_queries/pipelines/get_pipeline_details.query.graphql'; import { reportToSentry } from '~/ci/utils'; import { LOAD_FAILURE } from '../../constants'; @@ -18,6 +19,7 @@ export default { LinkedPipeline, PipelineGraph: () => import('./graph_component.vue'), }, + mixins: [glFeatureFlagMixin()], props: { columnTitle: { type: String, @@ -63,23 +65,30 @@ export default { 'gl-pipeline-job-width', 'gl-text-truncate', 'gl-line-height-36', - 'gl-pl-3', - 'gl-mb-5', ], minWidth: `${ONE_COL_WIDTH}px`, computed: { columnClass() { - const positionValues = { + const positionValuesOld = { right: 'gl-ml-6', left: 'gl-mx-6', }; + const positionValues = { + right: 'gl-mx-5', + left: 'gl-mx-4 gl-flex-basis-full', + }; + const usePositionValues = this.isNewPipelineGraph ? positionValues : positionValuesOld; - return `graph-position-${this.graphPosition} ${positionValues[this.graphPosition]}`; + return `graph-position-${this.graphPosition} ${usePositionValues[this.graphPosition]}`; }, computedTitleClasses() { const positionalClasses = this.isUpstream ? ['gl-w-full', 'gl-linked-pipeline-padding'] : []; - return [...this.$options.titleClasses, ...positionalClasses]; + return [ + ...this.$options.titleClasses, + !this.isNewPipelineGraph ?? ['gl-pl-3', 'gl-mb-5'], + ...positionalClasses, + ]; }, graphPosition() { return this.isUpstream ? 'left' : 'right'; @@ -93,6 +102,9 @@ export default { minWidth() { return this.isUpstream ? 0 : this.$options.minWidth; }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, }, methods: { getPipelineData(pipeline) { @@ -197,7 +209,7 @@ export default { </script> <template> - <div class="gl-display-flex"> + <div class="gl-display-flex" :class="{ 'gl-w-full gl-sm-w-auto': isNewPipelineGraph }"> <div :class="columnClass" class="linked-pipelines-column"> <div data-testid="linked-column-title" :class="computedTitleClasses"> {{ columnTitle }} @@ -206,8 +218,12 @@ export default { <li v-for="pipeline in linkedPipelines" :key="pipeline.id" - class="gl-display-flex gl-mb-3" - :class="{ 'gl-flex-direction-row-reverse': isUpstream }" + class="gl-display-flex" + :class="{ + 'gl-mb-3': !isNewPipelineGraph, + 'gl-flex-wrap gl-sm-flex-nowrap gl-mb-6': isNewPipelineGraph, + 'gl-flex-direction-row-reverse': !isNewPipelineGraph && isUpstream, + }" > <linked-pipeline class="gl-display-inline-block" @@ -224,12 +240,15 @@ export default { <div v-if="showContainer(pipeline.id)" :style="{ minWidth }" - class="gl-display-inline-block" + class="gl-display-inline-block pipeline-show-container" > <pipeline-graph v-if="isExpanded(pipeline.id)" :type="type" - class="gl-inline-block gl-mt-n2" + class="gl-inline-block" + :class="{ + 'gl-mt-n2': !isNewPipelineGraph, + }" :config-paths="configPaths" :pipeline="currentPipeline" :computed-pipeline-info="getPipelineLayers(pipeline.id)" diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue index bcd7705669e..7c07591d0de 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/root_graph_layout.vue @@ -1,5 +1,12 @@ <script> +import { GlCard } from '@gitlab/ui'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; + export default { + components: { + GlCard, + }, + mixins: [glFeatureFlagMixin()], props: { stageClasses: { type: String, @@ -12,18 +19,37 @@ export default { default: '', }, }, + computed: { + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, + }, }; </script> <template> <div> - <div class="gl-display-flex gl-align-items-center gl-w-full gl-mb-5" :class="stageClasses"> - <slot name="stages"> </slot> - </div> - <div - class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full" - :class="jobClasses" + <gl-card + v-if="isNewPipelineGraph" + class="gl-rounded-lg" + header-class="gl-rounded-lg gl-px-0 gl-py-0 gl-bg-white gl-border-b-0" + body-class="gl-pt-2 gl-pb-0 gl-px-2" > - <slot name="jobs"> </slot> - </div> + <template #header> + <slot name="stages"></slot> + </template> + + <slot name="jobs"></slot> + </gl-card> + <template v-else> + <div class="gl-display-flex gl-align-items-center gl-w-full" :class="stageClasses"> + <slot name="stages"> </slot> + </div> + <div + class="gl-display-flex gl-flex-direction-column gl-align-items-center gl-w-full" + :class="jobClasses" + > + <slot name="jobs"> </slot> + </div> + </template> </div> </template> diff --git a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue index 6030adc96ad..01a9c6d030d 100644 --- a/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue +++ b/app/assets/javascripts/ci/pipeline_details/graph/components/stage_column_component.vue @@ -68,7 +68,7 @@ export default { required: true, }, }, - jobClasses: [ + legacyJobClasses: [ 'gl-p-3', 'gl-border-gray-100', 'gl-border-solid', @@ -82,18 +82,43 @@ export default { 'gl-hover-border-gray-200', 'gl-focus-border-gray-200', ], - titleClasses: [ + jobClasses: [ + 'gl-p-3', + 'gl-border-0', + 'gl-bg-transparent', + 'gl-rounded-base', + 'gl-hover-bg-gray-50', + 'gl-focus-bg-gray-50', + 'gl-hover-text-gray-900', + 'gl-focus-text-gray-900', + ], + legacyTitleClasses: [ 'gl-font-weight-bold', 'gl-pipeline-job-width', 'gl-text-truncate', 'gl-line-height-36', 'gl-pl-3', ], + titleClasses: [ + 'gl-font-weight-bold', + 'gl-pipeline-job-width', + 'gl-text-truncate', + 'gl-line-height-36', + 'gl-pl-4', + 'gl-mb-n2', + ], computed: { canUpdatePipeline() { return this.userPermissions.updatePipeline; }, columnSpacingClass() { + if (this.isNewPipelineGraph) { + const baseClasses = 'stage-column gl-relative gl-flex-basis-full'; + return this.isStageView + ? `${baseClasses} is-stage-view gl-m-5` + : `${baseClasses} gl-my-5 gl-mx-7`; + } + return this.isStageView ? 'gl-px-6' : 'gl-px-9'; }, hasAction() { @@ -102,6 +127,17 @@ export default { showStageName() { return !this.isStageView; }, + isNewPipelineGraph() { + return this.glFeatures.newPipelineGraph; + }, + jobClasses() { + return this.isNewPipelineGraph ? this.$options.jobClasses : this.$options.legacyJobClasses; + }, + titleClasses() { + return this.isNewPipelineGraph + ? this.$options.titleClasses + : this.$options.legacyTitleClasses; + }, }, errorCaptured(err, _vm, info) { reportToSentry('stage_column_component', `error: ${err}, info: ${info}`); @@ -135,12 +171,16 @@ export default { }; </script> <template> - <root-graph-layout :class="columnSpacingClass" data-testid="stage-column"> + <root-graph-layout + :class="columnSpacingClass" + class="stage-column gl-relative gl-flex-basis-full" + data-testid="stage-column" + > <template #stages> <div data-testid="stage-column-title" - class="gl-display-flex gl-justify-content-space-between gl-relative" - :class="$options.titleClasses" + class="stage-column-title gl-display-flex gl-justify-content-space-between gl-relative" + :class="titleClasses" > <span :title="name" class="gl-text-truncate gl-pr-3 gl-w-85p"> {{ name }} @@ -161,7 +201,11 @@ export default { :id="groupId(group)" :key="getGroupId(group)" data-testid="stage-column-group" - class="gl-relative gl-mb-3 gl-white-space-normal gl-pipeline-job-width" + class="gl-relative gl-white-space-normal gl-pipeline-job-width" + :class="{ + 'gl-mb-3': !isNewPipelineGraph, + 'gl-mb-2': isNewPipelineGraph, + }" @mouseenter="$emit('jobHover', group.name)" @mouseleave="$emit('jobHover', '')" > @@ -174,7 +218,7 @@ export default { :pipeline-expanded="pipelineExpanded" :pipeline-id="pipelineId" :stage-name="showStageName ? group.stageName : ''" - :css-class-job-name="$options.jobClasses" + :css-class-job-name="jobClasses" :class="[ { 'gl-opacity-3': isFadedOut(group.name) }, 'gl-transition-duration-slow gl-transition-timing-function-ease', @@ -188,7 +232,7 @@ export default { :group="group" :stage-name="showStageName ? group.stageName : ''" :pipeline-id="pipelineId" - :css-class-job-name="$options.jobClasses" + :css-class-job-name="jobClasses" /> </div> </div> diff --git a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue index 51a68f6619a..651662d6395 100644 --- a/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue +++ b/app/assets/javascripts/ci/pipeline_details/header/pipeline_details_header.vue @@ -17,7 +17,7 @@ import { setUrlFragment, redirectTo } from '~/lib/utils/url_utility'; // eslint- import { __, s__, sprintf, formatNumber } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { LOAD_FAILURE, POST_FAILURE, DELETE_FAILURE, DEFAULT } from '../constants'; @@ -38,7 +38,7 @@ export default { pipelineRetry: 'pipelineRetry', finishedStatuses: ['FAILED', 'SUCCESS', 'CANCELED'], components: { - CiBadgeLink, + CiIcon, ClipboardButton, GlAlert, GlBadge, @@ -58,13 +58,17 @@ export default { i18n: { scheduleBadgeText: s__('Pipelines|Scheduled'), scheduleBadgeTooltip: __('This pipeline was created by a schedule'), + triggerBadgeText: __('trigger token'), + triggerBadgeTooltip: __( + 'This pipeline was created by an API call authenticated with a trigger token', + ), childBadgeText: s__('Pipelines|Child pipeline (%{linkStart}parent%{linkEnd})'), childBadgeTooltip: __('This is a child pipeline within the parent pipeline'), latestBadgeText: s__('Pipelines|latest'), latestBadgeTooltip: __('Latest pipeline for the most recent commit on this branch'), mergeTrainBadgeText: s__('Pipelines|merge train'), mergeTrainBadgeTooltip: s__( - 'Pipelines|This pipeline ran on the contents of this merge request combined with the contents of all other merge requests queued for merging into the target branch.', + 'Pipelines|This pipeline ran on the contents of the merge request combined with the contents of all other merge requests queued for merging into the target branch.', ), invalidBadgeText: s__('Pipelines|yaml invalid'), failedBadgeText: s__('Pipelines|error'), @@ -74,7 +78,11 @@ export default { ), detachedBadgeText: s__('Pipelines|merge request'), detachedBadgeTooltip: s__( - "Pipelines|This pipeline ran on the contents of this merge request's source branch, not the target branch.", + "Pipelines|This pipeline ran on the contents of the merge request's source branch, not the target branch.", + ), + mergedResultsBadgeText: s__('Pipelines|merged results'), + mergedResultsBadgeTooltip: s__( + 'Pipelines|This pipeline ran on the contents of the merge request combined with the contents of the target branch.', ), stuckBadgeText: s__('Pipelines|stuck'), stuckBadgeTooltip: s__('Pipelines|This pipeline is stuck'), @@ -403,7 +411,7 @@ export default { {{ commitTitle }} </h3> <div> - <ci-badge-link :status="detailedStatus" class="gl-display-inline-block gl-mb-3" /> + <ci-icon :status="detailedStatus" show-status-text :show-link="false" class="gl-mb-3" /> <div class="gl-ml-2 gl-mb-3 gl-display-inline-block gl-h-6"> <gl-link v-if="user" @@ -458,6 +466,15 @@ export default { {{ $options.i18n.scheduleBadgeText }} </gl-badge> <gl-badge + v-if="badges.trigger" + v-gl-tooltip + :title="$options.i18n.triggerBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.triggerBadgeText }} + </gl-badge> + <gl-badge v-if="badges.child" v-gl-tooltip :title="$options.i18n.childBadgeTooltip" @@ -527,6 +544,15 @@ export default { {{ $options.i18n.detachedBadgeText }} </gl-badge> <gl-badge + v-if="badges.mergedResultsPipeline" + v-gl-tooltip + :title="$options.i18n.mergedResultsBadgeTooltip" + variant="info" + size="sm" + > + {{ $options.i18n.mergedResultsBadgeText }} + </gl-badge> + <gl-badge v-if="badges.stuck" v-gl-tooltip :title="$options.i18n.stuckBadgeTooltip" diff --git a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue index 4752fbb3e96..287f6e045c6 100644 --- a/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue +++ b/app/assets/javascripts/ci/pipeline_details/jobs/components/failed_jobs_table.vue @@ -5,7 +5,7 @@ import { __, s__ } from '~/locale'; import { createAlert } from '~/alert'; import Tracking from '~/tracking'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { TRACKING_CATEGORIES } from '~/ci/constants'; import RetryFailedJobMutation from '../graphql/mutations/retry_failed_job.mutation.graphql'; import { DEFAULT_FIELDS } from '../../constants'; @@ -14,7 +14,7 @@ export default { fields: DEFAULT_FIELDS, retry: __('Retry'), components: { - CiBadgeLink, + CiIcon, GlButton, GlLink, GlTableLite, @@ -80,7 +80,7 @@ export default { <div class="gl-display-flex gl-align-items-center gl-lg-justify-content-start gl-justify-content-end" > - <ci-badge-link :status="item.detailedStatus" :show-text="false" class="gl-mr-3" /> + <ci-icon :status="item.detailedStatus" class="gl-mr-3" /> <div class="gl-text-truncate"> <gl-link :href="item.detailedStatus.detailsPath" diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js index 067ec3f305e..4966b657887 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_details_header.js @@ -23,9 +23,11 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph failureReason, triggeredByPath, schedule, + trigger, child, latest, mergeTrainPipeline, + mergedResultsPipeline, invalid, failed, autoDevops, @@ -59,9 +61,11 @@ export const createPipelineDetailsHeaderApp = (elSelector, apolloProvider, graph refText, badges: { schedule: parseBoolean(schedule), + trigger: parseBoolean(trigger), child: parseBoolean(child), latest: parseBoolean(latest), mergeTrainPipeline: parseBoolean(mergeTrainPipeline), + mergedResultsPipeline: parseBoolean(mergedResultsPipeline), invalid: parseBoolean(invalid), failed: parseBoolean(failed), autoDevops: parseBoolean(autoDevops), diff --git a/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js index c3be487caae..63a46d81dd5 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js +++ b/app/assets/javascripts/ci/pipeline_details/pipeline_shared_client.js @@ -2,10 +2,5 @@ import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; export const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - useGet: true, - }, - ), + defaultClient: createDefaultClient(), }); diff --git a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js index 8a7c3367fc1..ea2875713a9 100644 --- a/app/assets/javascripts/ci/pipeline_details/pipelines_index.js +++ b/app/assets/javascripts/ci/pipeline_details/pipelines_index.js @@ -42,8 +42,6 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { projectId, defaultBranchName, params, - iosRunnersAvailable, - registrationToken, fullPath, visibilityPipelineIdType, } = el.dataset; @@ -55,7 +53,6 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { artifactsEndpoint, artifactsEndpointPlaceholder, fullPath, - iosRunnersAvailable: parseBoolean(iosRunnersAvailable), manualActionsLimit: 50, pipelineEditorPath, pipelineSchedulesPath, @@ -84,7 +81,6 @@ export const initPipelinesIndex = (selector = '#pipelines-list-vue') => { newPipelinePath, params: JSON.parse(params), projectId, - registrationToken, resetCachePath, store: this.store, }, diff --git a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue index 8f4d566e7e6..204eaf20664 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/editor/ci_editor_header.vue @@ -80,7 +80,7 @@ export default { <template> <div - class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1 gl-sm-flex-direction-column" + class="gl-display-flex gl-p-3 gl-gap-3 gl-border-solid gl-border-gray-100 gl-border-1 gl-flex-direction-column gl-md-flex-direction-row" > <slot></slot> <gl-button diff --git a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue index 221a45d4d9a..21e21d54758 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/file_nav/branch_switcher.vue @@ -1,13 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlInfiniteScroll, - GlLoadingIcon, - GlSearchBoxByType, - GlTooltipDirective, -} from '@gitlab/ui'; +import { GlCollapsibleListbox, GlTooltipDirective } from '@gitlab/ui'; import { produce } from 'immer'; import { historyPushState } from '~/lib/utils/common_utils'; import { setUrlParams } from '~/lib/utils/url_utility'; @@ -25,17 +17,11 @@ import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/las export default { i18n: { dropdownHeader: __('Switch branch'), - title: __('Branches'), fetchError: __('Unable to fetch branch list for this project.'), }, inputDebounce: BRANCH_SEARCH_DEBOUNCE, components: { - GlDropdown, - GlDropdownItem, - GlDropdownSectionHeader, - GlInfiniteScroll, - GlLoadingIcon, - GlSearchBoxByType, + GlCollapsibleListbox, }, directives: { GlTooltip: GlTooltipDirective, @@ -66,6 +52,7 @@ export default { pageCounter: 0, searchTerm: '', lastCommitBranch: '', + infiniteScrollLoading: false, }; }, apollo: { @@ -112,6 +99,18 @@ export default { }, }, computed: { + infiniteScrollEnabled() { + return this.availableBranches.length > 0; + }, + branchesData() { + return this.availableBranches.map((branch) => ({ + text: branch, + extraAttrs: { + 'data-qa-selector': 'branch_menu_item_button', + }, + value: branch, + })); + }, availableBranchesVariables() { if (this.searchTerm.length > 0) { return { @@ -128,7 +127,7 @@ export default { enableBranchSwitcher() { return this.availableBranches.length > 0 || this.searchTerm.length > 0; }, - isBranchesLoading() { + areBranchesLoading() { return this.$apollo.queries.availableBranches.loading; }, }, @@ -143,7 +142,7 @@ export default { // if there is no searchPattern, paginate by {paginationLimit} branches fetchNextBranches() { if ( - this.isBranchesLoading || + this.areBranchesLoading || this.searchTerm.length > 0 || this.availableBranches.length >= this.totalBranches ) { @@ -178,16 +177,14 @@ export default { this.$emit('refetchContent'); }, selectBranch(newBranch) { - if (newBranch !== this.currentBranch) { - // If there are unsaved changes, we want to show the user - // a modal to confirm what to do with these before changing - // branches. - if (this.hasUnsavedChanges) { - this.branchSelected = newBranch; - this.$emit('select-branch', newBranch); - } else { - this.changeBranch(newBranch); - } + // If there are unsaved changes, we want to show the user + // a modal to confirm what to do with these before changing + // branches. + if (this.hasUnsavedChanges) { + this.branchSelected = newBranch; + this.$emit('select-branch', newBranch); + } else { + this.changeBranch(newBranch); } }, async setSearchTerm(newSearchTerm) { @@ -211,41 +208,23 @@ export default { </script> <template> - <gl-dropdown + <gl-collapsible-listbox + v-model="currentBranch" v-gl-tooltip.hover + data-qa-selector="branch_selector_button" + searchable + :items="branchesData" :title="$options.i18n.dropdownHeader" :header-text="$options.i18n.dropdownHeader" - :text="currentBranch" + :toggle-text="currentBranch" :disabled="!enableBranchSwitcher" icon="branch" data-testid="branch-selector" - > - <gl-search-box-by-type :debounce="$options.inputDebounce" @input="setSearchTerm" /> - <gl-dropdown-section-header> - {{ $options.i18n.title }} - </gl-dropdown-section-header> - - <gl-infinite-scroll - :fetched-items="availableBranches.length" - :max-list-height="250" - @bottomReached="fetchNextBranches" - > - <template #items> - <gl-dropdown-item - v-for="branch in availableBranches" - :key="branch" - :is-checked="currentBranch === branch" - is-check-item - @click="selectBranch(branch)" - > - {{ branch }} - </gl-dropdown-item> - </template> - <template #default> - <gl-dropdown-item v-if="isBranchesLoading" key="loading"> - <gl-loading-icon size="lg" /> - </gl-dropdown-item> - </template> - </gl-infinite-scroll> - </gl-dropdown> + :no-results-text="$options.i18n.fetchError" + :infinite-scroll-loading="areBranchesLoading" + :infinite-scroll="infiniteScrollEnabled" + @select="selectBranch" + @search="setSearchTerm" + @bottom-reached="fetchNextBranches" + /> </template> diff --git a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue index 44cf11acfe2..7c4a07e3f83 100644 --- a/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue +++ b/app/assets/javascripts/ci/pipeline_editor/components/header/pipeline_status.vue @@ -7,7 +7,7 @@ import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.quer import getPipelineEtag from '~/ci/pipeline_editor/graphql/queries/client/pipeline_etag.query.graphql'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import PipelineMiniGraph from '~/ci/pipeline_mini_graph/pipeline_mini_graph.vue'; import PipelineEditorMiniGraph from './pipeline_editor_mini_graph.vue'; @@ -25,7 +25,7 @@ export const i18n = { export default { i18n, components: { - CiBadgeLink, + CiIcon, GlButton, GlIcon, GlLink, @@ -155,14 +155,7 @@ export default { </template> <template v-else> <div class="gl-text-truncate gl-md-max-w-50p gl-mr-1"> - <a :href="status.detailsPath" class="gl-mr-auto"> - <ci-badge-link - :status="status" - size="md" - :show-text="false" - data-testid="pipeline-status-icon" - /> - </a> + <ci-icon :status="status" data-testid="pipeline-status-icon" /> <span class="gl-font-weight-bold"> <gl-sprintf :message="$options.i18n.pipelineInfo"> <template #id="{ content }"> diff --git a/app/assets/javascripts/ci/pipeline_editor/options.js b/app/assets/javascripts/ci/pipeline_editor/options.js index 922c8eee8fc..340cb6ab979 100644 --- a/app/assets/javascripts/ci/pipeline_editor/options.js +++ b/app/assets/javascripts/ci/pipeline_editor/options.js @@ -55,7 +55,6 @@ export const createAppOptions = (el) => { const apolloProvider = new VueApollo({ defaultClient: createDefaultClient(resolvers, { typeDefs, - useGet: true, }), }); const { cache } = apolloProvider.clients.defaultClient; diff --git a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue index 41e5199e204..09ba6292e13 100644 --- a/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue +++ b/app/assets/javascripts/ci/pipeline_editor/pipeline_editor_home.vue @@ -168,7 +168,7 @@ export default { @toggle-file-tree="toggleFileTree" v-on="$listeners" /> - <div class="gl-display-flex gl-w-full gl-sm-flex-direction-column"> + <div class="gl-display-flex gl-w-full gl-flex-direction-column gl-md-flex-direction-row"> <pipeline-editor-file-tree v-if="showFileTree" class="gl-flex-shrink-0" diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue index d20d4aec59d..4fded3aec60 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_job_item.vue @@ -132,7 +132,6 @@ export default { <template> <div class="ci-job-component gl-display-flex gl-align-items-center gl-justify-content-space-between" - data-qa-selector="job_item_container" > <gl-link v-if="hasDetails" diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue index 34640d49b80..ed78a335453 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/legacy_pipeline_stage.vue @@ -13,7 +13,7 @@ */ import { GlDropdown, GlLoadingIcon, GlTooltipDirective } from '@gitlab/ui'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; import eventHub from '~/ci/event_hub'; import axios from '~/lib/utils/axios_utils'; @@ -33,7 +33,7 @@ export default { positionFixed: true, }, components: { - CiBadgeLink, + CiIcon, GlLoadingIcon, GlDropdown, LegacyJobItem, @@ -126,14 +126,7 @@ export default { @show="onShowDropdown" > <template #button-content> - <ci-badge-link - :status="stage.status" - size="md" - :show-text="false" - :show-tooltip="false" - :use-link="false" - class="gl-mb-0!" - /> + <ci-icon :status="stage.status" :show-tooltip="false" :use-link="false" class="gl-mb-0!" /> </template> <div v-if="isLoading" class="gl--flex-center gl-p-2" data-testid="pipeline-stage-loading-state"> <gl-loading-icon size="sm" class="gl-mr-3" /> diff --git a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue index cc703d29e23..f6a375ab94c 100644 --- a/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue +++ b/app/assets/javascripts/ci/pipeline_mini_graph/linked_pipelines_mini_list.vue @@ -1,7 +1,7 @@ <script> import { GlTooltipDirective } from '@gitlab/ui'; import { sprintf, s__ } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { accessValue } from './accessors/linked_pipelines_accessors'; /** * Renders the upstream/downstream portions of the pipeline mini graph. @@ -11,7 +11,7 @@ export default { GlTooltip: GlTooltipDirective, }, components: { - CiBadgeLink, + CiIcon, }, inject: { dataMethod: { @@ -81,11 +81,6 @@ export default { // detailedStatus is graphQL, details.status is REST return pipeline?.detailedStatus || pipeline?.details?.status; }, - triggerButtonClass(pipeline) { - const { group } = accessValue(pipeline, this.dataMethod, 'detailedStatus'); - - return `ci-status-icon-${group}`; - }, }, }; </script> @@ -99,15 +94,12 @@ export default { }" class="linked-pipeline-mini-list gl-display-inline gl-vertical-align-middle" > - <ci-badge-link + <ci-icon v-for="pipeline in linkedPipelinesTrimmed" :key="pipeline.id" v-gl-tooltip="{ title: pipelineTooltipText(pipeline) }" :status="pipelineStatus(pipeline)" - size="md" - :show-text="false" :show-tooltip="false" - :class="triggerButtonClass(pipeline)" class="linked-pipeline-mini-item gl-mb-0!" data-testid="linked-pipeline-mini-item" /> diff --git a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue index 2f06b82bac0..722dc29d746 100644 --- a/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue +++ b/app/assets/javascripts/ci/pipeline_new/components/pipeline_new_form.vue @@ -13,9 +13,9 @@ import { GlSprintf, GlLoadingIcon, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import Vue from 'vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { fetchPolicies } from '~/lib/graphql'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { redirectTo } from '~/lib/utils/url_utility'; // eslint-disable-line import/no-deprecated diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue index cd1d9a97ef3..5444e66cbdf 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/pipeline_schedules_form.vue @@ -371,11 +371,7 @@ export default { </gl-form-group> <!--Variable List--> <gl-form-group class="gl-mb-0" :label="$options.i18n.variables"> - <div - v-for="(variable, index) in variables" - :key="`var-${index}`" - data-qa-selector="ci_variable_row_container" - > + <div v-for="(variable, index) in variables" :key="`var-${index}`"> <div v-if="!variable.destroy" class="gl-display-flex gl-align-items-stretch gl-flex-direction-column gl-md-flex-direction-row gl-mb-3 gl-pb-2" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue index ed7c2bbeb73..78df7298f4f 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_actions.vue @@ -61,6 +61,7 @@ export default { v-if="canPlay" v-gl-tooltip :title="$options.i18n.playTooltip" + :aria-label="$options.i18n.playTooltip" icon="play" data-testid="play-pipeline-schedule-btn" @click="$emit('playPipelineSchedule', schedule.id)" @@ -78,6 +79,7 @@ export default { v-gl-tooltip :href="editPathWithIdParam" :title="$options.i18n.editTooltip" + :aria-label="$options.i18n.editTooltip" icon="pencil" data-testid="edit-pipeline-schedule-btn" /> @@ -85,6 +87,7 @@ export default { v-if="canRemove" v-gl-tooltip :title="$options.i18n.deleteTooltip" + :aria-label="$options.i18n.deleteTooltip" icon="remove" variant="danger" data-testid="delete-pipeline-schedule-btn" diff --git a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue index 92f461c72d7..d979c0efaf2 100644 --- a/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue +++ b/app/assets/javascripts/ci/pipeline_schedules/components/table/cells/pipeline_schedule_last_pipeline.vue @@ -1,9 +1,9 @@ <script> -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; export default { components: { - CiBadgeLink, + CiIcon, }, props: { schedule: { @@ -24,9 +24,10 @@ export default { <template> <div data-testid="last-pipeline-status"> - <ci-badge-link + <ci-icon v-if="hasPipeline" :status="lastPipelineStatus" + show-status-text class="gl-vertical-align-middle" /> <span v-else data-testid="pipeline-schedule-status-text"> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue deleted file mode 100644 index 1a2021df9c8..00000000000 --- a/app/assets/javascripts/ci/pipelines_page/components/empty_state/ios_templates.vue +++ /dev/null @@ -1,220 +0,0 @@ -<script> -import { GlButton, GlCard, GlSprintf, GlLink, GlPopover, GlModalDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; -import { mergeUrlParams, DOCS_URL } from '~/lib/utils/url_utility'; -import RunnerInstructionsModal from '~/vue_shared/components/runner_instructions/runner_instructions_modal.vue'; -import apolloProvider from '~/ci/pipeline_details/graphql/provider'; -import CiTemplates from './ci_templates.vue'; - -export default { - components: { - GlButton, - GlCard, - GlSprintf, - GlLink, - GlPopover, - RunnerInstructionsModal, - CiTemplates, - }, - directives: { - GlModalDirective, - }, - inject: ['pipelineEditorPath', 'iosRunnersAvailable'], - props: { - registrationToken: { - type: String, - required: false, - default: null, - }, - }, - apolloProvider, - iOSTemplateName: 'iOS-Fastlane', - modalId: 'runner-instructions-modal', - runnerDocsLink: `${DOCS_URL}/runner/install/osx`, - whatElseLink: helpPagePath('ci/index.md'), - i18n: { - title: s__('Pipelines|Get started with GitLab CI/CD'), - subtitle: s__('Pipelines|Building for iOS?'), - explanation: s__("Pipelines|We'll walk you through how to deploy to iOS in two easy steps."), - runnerSetupTitle: s__('Pipelines|1. Set up a runner'), - runnerSetupButton: s__('Pipelines|Set up a runner'), - runnerSetupBodyUnfinished: s__( - 'Pipelines|GitLab Runner is an application that works with GitLab CI/CD to run jobs in a pipeline.', - ), - runnerSetupBodyFinished: s__( - 'Pipelines|You have runners available to run your job now. No need to do anything else.', - ), - runnerSetupPopoverTitle: s__( - "Pipelines|Let's get that runner set up! %{emojiStart}tada%{emojiEnd}", - ), - runnerSetupPopoverBodyLine1: s__( - 'Pipelines|Follow these instructions to install GitLab Runner on macOS.', - ), - runnerSetupPopoverBodyLine2: s__( - 'Pipelines|Need more information to set up your runner? %{linkStart}Check out our documentation%{linkEnd}.', - ), - configurePipelineTitle: s__('Pipelines|2. Configure deployment pipeline'), - configurePipelineBody: s__("Pipelines|We'll guide you through a simple pipeline set-up."), - configurePipelineButton: s__('Pipelines|Configure pipeline'), - noWalkthroughTitle: s__("Pipelines|Don't need a guide? Jump in right away with a template."), - noWalkthroughExplanation: s__('Pipelines|Based on your project, we recommend this template:'), - notBuildingForIos: s__( - "Pipelines|Not building for iOS or not what you're looking for? %{linkStart}See what else%{linkEnd} GitLab CI/CD has to offer.", - ), - }, - data() { - return { - isModalShown: false, - isPopoverShown: false, - isRunnerSetupFinished: this.iosRunnersAvailable, - popoverTarget: `${this.$options.modalId}___BV_modal_content_`, - configurePipelineLink: mergeUrlParams( - { template: this.$options.iOSTemplateName }, - this.pipelineEditorPath, - ), - }; - }, - computed: { - runnerSetupBodyText() { - return this.iosRunnersAvailable - ? this.$options.i18n.runnerSetupBodyFinished - : this.$options.i18n.runnerSetupBodyUnfinished; - }, - }, - methods: { - showModal() { - this.isModalShown = true; - }, - hideModal() { - this.togglePopover(); - this.isRunnerSetupFinished = true; - }, - togglePopover() { - this.isPopoverShown = !this.isPopoverShown; - }, - }, -}; -</script> - -<template> - <div> - <h2 class="gl-font-size-h2 gl-text-gray-900">{{ $options.i18n.title }}</h2> - <h3 class="gl-font-lg gl-text-gray-900 gl-mt-1">{{ $options.i18n.subtitle }}</h3> - <p>{{ $options.i18n.explanation }}</p> - - <div class="gl-lg-display-flex"> - <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> - <gl-card body-class="gl-display-flex gl-flex-grow-1"> - <div - class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" - > - <div> - <div class="gl-py-5"> - <gl-emoji - v-show="isRunnerSetupFinished" - class="gl-font-size-h2-xl" - data-name="white_check_mark" - data-testid="runner-setup-marked-completed" - /> - <gl-emoji - v-show="!isRunnerSetupFinished" - class="gl-font-size-h2-xl" - data-name="tools" - data-testid="runner-setup-marked-todo" - /> - </div> - <span class="gl-text-gray-800 gl-font-weight-bold"> - {{ $options.i18n.runnerSetupTitle }} - </span> - <p class="gl-font-sm gl-mt-3">{{ runnerSetupBodyText }}</p> - </div> - - <gl-button - v-if="!iosRunnersAvailable" - v-gl-modal-directive="$options.modalId" - category="primary" - variant="confirm" - @click="showModal" - > - {{ $options.i18n.runnerSetupButton }} - </gl-button> - <runner-instructions-modal - v-if="isModalShown" - :modal-id="$options.modalId" - :registration-token="registrationToken" - default-platform-name="osx" - @shown="togglePopover" - @hide="hideModal" - /> - <gl-popover - v-if="isPopoverShown" - :show="true" - :show-close-button="true" - :target="popoverTarget" - triggers="manual" - placement="left" - fallback-placement="clockwise" - > - <template #title> - <gl-sprintf :message="$options.i18n.runnerSetupPopoverTitle"> - <template #emoji="{ content }"> - <gl-emoji class="gl-ml-2" :data-name="content" /> - </template> - </gl-sprintf> - </template> - <div class="gl-mb-5"> - {{ $options.i18n.runnerSetupPopoverBodyLine1 }} - </div> - <gl-sprintf :message="$options.i18n.runnerSetupPopoverBodyLine2"> - <template #link="{ content }"> - <gl-link :href="$options.runnerDocsLink" target="_blank">{{ content }}</gl-link> - </template> - </gl-sprintf> - </gl-popover> - </div> - </gl-card> - </div> - <div class="gl-lg-display-flex gl-lg-w-25p gl-lg-pr-4 gl-mb-4"> - <gl-card body-class="gl-display-flex gl-flex-grow-1"> - <div - class="gl-display-flex gl-flex-grow-1 gl-flex-direction-column gl-justify-content-space-between gl-align-items-flex-start" - > - <div> - <div class="gl-py-5"><gl-emoji class="gl-font-size-h2-xl" data-name="tools" /></div> - <span class="gl-text-gray-800 gl-font-weight-bold"> - {{ $options.i18n.configurePipelineTitle }} - </span> - <p class="gl-font-sm gl-mt-3">{{ $options.i18n.configurePipelineBody }}</p> - </div> - - <gl-button - :disabled="!isRunnerSetupFinished" - category="primary" - variant="confirm" - data-testid="configure-pipeline-link" - :href="configurePipelineLink" - > - {{ $options.i18n.configurePipelineButton }} - </gl-button> - </div> - </gl-card> - </div> - </div> - <h3 class="gl-font-lg gl-text-gray-900 gl-mt-5">{{ $options.i18n.noWalkthroughTitle }}</h3> - <p>{{ $options.i18n.noWalkthroughExplanation }}</p> - <ci-templates - :filter-templates="/* eslint-disable @gitlab/vue-no-new-non-primitive-in-template */ [ - $options.iOSTemplateName, - ] /* eslint-enable @gitlab/vue-no-new-non-primitive-in-template */" - :disabled="!isRunnerSetupFinished" - /> - <p> - <gl-sprintf :message="$options.i18n.notBuildingForIos"> - <template #link="{ content }"> - <gl-link :href="$options.whatElseLink">{{ content }}</gl-link> - </template> - </gl-sprintf> - </p> - </div> -</template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue index 728e8541ae3..aed5f1d235d 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/empty_state/no_ci_empty_state.vue @@ -1,9 +1,7 @@ <script> import { GlEmptyState } from '@gitlab/ui'; import { s__ } from '~/locale'; -import GitlabExperiment from '~/experimentation/components/gitlab_experiment.vue'; import PipelinesCiTemplates from './pipelines_ci_templates.vue'; -import IosTemplates from './ios_templates.vue'; export default { i18n: { @@ -12,9 +10,7 @@ export default { name: 'PipelinesEmptyState', components: { GlEmptyState, - GitlabExperiment, PipelinesCiTemplates, - IosTemplates, }, props: { emptyStateSvgPath: { @@ -25,30 +21,15 @@ export default { type: Boolean, required: true, }, - registrationToken: { - type: String, - required: false, - default: null, - }, }, }; </script> <template> - <div> - <gitlab-experiment v-if="canSetCi" name="ios_specific_templates"> - <template #control> - <pipelines-ci-templates /> - </template> - <template #candidate> - <ios-templates :registration-token="registrationToken" /> - </template> - </gitlab-experiment> - <gl-empty-state - v-else - title="" - :svg-path="emptyStateSvgPath" - :svg-height="null" - :description="$options.i18n.noCiDescription" - /> - </div> + <pipelines-ci-templates v-if="canSetCi" /> + <gl-empty-state + v-else + :svg-path="emptyStateSvgPath" + :svg-height="null" + :description="$options.i18n.noCiDescription" + /> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue index 8f45094eb74..31d8f207a63 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_labels.vue @@ -1,7 +1,7 @@ <script> import { GlLink, GlPopover, GlSprintf, GlTooltipDirective, GlBadge } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { SCHEDULE_ORIGIN } from '~/ci/pipeline_details/constants'; +import { SCHEDULE_ORIGIN, API_ORIGIN, TRIGGER_ORIGIN } from '../constants'; export default { components: { @@ -31,6 +31,9 @@ export default { isScheduled() { return this.pipeline.source === SCHEDULE_ORIGIN; }, + isTriggered() { + return this.pipeline.source === TRIGGER_ORIGIN; + }, isInFork() { return Boolean( this.targetProjectFullPath && @@ -50,6 +53,9 @@ export default { autoDevopsHelpPath() { return helpPagePath('topics/autodevops/index.md'); }, + isApi() { + return this.pipeline.source === API_ORIGIN; + }, }, }; </script> @@ -64,7 +70,16 @@ export default { variant="info" size="sm" data-testid="pipeline-url-scheduled" - >{{ __('Scheduled') }}</gl-badge + >{{ __('scheduled') }}</gl-badge + > + <gl-badge + v-if="isTriggered" + v-gl-tooltip + :title="__('This pipeline was created by an API call authenticated with a trigger token')" + variant="info" + size="sm" + data-testid="pipeline-url-triggered" + >{{ __('trigger token') }}</gl-badge > <gl-badge v-if="pipeline.flags.latest" @@ -185,5 +200,14 @@ export default { data-testid="pipeline-url-fork" >{{ __('fork') }}</gl-badge > + <gl-badge + v-if="isApi" + v-gl-tooltip + :title="__('This pipeline was triggered using the api')" + variant="info" + size="sm" + data-testid="pipeline-api-badge" + >{{ s__('Pipeline|api') }}</gl-badge + > </div> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue index 20e2c7e9dce..380f8ce172f 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_status_badge.vue @@ -1,12 +1,12 @@ <script> import { TRACKING_CATEGORIES } from '~/ci/constants'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import Tracking from '~/tracking'; import PipelinesTimeago from './time_ago.vue'; export default { components: { - CiBadgeLink, + CiIcon, PipelinesTimeago, }, mixins: [Tracking.mixin()], @@ -31,7 +31,12 @@ export default { <template> <div> - <ci-badge-link class="gl-mb-3" :status="pipelineStatus" @ciStatusBadgeClick="trackClick" /> + <ci-icon + class="gl-mb-2" + :status="pipelineStatus" + show-status-text + @ciStatusBadgeClick="trackClick" + /> <pipelines-timeago :pipeline="pipeline" /> </div> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue index 2a73795db0a..a53c7cacae2 100644 --- a/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue +++ b/app/assets/javascripts/ci/pipelines_page/components/pipeline_triggerer.vue @@ -29,9 +29,5 @@ export default { <gl-avatar-link v-if="user" v-gl-tooltip :href="user.path" :title="user.name" class="gl-ml-3"> <gl-avatar :size="32" :src="user.avatar_url" /> </gl-avatar-link> - - <span v-else class="gl-ml-3"> - {{ s__('Pipelines|API') }} - </span> </div> </template> diff --git a/app/assets/javascripts/ci/pipelines_page/constants.js b/app/assets/javascripts/ci/pipelines_page/constants.js index aa6ef8a25ee..438eda44afe 100644 --- a/app/assets/javascripts/ci/pipelines_page/constants.js +++ b/app/assets/javascripts/ci/pipelines_page/constants.js @@ -1,2 +1,5 @@ export const ANY_TRIGGER_AUTHOR = 'Any'; export const FILTER_PIPELINES_SEARCH_DELAY = 200; +export const SCHEDULE_ORIGIN = 'schedule'; +export const API_ORIGIN = 'api'; +export const TRIGGER_ORIGIN = 'trigger'; diff --git a/app/assets/javascripts/ci/pipelines_page/pipelines.vue b/app/assets/javascripts/ci/pipelines_page/pipelines.vue index faa013079be..98e005a162f 100644 --- a/app/assets/javascripts/ci/pipelines_page/pipelines.vue +++ b/app/assets/javascripts/ci/pipelines_page/pipelines.vue @@ -4,7 +4,7 @@ import NO_PIPELINES_SVG from '@gitlab/svgs/dist/illustrations/empty-state/empty- import ERROR_STATE_SVG from '@gitlab/svgs/dist/illustrations/pipelines_failed.svg?url'; import { GlEmptyState, GlIcon, GlLoadingIcon, GlCollapsibleListbox } from '@gitlab/ui'; import { isEqual } from 'lodash'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert, VARIANT_INFO, VARIANT_WARNING } from '~/alert'; import { getParameterByName } from '~/lib/utils/url_utility'; import { __, s__ } from '~/locale'; @@ -88,11 +88,6 @@ export default { type: Object, required: true, }, - registrationToken: { - type: String, - required: false, - default: null, - }, defaultVisibilityPipelineIdType: { type: String, required: false, @@ -311,6 +306,12 @@ export default { }, changeVisibilityPipelineIDType(idType) { this.visibilityPipelineIdType = idType; + if (idType === PIPELINE_IID_KEY) { + this.track('pipelines_display_options', { + label: TRACKING_CATEGORIES.listbox, + property: idType, + }); + } if (isLoggedIn()) { this.saveVisibilityPipelineIDType(idType); @@ -404,7 +405,6 @@ export default { v-else-if="stateToRender === $options.stateMap.emptyState" :empty-state-svg-path="$options.noPipelinesSvgPath" :can-set-ci="canCreatePipeline" - :registration-token="registrationToken" /> <gl-empty-state diff --git a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue index f0a41a5949e..97163c1f55c 100644 --- a/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/admin_new_runner/admin_new_runner_app.vue @@ -4,7 +4,6 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; -import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, INSTANCE_TYPE } from '../constants'; @@ -14,7 +13,6 @@ export default { name: 'AdminNewRunnerApp', components: { RegistrationCompatibilityAlert, - RegistrationFeedbackBanner, RunnerPlatformsRadioGroup, RunnerCreateForm, }, @@ -44,8 +42,6 @@ export default { <template> <div> - <registration-feedback-banner /> - <h1 class="gl-font-size-h2">{{ s__('Runners|New instance runner') }}</h1> <registration-compatibility-alert :alert-key="$options.INSTANCE_TYPE" /> diff --git a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue index 0ec94dc865f..1431f156c0e 100644 --- a/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue +++ b/app/assets/javascripts/ci/runner/admin_runners/admin_runners_app.vue @@ -14,6 +14,7 @@ import { import allRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners.query.graphql'; import allRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/all_runners_count.query.graphql'; +import RunnerListHeader from '../components/runner_list_header.vue'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; @@ -28,6 +29,7 @@ import RunnerJobStatusBadge from '../components/runner_job_status_badge.vue'; import { pausedTokenConfig } from '../components/search_tokens/paused_token_config'; import { statusTokenConfig } from '../components/search_tokens/status_token_config'; import { tagTokenConfig } from '../components/search_tokens/tag_token_config'; +import { versionTokenConfig } from '../components/search_tokens/version_token_config'; import { ADMIN_FILTERED_SEARCH_NAMESPACE, INSTANCE_TYPE, @@ -42,6 +44,7 @@ export default { components: { GlButton, GlLink, + RunnerListHeader, RegistrationDropdown, RunnerFilteredSearchBar, RunnerList, @@ -78,9 +81,6 @@ export default { apollo: { runners: { query: allRunnersQuery, - context: { - isSingleRequest: true, - }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -118,6 +118,7 @@ export default { return [ pausedTokenConfig, statusTokenConfig, + versionTokenConfig, { ...tagTokenConfig, recentSuggestionsStorageKey: `${this.$options.filteredSearchNamespace}-recent-tags`, @@ -178,11 +179,9 @@ export default { </script> <template> <div> - <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> - <h2 class="gl-my-0 header-title"> - {{ s__('Runners|Runners') }} - </h2> - <div class="gl-display-flex gl-gap-3"> + <runner-list-header> + <template #title>{{ s__('Runners|Runners') }}</template> + <template #actions> <runner-dashboard-link /> <gl-button :href="newRunnerPath" variant="confirm"> {{ s__('Runners|New instance runner') }} @@ -192,8 +191,9 @@ export default { :type="$options.INSTANCE_TYPE" placement="right" /> - </div> - </header> + </template> + </runner-list-header> + <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > diff --git a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue index a80d6207be8..8a920c85e06 100644 --- a/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue +++ b/app/assets/javascripts/ci/runner/components/cells/runner_summary_cell.vue @@ -2,9 +2,9 @@ import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { sprintf, __, formatNumber } from '~/locale'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate/tooltip_on_truncate.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import RunnerCreatedAt from '../runner_created_at.vue'; import RunnerName from '../runner_name.vue'; import RunnerTags from '../runner_tags.vue'; import RunnerTypeBadge from '../runner_type_badge.vue'; @@ -15,8 +15,6 @@ import { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, - I18N_CREATED_AT_LABEL, - I18N_CREATED_AT_BY_LABEL, } from '../../constants'; import RunnerSummaryField from './runner_summary_field.vue'; @@ -26,13 +24,13 @@ export default { GlSprintf, TimeAgo, RunnerSummaryField, + RunnerCreatedAt, RunnerName, RunnerTags, RunnerTypeBadge, RunnerManagersBadge, RunnerUpgradeStatusIcon: () => import('ee_component/ci/runner/components/runner_upgrade_status_icon.vue'), - UserAvatarLink, TooltipOnTruncate, }, directives: { @@ -75,8 +73,6 @@ export default { I18N_LOCKED_RUNNER_DESCRIPTION, I18N_VERSION_LABEL, I18N_LAST_CONTACT_LABEL, - I18N_CREATED_AT_LABEL, - I18N_CREATED_AT_BY_LABEL, }, }; </script> @@ -143,30 +139,7 @@ export default { </runner-summary-field> <runner-summary-field icon="calendar"> - <template v-if="createdBy"> - <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_BY_LABEL"> - <template #timeAgo> - <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> - </template> - <template #avatar> - <user-avatar-link - :link-href="createdBy.webUrl" - :img-src="createdBy.avatarUrl" - img-css-classes="gl-vertical-align-top" - :img-size="16" - :img-alt="createdByImgAlt" - :tooltip-text="createdBy.username" - /> - </template> - </gl-sprintf> - </template> - <template v-else> - <gl-sprintf :message="$options.i18n.I18N_CREATED_AT_LABEL"> - <template #timeAgo> - <time-ago v-if="runner.createdAt" :time="runner.createdAt" /> - </template> - </gl-sprintf> - </template> + <runner-created-at :runner="runner" /> </runner-summary-field> </div> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue b/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue deleted file mode 100644 index 6fd4edf5847..00000000000 --- a/app/assets/javascripts/ci/runner/components/registration/registration_feedback_banner.vue +++ /dev/null @@ -1,41 +0,0 @@ -<script> -import ILLUSTRATION_URL from '@gitlab/svgs/dist/illustrations/rocket-launch-md.svg?url'; -import { GlBanner } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; - -const FEEDBACK_ISSUE_URL = 'https://gitlab.com/gitlab-org/gitlab/-/issues/387993'; - -export default { - components: { - GlBanner, - UserCalloutDismisser, - }, - i18n: { - title: s__("Runners|We've made some changes and want your feedback"), - body: s__( - "Runners|We've been making improvements to how you register runners so that it's more secure and efficient. Tell us how we're doing.", - ), - button: s__('Runners|Add your feedback to this issue'), - }, - ILLUSTRATION_URL, - FEEDBACK_ISSUE_URL, -}; -</script> -<template> - <user-callout-dismisser feature-name="create_runner_workflow_banner"> - <template #default="{ dismiss, shouldShowCallout }"> - <gl-banner - v-if="shouldShowCallout" - class="gl-my-6" - :title="$options.i18n.title" - :svg-path="$options.ILLUSTRATION_URL" - :button-text="$options.i18n.button" - :button-link="$options.FEEDBACK_ISSUE_URL" - @close="dismiss" - > - <p>{{ $options.i18n.body }}</p> - </gl-banner> - </template> - </user-callout-dismisser> -</template> diff --git a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue index 771ecb1a0d4..a4dec8199a3 100644 --- a/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue +++ b/app/assets/javascripts/ci/runner/components/registration/registration_instructions.vue @@ -100,11 +100,11 @@ export default { tokenMessage() { if (this.token) { return s__( - 'Runners|The %{boldStart}runner token%{boldEnd} %{token} displays %{boldStart}only for a short time%{boldEnd}, and is stored in the %{codeStart}config.toml%{codeEnd} after you register the runner. It will not be visible once the runner is registered.', + 'Runners|The %{boldStart}runner authentication token%{boldEnd} %{token} displays here %{boldStart}for a short time only%{boldEnd}. After you register the runner, this token is stored in the %{codeStart}config.toml%{codeEnd} and cannot be accessed again from the UI.', ); } return s__( - 'Runners|The %{boldStart}runner token%{boldEnd} is no longer visible, it is stored in the %{codeStart}config.toml%{codeEnd} if you have registered the runner.', + 'Runners|The %{boldStart}runner authentication token%{boldEnd} is no longer visible, it is stored in the %{codeStart}config.toml%{codeEnd} if you have registered the runner.', ); }, commandPrompt() { diff --git a/app/assets/javascripts/ci/runner/components/runner_created_at.vue b/app/assets/javascripts/ci/runner/components/runner_created_at.vue new file mode 100644 index 00000000000..410142a0eb5 --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_created_at.vue @@ -0,0 +1,72 @@ +<script> +import { GlSprintf, GlLink } from '@gitlab/ui'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; + +import { + I18N_CREATED_AT_LABEL, + I18N_CREATED_BY_LABEL, + I18N_CREATED_BY_AT_LABEL, +} from '../constants'; + +export default { + components: { + GlSprintf, + GlLink, + TimeAgo, + }, + props: { + runner: { + type: Object, + required: true, + }, + }, + computed: { + createdAt() { + return this.runner?.createdAt; + }, + createdBy() { + return this.runner?.createdBy; + }, + createdById() { + if (this.createdBy?.id) { + return getIdFromGraphQLId(this.createdBy.id); + } + return null; + }, + message() { + if (this.createdBy && this.createdAt) { + return I18N_CREATED_BY_AT_LABEL; + } + if (this.createdBy) { + return I18N_CREATED_BY_LABEL; + } + if (this.createdAt) { + return I18N_CREATED_AT_LABEL; + } + + return null; + }, + }, +}; +</script> +<template> + <span v-if="message"> + <gl-sprintf :message="message"> + <template #timeAgo> + <time-ago v-if="createdAt" :time="createdAt" /> + </template> + <template #user> + <gl-link + class="js-user-link gl-reset-color gl-font-weight-bold" + :href="createdBy.webUrl" + :data-user-id="createdById" + :data-username="createdBy.username" + :data-name="createdBy.name" + :data-avatar-url="createdBy.avatarUrl" + >{{ createdBy.name }}</gl-link + > + </template> + </gl-sprintf> + </span> +</template> diff --git a/app/assets/javascripts/ci/runner/components/runner_details.vue b/app/assets/javascripts/ci/runner/components/runner_details.vue index 0ec2ef30c20..477d28c6c28 100644 --- a/app/assets/javascripts/ci/runner/components/runner_details.vue +++ b/app/assets/javascripts/ci/runner/components/runner_details.vue @@ -120,12 +120,9 @@ export default { }} </p> <p class="gl-mb-0"> - <gl-link - :href="tokenExpirationHelpUrl" - target="_blank" - class="gl-reset-font-size" - >{{ __('Learn more') }}</gl-link - > + <gl-link :href="tokenExpirationHelpUrl" target="_blank">{{ + __('Learn more') + }}</gl-link> </p> </help-popover> </template> @@ -156,12 +153,9 @@ export default { " > <template #link="{ content }" - ><gl-link - :href="$options.RUNNER_MANAGERS_HELP_URL" - target="_blank" - class="gl-reset-font-size" - >{{ content }}</gl-link - ></template + ><gl-link :href="$options.RUNNER_MANAGERS_HELP_URL" target="_blank">{{ + content + }}</gl-link></template > </gl-sprintf> </help-popover> diff --git a/app/assets/javascripts/ci/runner/components/runner_header.vue b/app/assets/javascripts/ci/runner/components/runner_header.vue index 0fa06537ed6..f8d0352e532 100644 --- a/app/assets/javascripts/ci/runner/components/runner_header.vue +++ b/app/assets/javascripts/ci/runner/components/runner_header.vue @@ -1,16 +1,15 @@ <script> -import { GlIcon, GlSprintf, GlTooltipDirective } from '@gitlab/ui'; -import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { I18N_LOCKED_RUNNER_DESCRIPTION } from '../constants'; import { formatRunnerName } from '../utils'; +import RunnerCreatedAt from './runner_created_at.vue'; import RunnerTypeBadge from './runner_type_badge.vue'; import RunnerStatusBadge from './runner_status_badge.vue'; export default { components: { GlIcon, - GlSprintf, - TimeAgo, + RunnerCreatedAt, RunnerTypeBadge, RunnerStatusBadge, RunnerUpgradeStatusBadge: () => @@ -43,21 +42,13 @@ export default { <runner-status-badge :contacted-at="runner.contactedAt" :status="runner.status" /> <runner-type-badge :type="runner.runnerType" /> <runner-upgrade-status-badge :runner="runner" /> - <span v-if="runner.createdAt"> - <gl-sprintf :message="__('%{locked} created %{timeago}')"> - <template #locked> - <gl-icon - v-if="runner.locked" - v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - name="lock" - :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" - /> - </template> - <template #timeago> - <time-ago :time="runner.createdAt" /> - </template> - </gl-sprintf> - </span> + <gl-icon + v-if="runner.locked" + v-gl-tooltip="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + name="lock" + :aria-label="$options.I18N_LOCKED_RUNNER_DESCRIPTION" + /> + <runner-created-at :runner="runner" /> </div> </div> </template> diff --git a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue index 5d8e9dcdee2..653d9b05330 100644 --- a/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue +++ b/app/assets/javascripts/ci/runner/components/runner_jobs_table.vue @@ -3,7 +3,7 @@ import { GlTableLite } from '@gitlab/ui'; import { __, s__ } from '~/locale'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { formatTime } from '~/lib/utils/datetime_utility'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import { tableField } from '../utils'; @@ -11,7 +11,7 @@ import LinkCell from './cells/link_cell.vue'; export default { components: { - CiBadgeLink, + CiIcon, GlTableLite, LinkCell, RunnerTags, @@ -80,7 +80,7 @@ export default { fixed > <template #cell(status)="{ item = {} }"> - <ci-badge-link v-if="item.detailedStatus" :status="item.detailedStatus" /> + <ci-icon v-if="item.detailedStatus" :status="item.detailedStatus" show-status-text /> </template> <template #cell(job)="{ item = {} }"> diff --git a/app/assets/javascripts/ci/runner/components/runner_list_header.vue b/app/assets/javascripts/ci/runner/components/runner_list_header.vue new file mode 100644 index 00000000000..e4367db035e --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/runner_list_header.vue @@ -0,0 +1,17 @@ +<script> +export default { + name: 'RunnerListHeader', +}; +</script> +<template> + <header + class="gl-my-5 gl-display-flex gl-align-items-flex-start gl-flex-wrap gl-justify-content-space-between" + > + <h1 v-if="$scopedSlots.title" class="gl-my-0 gl-font-size-h1 header-title"> + <slot name="title"></slot> + </h1> + <div v-if="$scopedSlots.actions" class="gl-display-flex gl-gap-3"> + <slot name="actions"></slot> + </div> + </header> +</template> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue index dd1cca0a05c..1f61e878eb0 100644 --- a/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue +++ b/app/assets/javascripts/ci/runner/components/search_tokens/tag_token.vue @@ -70,7 +70,7 @@ export default { @fetch-suggestions="fetchTags" v-on="$listeners" > - <template #view-token="{ viewTokenProps: { listeners, inputValue, activeTokenValue } }"> + <template #view-token="{ viewTokenProps: { listeners = {}, inputValue, activeTokenValue } }"> <gl-token variant="search-value" :class="$options.RUNNER_TAG_BG_CLASS" v-on="listeners"> {{ activeTokenValue ? activeTokenValue.text : inputValue }} </gl-token> diff --git a/app/assets/javascripts/ci/runner/components/search_tokens/version_token_config.js b/app/assets/javascripts/ci/runner/components/search_tokens/version_token_config.js new file mode 100644 index 00000000000..23f82d06f6d --- /dev/null +++ b/app/assets/javascripts/ci/runner/components/search_tokens/version_token_config.js @@ -0,0 +1,12 @@ +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; +import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; +import { PARAM_KEY_VERSION, I18N_VERSION } from '../../constants'; + +export const versionTokenConfig = { + icon: 'doc-versions', + title: I18N_VERSION, + type: PARAM_KEY_VERSION, + token: BaseToken, + operators: OPERATORS_IS, + suggestionsDisabled: true, +}; diff --git a/app/assets/javascripts/ci/runner/constants.js b/app/assets/javascripts/ci/runner/constants.js index b3cc295f8e4..d04d75b6e75 100644 --- a/app/assets/javascripts/ci/runner/constants.js +++ b/app/assets/javascripts/ci/runner/constants.js @@ -99,10 +99,14 @@ export const I18N_DELETED_TOAST = s__('Runners|Runner %{name} was deleted'); export const I18N_LOCKED_RUNNER_DESCRIPTION = s__( 'Runners|Runner is locked and available for currently assigned projects only. Only administrators can change the assigned projects.', ); +export const I18N_VERSION = s__('Runners|Version starts with'); export const I18N_VERSION_LABEL = s__('Runners|Version %{version}'); export const I18N_LAST_CONTACT_LABEL = s__('Runners|Last contact: %{timeAgo}'); + export const I18N_CREATED_AT_LABEL = s__('Runners|Created %{timeAgo}'); -export const I18N_CREATED_AT_BY_LABEL = s__('Runners|Created %{timeAgo} by %{avatar}'); +export const I18N_CREATED_BY_LABEL = s__('Runners|Created by %{user}'); +export const I18N_CREATED_BY_AT_LABEL = s__('Runners|Created by %{user} %{timeAgo}'); + export const I18N_SHOW_ONLY_INHERITED = s__('Runners|Show only inherited'); export const I18N_ADMIN = s__('Runners|Administrator'); @@ -154,6 +158,7 @@ export const PARAM_KEY_STATUS = 'status'; export const PARAM_KEY_PAUSED = 'paused'; export const PARAM_KEY_RUNNER_TYPE = 'runner_type'; export const PARAM_KEY_TAG = 'tag'; +export const PARAM_KEY_VERSION = 'version_prefix'; export const PARAM_KEY_SEARCH = 'search'; export const PARAM_KEY_MEMBERSHIP = 'membership'; diff --git a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql index 41ec9967d90..5aa96f42b04 100644 --- a/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/edit/runner_fields_shared.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment RunnerFieldsShared on CiRunner { id shortSha @@ -10,5 +12,8 @@ fragment RunnerFieldsShared on CiRunner { maximumTimeout tagList createdAt + createdBy { + ...User + } status } diff --git a/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql index 15401c25c64..628ebfd2029 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners.query.graphql @@ -10,6 +10,7 @@ query getAllRunners( $type: CiRunnerType $tagList: [String!] $search: String + $versionPrefix: String $sort: CiRunnerSort ) { runners( @@ -22,6 +23,7 @@ query getAllRunners( type: $type tagList: $tagList search: $search + versionPrefix: $versionPrefix sort: $sort ) { ...AllRunnersConnection diff --git a/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql index 82591b88d3e..18f587495b0 100644 --- a/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/list/all_runners_count.query.graphql @@ -4,8 +4,16 @@ query getAllRunnersCount( $type: CiRunnerType $tagList: [String!] $search: String + $versionPrefix: String ) { - runners(paused: $paused, status: $status, type: $type, tagList: $tagList, search: $search) { + runners( + paused: $paused + status: $status + type: $type + tagList: $tagList + search: $search + versionPrefix: $versionPrefix + ) { count } } diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql index e2c890b3834..8f998ab42fa 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_details_shared.fragment.graphql @@ -1,3 +1,5 @@ +#import "~/graphql_shared/fragments/user.fragment.graphql" + fragment RunnerDetailsShared on CiRunner { id shortSha @@ -11,6 +13,9 @@ fragment RunnerDetailsShared on CiRunner { jobCount tagList createdAt + createdBy { + ...User + } status contactedAt tokenExpiresAt diff --git a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql index b6d6996a857..611de43b995 100644 --- a/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql +++ b/app/assets/javascripts/ci/runner/graphql/show/runner_jobs.query.graphql @@ -8,7 +8,7 @@ query getRunnerJobs($id: CiRunnerID!, $first: Int, $last: Int, $before: String, nodes { id detailedStatus { - # fields for `<ci-badge-link>` + # fields for `<ci-icon>` id detailsPath group diff --git a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue index 2e1706ddae9..c907f9c8982 100644 --- a/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/group_new_runner/group_new_runner_app.vue @@ -4,7 +4,6 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; -import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { DEFAULT_PLATFORM, GROUP_TYPE, PARAM_KEY_PLATFORM } from '../constants'; @@ -14,7 +13,6 @@ export default { name: 'GroupNewRunnerApp', components: { RegistrationCompatibilityAlert, - RegistrationFeedbackBanner, RunnerPlatformsRadioGroup, RunnerCreateForm, }, @@ -50,8 +48,6 @@ export default { <template> <div> - <registration-feedback-banner /> - <h1 class="gl-font-size-h2">{{ s__('Runners|New group runner') }}</h1> <registration-compatibility-alert :alert-key="groupId" /> diff --git a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue index dcaf8635f5c..b5042936b1e 100644 --- a/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue +++ b/app/assets/javascripts/ci/runner/group_runners/group_runners_app.vue @@ -14,6 +14,7 @@ import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import groupRunnersCountQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners_count.query.graphql'; import groupRunnersQuery from 'ee_else_ce/ci/runner/graphql/list/group_runners.query.graphql'; +import RunnerListHeader from '../components/runner_list_header.vue'; import RegistrationDropdown from '../components/registration/registration_dropdown.vue'; import RunnerFilteredSearchBar from '../components/runner_filtered_search_bar.vue'; import RunnerList from '../components/runner_list.vue'; @@ -44,6 +45,7 @@ export default { components: { GlButton, GlLink, + RunnerListHeader, RegistrationDropdown, RunnerFilteredSearchBar, RunnerList, @@ -86,9 +88,6 @@ export default { apollo: { runners: { query: groupRunnersQuery, - context: { - isSingleRequest: true, - }, fetchPolicy: fetchPolicies.NETWORK_ONLY, variables() { return this.variables; @@ -212,11 +211,9 @@ export default { <template> <div> - <header class="gl-my-5 gl-display-flex gl-justify-content-space-between"> - <h2 class="gl-my-0 header-title"> - {{ s__('Runners|Runners') }} - </h2> - <div class="gl-display-flex gl-gap-3"> + <runner-list-header> + <template #title>{{ s__('Runners|Runners') }}</template> + <template #actions> <gl-button v-if="newRunnerPath" :href="newRunnerPath" @@ -231,8 +228,9 @@ export default { :type="$options.GROUP_TYPE" placement="right" /> - </div> - </header> + </template> + </runner-list-header> + <div class="gl-display-flex gl-align-items-center gl-flex-direction-column-reverse gl-md-flex-direction-row gl-mt-3 gl-md-mt-0" > diff --git a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue index 51f5a9ce8d9..241479a8c98 100644 --- a/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue +++ b/app/assets/javascripts/ci/runner/project_new_runner/project_new_runner_app.vue @@ -4,7 +4,6 @@ import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; import { s__ } from '~/locale'; import RegistrationCompatibilityAlert from '~/ci/runner/components/registration/registration_compatibility_alert.vue'; -import RegistrationFeedbackBanner from '~/ci/runner/components/registration/registration_feedback_banner.vue'; import RunnerPlatformsRadioGroup from '~/ci/runner/components/runner_platforms_radio_group.vue'; import RunnerCreateForm from '~/ci/runner/components/runner_create_form.vue'; import { DEFAULT_PLATFORM, PARAM_KEY_PLATFORM, PROJECT_TYPE } from '../constants'; @@ -14,7 +13,6 @@ export default { name: 'ProjectNewRunnerApp', components: { RegistrationCompatibilityAlert, - RegistrationFeedbackBanner, RunnerPlatformsRadioGroup, RunnerCreateForm, }, @@ -50,8 +48,6 @@ export default { <template> <div> - <registration-feedback-banner /> - <h1 class="gl-font-size-h2">{{ s__('Runners|New project runner') }}</h1> <registration-compatibility-alert :alert-key="projectId" /> diff --git a/app/assets/javascripts/ci/runner/runner_search_utils.js b/app/assets/javascripts/ci/runner/runner_search_utils.js index 8915198350f..e3aee15f42c 100644 --- a/app/assets/javascripts/ci/runner/runner_search_utils.js +++ b/app/assets/javascripts/ci/runner/runner_search_utils.js @@ -12,6 +12,7 @@ import { PARAM_KEY_STATUS, PARAM_KEY_RUNNER_TYPE, PARAM_KEY_TAG, + PARAM_KEY_VERSION, PARAM_KEY_SEARCH, PARAM_KEY_MEMBERSHIP, PARAM_KEY_SORT, @@ -151,7 +152,12 @@ export const fromUrlQueryToSearch = (query = window.location.search) => { membership: membership || DEFAULT_MEMBERSHIP, filters: prepareTokens( urlQueryToFilter(query, { - filterNamesAllowList: [PARAM_KEY_PAUSED, PARAM_KEY_STATUS, PARAM_KEY_TAG], + filterNamesAllowList: [ + PARAM_KEY_PAUSED, + PARAM_KEY_STATUS, + PARAM_KEY_TAG, + PARAM_KEY_VERSION, + ], filteredSearchTermKey: PARAM_KEY_SEARCH, }), ), @@ -178,6 +184,7 @@ export const fromSearchToUrl = ( [PARAM_KEY_MEMBERSHIP]: [], [PARAM_KEY_TAG]: [], [PARAM_KEY_PAUSED]: [], + [PARAM_KEY_VERSION]: [], // Current filters ...filterToQueryObject(processFilters(filters), { filteredSearchTermKey: PARAM_KEY_SEARCH, @@ -229,6 +236,7 @@ export const fromSearchToVariables = ({ [filterVariables.status] = queryObj[PARAM_KEY_STATUS] || []; filterVariables.search = queryObj[PARAM_KEY_SEARCH]; filterVariables.tagList = queryObj[PARAM_KEY_TAG]; + [filterVariables.versionPrefix] = queryObj[PARAM_KEY_VERSION] || []; if (queryObj[PARAM_KEY_PAUSED]) { filterVariables.paused = parseBoolean(queryObj[PARAM_KEY_PAUSED]); diff --git a/app/assets/javascripts/ci/runner/sentry_utils.js b/app/assets/javascripts/ci/runner/sentry_utils.js index 25fecdcfa7d..01a20880e0a 100644 --- a/app/assets/javascripts/ci/runner/sentry_utils.js +++ b/app/assets/javascripts/ci/runner/sentry_utils.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; const COMPONENT_TAG = 'vue_component'; diff --git a/app/assets/javascripts/ci/utils.js b/app/assets/javascripts/ci/utils.js index 8a4f28404c6..21361aedb9d 100644 --- a/app/assets/javascripts/ci/utils.js +++ b/app/assets/javascripts/ci/utils.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; export const reportToSentry = (component, failureType) => { Sentry.captureException(failureType, { diff --git a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue index 509bdabdd9e..fb2e24e15f6 100644 --- a/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue +++ b/app/assets/javascripts/ci_secure_files/components/secure_files_list.vue @@ -12,7 +12,7 @@ import { GlTable, GlTooltipDirective, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import { HTTP_STATUS_PAYLOAD_TOO_LARGE } from '~/lib/utils/http_status'; import { __, s__, sprintf } from '~/locale'; diff --git a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue index 0871d543d46..e1f6006fedf 100644 --- a/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue +++ b/app/assets/javascripts/ci_settings_pipeline_triggers/components/triggers_list.vue @@ -180,17 +180,11 @@ export default { data-confirm-btn-variant="danger" rel="nofollow" data-testid="trigger_revoke_button" - data-qa-selector="trigger_revoke_button" :href="item.projectTriggerPath" /> </template> </gl-table> - <div - v-else - class="gl-new-card-empty gl-px-5 gl-py-4" - data-testid="no_triggers_content" - data-qa-selector="no_triggers_content" - > + <div v-else class="gl-new-card-empty gl-px-5 gl-py-4" data-testid="no_triggers_content"> {{ s__('Pipelines|No triggers have been created yet. Add one using the form above.') }} </div> </div> diff --git a/app/assets/javascripts/clusters_list/store/actions.js b/app/assets/javascripts/clusters_list/store/actions.js index 4537fd51fcf..f474e51622a 100644 --- a/app/assets/javascripts/clusters_list/store/actions.js +++ b/app/assets/javascripts/clusters_list/store/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; diff --git a/app/assets/javascripts/commons/gitlab_ui.js b/app/assets/javascripts/commons/gitlab_ui.js index 4a9f79460da..fe6142ae145 100644 --- a/app/assets/javascripts/commons/gitlab_ui.js +++ b/app/assets/javascripts/commons/gitlab_ui.js @@ -5,6 +5,8 @@ applyGitLabUIConfig({ translations: { 'GlSearchBoxByType.input.placeholder': __('Search'), 'GlSearchBoxByType.clearButtonTitle': __('Clear'), + 'GlSorting.sortAscending': __('Sort direction: Ascending'), + 'GlSorting.sortDescending': __('Sort direction: Descending'), 'ClearIconButton.title': __('Clear'), }, }); diff --git a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue index 6535d9eaa5d..b34ebe85eb4 100644 --- a/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue +++ b/app/assets/javascripts/content_editor/components/suggestions_dropdown.vue @@ -161,6 +161,8 @@ export default { }, onKeyDown({ event }) { + if (!this.items.length) return false; + if (event.key === 'ArrowUp') { this.upHandler(); return true; diff --git a/app/assets/javascripts/content_editor/content_editor.stories.js b/app/assets/javascripts/content_editor/content_editor.stories.js index 1aa6568848f..6f7e9653e6e 100644 --- a/app/assets/javascripts/content_editor/content_editor.stories.js +++ b/app/assets/javascripts/content_editor/content_editor.stories.js @@ -30,4 +30,5 @@ Default.args = { serializerConfig: {}, extensions: [], enableAutocomplete: false, + markdownDocsPath: 'fake/path', }; diff --git a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js index 8917417e55e..da5ac7eb158 100644 --- a/app/assets/javascripts/content_editor/extensions/code_block_highlight.js +++ b/app/assets/javascripts/content_editor/extensions/code_block_highlight.js @@ -81,4 +81,13 @@ export default CodeBlockLowlight.extend({ addNodeView() { return new VueNodeViewRenderer(CodeBlockWrapper); }, + + addProseMirrorPlugins() { + const parentPlugins = this.parent?.() ?? []; + // We don't want TipTap's VSCode paste plugin to be loaded since + // it conflicts with our CopyPaste plugin. + const i = parentPlugins.findIndex((plugin) => plugin.key.includes('VSCode')); + if (i >= 0) parentPlugins.splice(i, 1); + return parentPlugins; + }, }).configure({ lowlight }); diff --git a/app/assets/javascripts/content_editor/extensions/copy_paste.js b/app/assets/javascripts/content_editor/extensions/copy_paste.js index ab9e5619600..d29a407c5ca 100644 --- a/app/assets/javascripts/content_editor/extensions/copy_paste.js +++ b/app/assets/javascripts/content_editor/extensions/copy_paste.js @@ -11,6 +11,7 @@ import CodeBlockHighlight from './code_block_highlight'; import CodeSuggestion from './code_suggestion'; import Diagram from './diagram'; import Frontmatter from './frontmatter'; +import { loadingPlugin, findLoader } from './loading'; const TEXT_FORMAT = 'text/plain'; const GFM_FORMAT = 'text/x-gfm'; @@ -31,21 +32,6 @@ function parseHTML(schema, html) { return { document: ProseMirrorDOMParser.fromSchema(schema).parse(body) }; } -const findLoader = (editor, loaderId) => { - let position; - - editor.view.state.doc.descendants((descendant, pos) => { - if (descendant.type.name === 'loading' && descendant.attrs.id === loaderId) { - position = pos; - return false; - } - - return true; - }); - - return position; -}; - export default Extension.create({ name: 'copyPaste', priority: EXTENSION_PRIORITY_HIGHEST, @@ -74,13 +60,20 @@ export default Extension.create({ Promise.resolve() .then(() => { - editor.commands.insertContent({ type: 'loading', attrs: { id: loaderId } }); + editor + .chain() + .deleteSelection() + .setMeta(loadingPlugin, { + add: { loaderId, pos: editor.state.selection.from }, + }) + .run(); + return promise; }) .then(async ({ document }) => { if (!document) return; - const pos = findLoader(editor, loaderId); + const pos = findLoader(editor.state, loaderId); if (!pos) return; const { firstChild, childCount } = document.content; @@ -91,7 +84,7 @@ export default Extension.create({ editor .chain() - .deleteRange({ from: pos, to: pos + 1 }) + .setMeta(loadingPlugin, { remove: { loaderId } }) .insertContentAt(pos, toPaste.toJSON(), { updateSelection: false, }) @@ -113,7 +106,16 @@ export default Extension.create({ const handleCutAndCopy = (view, event) => { const slice = view.state.selection.content(); - const gfmContent = this.options.serializer.serialize({ doc: slice.content }); + let gfmContent = this.options.serializer.serialize({ doc: slice.content }); + const gfmContentWithoutSingleTableCell = gfmContent.replace( + /^<table>[\s\n]*<tr>[\s\n]*<t[hd]>|<\/t[hd]>[\s\n]*<\/tr>[\s\n]*<\/table>[\s\n]*$/gim, + '', + ); + const containsSingleTableCell = !/<t[hd]>/.test(gfmContentWithoutSingleTableCell); + + if (containsSingleTableCell) { + gfmContent = gfmContentWithoutSingleTableCell; + } const documentFragment = DOMSerializer.fromSchema(view.state.schema).serializeFragment( slice.content, ); diff --git a/app/assets/javascripts/content_editor/extensions/emoji.js b/app/assets/javascripts/content_editor/extensions/emoji.js index 7f8b5da5f46..be6ecb6cafd 100644 --- a/app/assets/javascripts/content_editor/extensions/emoji.js +++ b/app/assets/javascripts/content_editor/extensions/emoji.js @@ -1,5 +1,5 @@ import { Node, InputRule } from '@tiptap/core'; -import { initEmojiMap, getAllEmoji } from '~/emoji'; +import { initEmojiMap, getEmojiMap } from '~/emoji'; export default Node.create({ name: 'emoji', @@ -58,7 +58,7 @@ export default Node.create({ find: emojiInputRegex, handler: ({ state, range: { from, to }, match }) => { const [, , name] = match; - const emojis = getAllEmoji(); + const emojis = getEmojiMap(); const emoji = emojis[name]; const { tr } = state; diff --git a/app/assets/javascripts/content_editor/extensions/html_marks.js b/app/assets/javascripts/content_editor/extensions/html_marks.js index 79fc0eea2c7..58fa2655e25 100644 --- a/app/assets/javascripts/content_editor/extensions/html_marks.js +++ b/app/assets/javascripts/content_editor/extensions/html_marks.js @@ -50,7 +50,8 @@ export default marks.map((name) => }, parseHTML() { - return [{ tag: name, priority: PARSE_HTML_PRIORITY_LOWEST }]; + const tag = name === 'span' ? `${name}:not([data-escaped-char])` : name; + return [{ tag, priority: PARSE_HTML_PRIORITY_LOWEST }]; }, renderHTML({ HTMLAttributes }) { diff --git a/app/assets/javascripts/content_editor/extensions/loading.js b/app/assets/javascripts/content_editor/extensions/loading.js index 0115fb10d5d..942ac650925 100644 --- a/app/assets/javascripts/content_editor/extensions/loading.js +++ b/app/assets/javascripts/content_editor/extensions/loading.js @@ -1,4 +1,52 @@ import { Node } from '@tiptap/core'; +import { Decoration, DecorationSet } from '@tiptap/pm/view'; +import { Plugin } from '@tiptap/pm/state'; + +const createDotsLoader = () => { + const root = document.createElement('span'); + root.classList.add('gl-display-inline-flex', 'gl-align-items-center'); + root.innerHTML = '<span class="gl-dots-loader gl-mx-2"><span></span></span>'; + return root; +}; + +export const loadingPlugin = new Plugin({ + state: { + init() { + return DecorationSet.empty; + }, + apply(tr, set) { + let transformedSet = set.map(tr.mapping, tr.doc); + const action = tr.getMeta(this); + + if (action?.add) { + const deco = Decoration.widget(action.add.pos, createDotsLoader(), { + id: action.add.loaderId, + side: -1, + }); + transformedSet = transformedSet.add(tr.doc, [deco]); + } else if (action?.remove) { + transformedSet = transformedSet.remove( + transformedSet.find(null, null, (spec) => spec.id === action.remove.loaderId), + ); + } + return transformedSet; + }, + }, + props: { + decorations(state) { + return this.getState(state); + }, + }, +}); + +export const findLoader = (state, loaderId) => { + const decos = loadingPlugin.getState(state); + const found = decos.find(null, null, (spec) => spec.id === loaderId); + + return found.length ? found[0].from : null; +}; + +export const findAllLoaders = (state) => loadingPlugin.getState(state).find(); export default Node.create({ name: 'loading', @@ -13,11 +61,7 @@ export default Node.create({ }; }, - renderHTML() { - return [ - 'span', - { class: 'gl-display-inline-flex gl-align-items-center' }, - ['span', { class: 'gl-dots-loader gl-mx-2' }, ['span']], - ]; + addProseMirrorPlugins() { + return [loadingPlugin]; }, }); diff --git a/app/assets/javascripts/content_editor/extensions/suggestions.js b/app/assets/javascripts/content_editor/extensions/suggestions.js index f29222a5289..f7ff2fd6647 100644 --- a/app/assets/javascripts/content_editor/extensions/suggestions.js +++ b/app/assets/javascripts/content_editor/extensions/suggestions.js @@ -20,6 +20,7 @@ function createSuggestionPlugin({ limit = 15, nodeType, nodeProps = {}, + insertionMap = {}, }) { const fetchData = memoize( isFunction(dataSource) ? dataSource : async () => (await axios.get(dataSource)).data, @@ -36,7 +37,7 @@ function createSuggestionPlugin({ .focus() .insertContentAt(range, [ { type: nodeType, attrs: props }, - { type: 'text', text: ' ' }, + { type: 'text', text: ` ${insertionMap[props.text] || ''}` }, ]) .run(); }, @@ -56,6 +57,7 @@ function createSuggestionPlugin({ render: () => { let component; let popup; + let isHidden = false; const onUpdate = (props) => { component?.updateProps({ ...props, loading: false }); @@ -87,6 +89,12 @@ function createSuggestionPlugin({ popup = tippy('body', { getReferenceClientRect: props.clientRect, appendTo: () => document.body, + onHide: () => { + isHidden = true; + }, + onShow: () => { + isHidden = false; + }, content: component.element, showOnCreate: true, interactive: true, @@ -99,6 +107,8 @@ function createSuggestionPlugin({ onUpdate, onKeyDown(props) { + if (isHidden) return false; + if (props.event.key === 'Escape') { popup?.[0].hide(); @@ -217,11 +227,24 @@ export default Node.create({ referenceType: 'command', }, search: (query) => ({ name }) => find(name, query), + insertionMap: { + '/label': '~', + '/unlabel': '~', + '/relabel': '~', + '/assign': '@', + '/unassign': '@', + '/reassign': '@', + '/cc': '@', + '/assign_reviewer': '@', + '/unassign_reviewer': '@', + '/reassign_reviewer': '@', + '/milestone': '%', + }, }), createSuggestionPlugin({ editor: this.editor, char: ':', - dataSource: () => Object.values(getAllEmoji()), + dataSource: () => getAllEmoji(), nodeType: 'emoji', search: (query) => ({ d, name }) => find(d, query) || find(name, query), limit: 10, diff --git a/app/assets/javascripts/content_editor/extensions/word_break.js b/app/assets/javascripts/content_editor/extensions/word_break.js index 457b7c36564..01b19cbbd13 100644 --- a/app/assets/javascripts/content_editor/extensions/word_break.js +++ b/app/assets/javascripts/content_editor/extensions/word_break.js @@ -24,7 +24,7 @@ export default Node.create({ }, addInputRules() { - const inputRegex = /^<wbr>$/; + const inputRegex = /<wbr>$/; return [nodeInputRule({ find: inputRegex, type: this.type })]; }, diff --git a/app/assets/javascripts/content_editor/services/content_editor.js b/app/assets/javascripts/content_editor/services/content_editor.js index bc1ee696323..d3d2d76e481 100644 --- a/app/assets/javascripts/content_editor/services/content_editor.js +++ b/app/assets/javascripts/content_editor/services/content_editor.js @@ -84,9 +84,9 @@ export class ContentEditor { async setSerializedContent(serializedContent) { const { _tiptapEditor: editor } = this; - const { doc, tr } = editor.state; const { document } = await this.deserialize(serializedContent); + const { doc, tr } = editor.state; if (document) { this._pristineDoc = document; diff --git a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue index e3d3360cd0c..3b9f14a218f 100644 --- a/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue +++ b/app/assets/javascripts/contribution_events/components/contribution_event/contribution_event_base.vue @@ -47,16 +47,20 @@ export default { <template> <li class="gl-mt-5 gl-pb-5 gl-border-b gl-relative"> - <time-ago-tooltip :time="event.created_at" class="gl-float-right gl-text-secondary" /> + <time-ago-tooltip + :time="event.created_at" + class="gl-float-right gl-font-sm gl-text-secondary gl-mt-2" + /> <gl-avatar-link :href="author.web_url"> <gl-avatar-labeled :label="author.name" :sub-label="authorUsername" + inline-labels :src="author.avatar_url" - :size="32" + :size="24" /> </gl-avatar-link> - <div class="gl-pl-8 gl-mt-2" data-testid="event-body"> + <div class="gl-pl-7" data-testid="event-body"> <div class="gl-text-secondary"> <gl-icon :class="iconClass" :name="iconName" /> <gl-sprintf v-if="message" :message="message"> diff --git a/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql b/app/assets/javascripts/crm/organizations/components/graphql/update_customer_relations_organization.mutation.graphql index a4c46d1f0fa..5ee3da2dfad 100644 --- a/app/assets/javascripts/crm/organizations/components/graphql/update_organization.mutation.graphql +++ b/app/assets/javascripts/crm/organizations/components/graphql/update_customer_relations_organization.mutation.graphql @@ -1,6 +1,6 @@ #import "./crm_organization_fields.fragment.graphql" -mutation updateOrganization($input: CustomerRelationsOrganizationUpdateInput!) { +mutation updateCustomerRelationsOrganization($input: CustomerRelationsOrganizationUpdateInput!) { customerRelationsOrganizationUpdate(input: $input) { organization { ...OrganizationFragment diff --git a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue index fb056e4fa2c..7dd65205b90 100644 --- a/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue +++ b/app/assets/javascripts/crm/organizations/components/organization_form_wrapper.vue @@ -5,7 +5,7 @@ import { TYPENAME_CRM_ORGANIZATION, TYPENAME_GROUP } from '~/graphql_shared/cons import CrmForm from '../../components/crm_form.vue'; import getGroupOrganizationsQuery from './graphql/get_group_organizations.query.graphql'; import createCustomerRelationsOrganizationMutation from './graphql/create_customer_relations_organization.mutation.graphql'; -import updateOrganizationMutation from './graphql/update_organization.mutation.graphql'; +import updateCustomerRelationsOrganizationMutation from './graphql/update_customer_relations_organization.mutation.graphql'; export default { components: { @@ -29,7 +29,7 @@ export default { return convertToGraphQLId(TYPENAME_GROUP, this.groupId); }, mutation() { - if (this.isEditMode) return updateOrganizationMutation; + if (this.isEditMode) return updateCustomerRelationsOrganizationMutation; return createCustomerRelationsOrganizationMutation; }, diff --git a/app/assets/javascripts/custom_emoji/components/delete_item.vue b/app/assets/javascripts/custom_emoji/components/delete_item.vue index 9d13d40dc47..91bd90c3682 100644 --- a/app/assets/javascripts/custom_emoji/components/delete_item.vue +++ b/app/assets/javascripts/custom_emoji/components/delete_item.vue @@ -1,7 +1,7 @@ <script> -import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; import { GlButton, GlTooltipDirective, GlModal, GlModalDirective, GlSprintf } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import deleteCustomEmojiMutation from '../queries/delete_custom_emoji.mutation.graphql'; diff --git a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue index 72d1ce9768a..6210e82119f 100644 --- a/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue +++ b/app/assets/javascripts/deploy_freeze/components/deploy_freeze_modal.vue @@ -142,7 +142,6 @@ export default { ref="freezeStartCron" v-model="freezeStartCron" class="gl-font-monospace!" - data-qa-selector="deploy_freeze_start_field" :placeholder="$options.i18n.cronPlaceholder" :state="freezeStartCronState" autofocus @@ -160,7 +159,6 @@ export default { id="deploy-freeze-end" v-model="freezeEndCron" class="gl-font-monospace!" - data-qa-selector="deploy_freeze_end_field" :placeholder="$options.i18n.cronPlaceholder" :state="freezeEndCronState" trim diff --git a/app/assets/javascripts/deprecated_notes.js b/app/assets/javascripts/deprecated_notes.js index 008e12abbcd..9b5b4cef1b9 100644 --- a/app/assets/javascripts/deprecated_notes.js +++ b/app/assets/javascripts/deprecated_notes.js @@ -152,7 +152,7 @@ export default class Notes { // update the file name when an attachment is selected this.$wrapperEl.on('change', '.js-note-attachment-input', this.updateFormAttachment); // reply to diff/discussion notes - this.$wrapperEl.on('click', '.js-discussion-reply-button', this.onReplyToDiscussionNote); + this.$wrapperEl.on('focus', '.js-discussion-reply-button', this.onReplyToDiscussionNote); // add diff note this.$wrapperEl.on('click', '.js-add-diff-note-button', this.onAddDiffNote); // add diff note for images diff --git a/app/assets/javascripts/design_management/components/delete_button.vue b/app/assets/javascripts/design_management/components/delete_button.vue index dec1038d2e3..88c1b444b31 100644 --- a/app/assets/javascripts/design_management/components/delete_button.vue +++ b/app/assets/javascripts/design_management/components/delete_button.vue @@ -63,7 +63,7 @@ export default { title: s__('DesignManagement|Are you sure you want to archive the selected designs?'), actionPrimary: { text: s__('DesignManagement|Archive designs'), - attributes: { variant: 'confirm', 'data-qa-selector': 'confirm_archiving_button' }, + attributes: { variant: 'confirm', 'data-testid': 'confirm-archiving-button' }, }, actionCancel: { text: __('Cancel'), diff --git a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue index 45f33967476..2a099b6f22d 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_discussion.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlLink, GlTooltipDirective, GlFormCheckbox } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; @@ -293,7 +293,6 @@ export default { <ul class="design-discussion bordered-box gl-relative gl-p-0 gl-list-style-none" :class="{ 'gl-bg-blue-50': isDiscussionActive }" - data-qa-selector="design_discussion_content" data-testid="design-discussion-content" > <design-note diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note.vue b/app/assets/javascripts/design_management/components/design_notes/design_note.vue index a5b6d6276f8..b247f17fd97 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note.vue @@ -7,12 +7,13 @@ import { GlLink, GlTooltipDirective, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { produce } from 'immer'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; import { __ } from '~/locale'; +import { setUrlFragment } from '~/lib/utils/url_utility'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import EmojiPicker from '~/emoji/components/picker.vue'; @@ -29,6 +30,7 @@ export default { editCommentLabel: __('Edit comment'), moreActionsLabel: __('More actions'), deleteCommentText: __('Delete comment'), + copyCommentLink: __('Copy link'), }, components: { DesignNoteAwardsList, @@ -129,19 +131,27 @@ export default { this.isEditing = true; }, extraAttrs: { - 'data-testid': 'delete-note-button', - 'data-qa-selector': 'delete_design_note_button', class: 'gl-sm-display-none!', }, }, { + text: this.$options.i18n.copyCommentLink, + action: () => { + this.$toast.show(__('Link copied to clipboard.')); + }, + extraAttrs: { + 'data-clipboard-text': setUrlFragment( + window.location.href, + `note_${this.noteAnchorId}`, + ), + }, + }, + { text: this.$options.i18n.deleteCommentText, action: () => { this.$emit('delete-note', this.note); }, extraAttrs: { - 'data-testid': 'delete-note-button', - 'data-qa-selector': 'delete_design_note_button', class: 'gl-text-red-500!', }, }, @@ -311,7 +321,6 @@ export default { v-gl-tooltip.hover icon="ellipsis_v" category="tertiary" - data-qa-selector="design_discussion_actions_ellipsis_dropdown" text-sr-only :title="$options.i18n.moreActionsLabel" :aria-label="$options.i18n.moreActionsLabel" @@ -322,12 +331,7 @@ export default { </div> </div> <template v-if="!isEditing"> - <div - v-safe-html="note.bodyHtml" - class="note-text md" - data-qa-selector="note_content" - data-testid="note-text" - ></div> + <div v-safe-html="note.bodyHtml" class="note-text md" data-testid="note-text"></div> <slot name="resolved-status"></slot> </template> <design-note-awards-list diff --git a/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue b/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue index f0812e62bba..de3e71c0e9c 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_note_signed_out.vue @@ -37,7 +37,7 @@ export default { </script> <template> - <div class="disabled-comment text-center"> + <div class="disabled-comment gl-text-center gl-text-secondary"> <gl-sprintf :message="signedOutText"> <template #registerLink="{ content }"> <gl-link :href="registerPath">{{ content }}</gl-link> diff --git a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue index 764c78ff581..b6a303ddde8 100644 --- a/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue +++ b/app/assets/javascripts/design_management/components/design_notes/design_reply_form.vue @@ -221,7 +221,7 @@ export default { class="note-textarea js-gfm-input js-autosize markdown-area" dir="auto" data-supports-quick-actions="false" - data-qa-selector="note_textarea" + data-testid="note-textarea" :aria-label="__('Description')" :placeholder="__('Write a comment…')" @input="handleInput" @@ -243,7 +243,7 @@ export default { variant="confirm" type="submit" data-track-action="click_button" - data-qa-selector="save_comment_button" + data-testid="save-comment-button" @click="submitForm" > {{ buttonText }} diff --git a/app/assets/javascripts/design_management/components/design_overlay.vue b/app/assets/javascripts/design_management/components/design_overlay.vue index 4ce6395140e..e4361f94026 100644 --- a/app/assets/javascripts/design_management/components/design_overlay.vue +++ b/app/assets/javascripts/design_management/components/design_overlay.vue @@ -272,7 +272,7 @@ export default { role="button" :aria-label="$options.i18n.newCommentButtonLabel" class="gl-absolute gl-w-full gl-h-full gl-p-0 gl-top-0 gl-left-0 gl-outline-0! btn-transparent gl-hover-cursor-crosshair" - data-qa-selector="design_image_button" + data-testid="design-image-button" @mouseup="onAddCommentMouseup" ></button> diff --git a/app/assets/javascripts/design_management/components/list/item.vue b/app/assets/javascripts/design_management/components/list/item.vue index 7b98557f4f0..6400f939244 100644 --- a/app/assets/javascripts/design_management/components/list/item.vue +++ b/app/assets/javascripts/design_management/components/list/item.vue @@ -144,12 +144,17 @@ export default { :name="icon.name" :size="16" :class="icon.classes" - data-qa-selector="design_status_icon" + data-testid="design-status-icon" :data-qa-status="icon.name" /> </span> </div> - <gl-intersection-observer class="gl-flex-grow-1" @appear="onAppear"> + <gl-intersection-observer + class="gl-flex-grow-1" + data-testid="design-image" + :data-qa-filename="filename" + @appear="onAppear" + > <gl-loading-icon v-if="showLoadingSpinner" size="lg" /> <gl-icon v-else-if="showImageErrorIcon" @@ -162,8 +167,6 @@ export default { :src="imageLink" :alt="filename" class="gl-display-block gl-mx-auto gl-max-w-full gl-max-h-full gl-w-auto design-img" - data-qa-selector="design_image" - :data-qa-filename="filename" :data-testid="`design-img-${id}`" @load="onImageLoad" @error="onImageError" @@ -171,11 +174,13 @@ export default { </gl-intersection-observer> </div> <div class="card-footer gl-display-flex gl-w-full gl-bg-white gl-py-3 gl-px-4"> - <div class="gl-display-flex gl-flex-direction-column str-truncated-100"> + <div + class="gl-display-flex gl-flex-direction-column str-truncated-100" + data-testid="design-file-name" + > <span v-gl-tooltip class="gl-font-weight-semibold str-truncated-100" - data-qa-selector="design_file_name" :data-testid="`design-img-filename-${id}`" :title="filename" >{{ filename }}</span diff --git a/app/assets/javascripts/design_management/pages/index.vue b/app/assets/javascripts/design_management/pages/index.vue index 09f99f0927f..a1fd3520982 100644 --- a/app/assets/javascripts/design_management/pages/index.vue +++ b/app/assets/javascripts/design_management/pages/index.vue @@ -402,7 +402,7 @@ export default { button-variant="default" button-class="gl-mr-3" button-size="small" - data-qa-selector="archive_button" + data-testid="archive-button" :loading="loading" :has-selected-designs="hasSelectedDesigns" @delete-selected-designs="mutate()" @@ -490,7 +490,7 @@ export default { :checked="isDesignSelected(design.filename)" type="checkbox" class="design-checkbox gl-absolute gl-top-4 gl-left-6 gl-ml-2" - data-qa-selector="design_checkbox" + data-testid="design-checkbox" :data-qa-design="design.filename" @change="changeSelectedDesigns(design.filename)" /> @@ -506,7 +506,7 @@ export default { :class="{ 'design-list-item': !isDesignListEmpty }" :display-as-card="hasDesigns" v-bind="$options.dropzoneProps" - data-qa-selector="design_dropzone_content" + data-testid="design-dropzone-content" @change="onUploadDesign" @error="onDesignDropzoneError" > diff --git a/app/assets/javascripts/diffs/components/app.vue b/app/assets/javascripts/diffs/components/app.vue index 924c515ee2d..54c276c36b1 100644 --- a/app/assets/javascripts/diffs/components/app.vue +++ b/app/assets/javascripts/diffs/components/app.vue @@ -1,6 +1,7 @@ <script> import { GlLoadingIcon, GlPagination, GlSprintf, GlAlert } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; +import { debounce } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapState, mapGetters, mapActions } from 'vuex'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; @@ -16,7 +17,8 @@ import { import { createAlert } from '~/alert'; import { isSingleViewStyle } from '~/helpers/diffs_helper'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { parseBoolean } from '~/lib/utils/common_utils'; +import { parseBoolean, handleLocationHash } from '~/lib/utils/common_utils'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { Mousetrap } from '~/lib/mousetrap'; import { updateHistory } from '~/lib/utils/url_utility'; import { __ } from '~/locale'; @@ -39,8 +41,10 @@ import { TRACKING_SINGLE_FILE_MODE, TRACKING_MULTIPLE_FILES_MODE, EVT_MR_PREPARED, + EVT_DISCUSSIONS_ASSIGNED, } from '../constants'; +import { isCollapsed } from '../utils/diff_file'; import diffsEventHub from '../event_hub'; import { reviewStatuses } from '../utils/file_reviews'; import { diffsApp } from '../utils/performance'; @@ -55,10 +59,16 @@ import HiddenFilesWarning from './hidden_files_warning.vue'; import NoChanges from './no_changes.vue'; import VirtualScrollerScrollSync from './virtual_scroller_scroll_sync'; import DiffsFileTree from './diffs_file_tree.vue'; -import getMRCodequalityReports from './graphql/get_mr_codequality_reports.query.graphql'; +import getMRCodequalityAndSecurityReports from './graphql/get_mr_codequality_and_security_reports.query.graphql'; + +export const FINDINGS_STATUS_PARSED = 'PARSED'; +export const FINDINGS_STATUS_ERROR = 'ERROR'; +export const FINDINGS_POLL_INTERVAL = 1000; export default { name: 'DiffsApp', + FINDINGS_STATUS_PARSED, + FINDINGS_STATUS_ERROR, components: { DiffsFileTree, FindingsDrawer, @@ -100,10 +110,10 @@ export default { required: false, default: '', }, - endpointSast: { - type: String, + sastReportAvailable: { + type: Boolean, required: false, - default: '', + default: false, }, endpointCodequality: { type: String, @@ -135,31 +145,53 @@ export default { diffFilesLength: 0, virtualScrollCurrentIndex: -1, subscribedToVirtualScrollingEvents: false, + autoScrolled: false, + activeProject: undefined, }; }, apollo: { - getMRCodequalityReports: { - query: getMRCodequalityReports, + getMRCodequalityAndSecurityReports: { + query: getMRCodequalityAndSecurityReports, + pollInterval: FINDINGS_POLL_INTERVAL, variables() { return { fullPath: this.projectPath, iid: this.iid }; }, skip() { - return !this.endpointCodequality || !this.sastReportsInInlineDiff; + const codeQualityBoolean = Boolean(this.endpointCodequality); + + return !this.sastReportsInInlineDiff || (!codeQualityBoolean && !this.sastReportAvailable); }, update(data) { - if (data?.project?.mergeRequest?.codequalityReportsComparer?.report?.newErrors) { + const codeQualityBoolean = Boolean(this.endpointCodequality); + const { codequalityReportsComparer, sastReport } = data?.project?.mergeRequest || {}; + + this.activeProject = data?.project?.mergeRequest?.project; + if ( + (sastReport?.status === FINDINGS_STATUS_PARSED || !this.sastReportAvailable) && + (!codeQualityBoolean || codequalityReportsComparer.status === FINDINGS_STATUS_PARSED) + ) { + this.getMRCodequalityAndSecurityReportStopPolling( + this.$apollo.queries.getMRCodequalityAndSecurityReports, + ); + } + + if (sastReport?.status === FINDINGS_STATUS_ERROR && this.sastReportAvailable) { + this.fetchScannerFindingsError(); + } + + if (codequalityReportsComparer?.report?.newErrors) { this.$store.commit( 'diffs/SET_CODEQUALITY_DATA', - sortFindingsByFile( - data.project.mergeRequest.codequalityReportsComparer.report.newErrors, - ), + sortFindingsByFile(codequalityReportsComparer.report.newErrors), ); } + + if (sastReport?.report) { + this.$store.commit('diffs/SET_SAST_DATA', sastReport.report); + } }, error() { - createAlert({ - message: __('Something went wrong fetching the CodeQuality Findings. Please try again!'), - }); + this.fetchScannerFindingsError(); }, }, }, @@ -304,10 +336,6 @@ export default { this.setCodequalityEndpoint(this.endpointCodequality); } - if (this.endpointSast) { - this.setSastEndpoint(this.endpointSast); - } - if (this.shouldShow) { this.fetchData(); } @@ -355,22 +383,15 @@ export default { this.adjustView(); this.subscribeToEvents(); - this.unwatchDiscussions = this.$watch( - () => `${this.flatBlobsList.length}:${this.$store.state.notes.discussions.length}`, - () => { - this.setDiscussions(); - - if (this.$store.state.notes.doneFetchingBatchDiscussions) { - this.unwatchDiscussions(); - } - }, - ); - - this.unwatchRetrievingBatches = this.$watch( - () => `${this.retrievingBatches}:${this.$store.state.notes.discussions.length}`, - () => { - if (!this.retrievingBatches && this.$store.state.notes.discussions.length) { - this.unwatchRetrievingBatches(); + this.slowHashHandler = debounce(() => { + handleLocationHash(); + this.autoScrolled = true; + }, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + this.$watch( + () => this.$store.state.notes.discussions.length, + (newVal, prevVal) => { + if (newVal > prevVal) { + this.setDiscussions(); } }, ); @@ -388,7 +409,6 @@ export default { ...mapActions('diffs', [ 'moveToNeighboringCommit', 'setCodequalityEndpoint', - 'setSastEndpoint', 'fetchDiffFilesMeta', 'fetchDiffFilesBatch', 'fetchFileByFile', @@ -396,7 +416,6 @@ export default { 'setFileForcedOpen', 'fetchCoverageFiles', 'fetchCodequality', - 'fetchSast', 'rereadNoteHash', 'startRenderDiffsQueue', 'assignDiscussionsToDiff', @@ -412,6 +431,11 @@ export default { closeDrawer() { this.setDrawer({}); }, + fetchScannerFindingsError() { + createAlert({ + message: __('Something went wrong fetching the Scanner Findings. Please try again.'), + }); + }, subscribeToEvents() { notesEventHub.$once('fetchDiffData', this.fetchData); notesEventHub.$on('refetchDiffData', this.refetchDiffData); @@ -419,8 +443,13 @@ export default { diffsEventHub.$on('diffFilesModified', this.setDiscussions); diffsEventHub.$on('doneLoadingBatches', this.autoScroll); diffsEventHub.$on(EVT_MR_PREPARED, this.fetchData); + diffsEventHub.$on(EVT_DISCUSSIONS_ASSIGNED, this.handleHash); + }, + getMRCodequalityAndSecurityReportStopPolling(query) { + query.stopPolling(); }, unsubscribeFromEvents() { + diffsEventHub.$off(EVT_DISCUSSIONS_ASSIGNED, this.handleHash); diffsEventHub.$off(EVT_MR_PREPARED, this.fetchData); diffsEventHub.$off('doneLoadingBatches', this.autoScroll); diffsEventHub.$off('diffFilesModified', this.setDiscussions); @@ -436,15 +465,27 @@ export default { const idx = this.diffs.findIndex((diffFile) => diffFile.file_hash === sha1InHash); const file = this.diffs[idx]; + if (!isCollapsed(file)) return; + this.loadCollapsedDiff({ file }) .then(() => { this.setDiscussions(); - this.scrollVirtualScrollerToIndex(idx); this.setFileForcedOpen({ filePath: file.new_path }); + + this.$nextTick(() => this.scrollVirtualScrollerToIndex(idx)); }) .catch(() => {}); } }, + handleHash() { + if (this.viewDiffsFileByFile && !this.autoScrolled) { + const file = this.diffs[0]; + + if (file && !file.isLoadingFullFile) { + requestIdleCallback(() => this.slowHashHandler()); + } + } + }, navigateToDiffFileNumber(number) { this.navigateToDiffFileIndex(number - 1); }, @@ -482,7 +523,7 @@ export default { }) .catch(() => { createAlert({ - message: __('Something went wrong on our end. Please try again!'), + message: __('Something went wrong on our end. Please try again.'), }); }); } @@ -499,7 +540,7 @@ export default { }) .catch(() => { createAlert({ - message: __('Something went wrong on our end. Please try again!'), + message: __('Something went wrong on our end. Please try again.'), }); }); } @@ -512,10 +553,6 @@ export default { this.fetchCodequality(); } - if (this.endpointSast) { - this.fetchSast(); - } - if (!this.isNotesFetched) { notesEventHub.$emit('fetchNotesData'); } @@ -641,7 +678,7 @@ export default { <template> <div v-show="shouldShow"> - <findings-drawer :drawer="activeDrawer" @close="closeDrawer" /> + <findings-drawer :project="activeProject" :drawer="activeDrawer" @close="closeDrawer" /> <div v-if="isLoading || !isTreeLoaded" class="loading"><gl-loading-icon size="lg" /></div> <div v-else id="diffs" :class="{ active: shouldShow }" class="diffs tab-pane"> <compare-versions :diff-files-count-text="numTotalFiles" /> diff --git a/app/assets/javascripts/diffs/components/commit_item.vue b/app/assets/javascripts/diffs/components/commit_item.vue index 3746ab9427f..7493bd5fdf7 100644 --- a/app/assets/javascripts/diffs/components/commit_item.vue +++ b/app/assets/javascripts/diffs/components/commit_item.vue @@ -135,7 +135,7 @@ export default { <div class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" > - <div class="commit-content" data-qa-selector="commit_content"> + <div class="commit-content" data-testid="commit-content"> <a v-safe-html:[$options.safeHtmlConfig]="commit.title_html" :href="commit.commit_url" diff --git a/app/assets/javascripts/diffs/components/diff_discussions.vue b/app/assets/javascripts/diffs/components/diff_discussions.vue index 8915f32eadf..556f72059c2 100644 --- a/app/assets/javascripts/diffs/components/diff_discussions.vue +++ b/app/assets/javascripts/diffs/components/diff_discussions.vue @@ -39,12 +39,6 @@ export default { }, methods: { ...mapActions(['toggleDiscussion']), - ...mapActions('diffs', ['removeDiscussionsFromDiff']), - deleteNoteHandler(discussion) { - if (discussion.notes.length <= 1) { - this.removeDiscussionsFromDiff(discussion); - } - }, isExpanded(discussion) { return this.shouldCollapseDiscussions ? discussion.expanded : true; }, @@ -90,7 +84,6 @@ export default { :line="line" :help-page-path="helpPagePath" :should-scroll-to-note="false" - @noteDeleted="deleteNoteHandler" > <template v-if="renderAvatarBadge" #avatar-badge> <design-note-pin diff --git a/app/assets/javascripts/diffs/components/diff_file.vue b/app/assets/javascripts/diffs/components/diff_file.vue index c74a4b47fcb..8c1cab20ece 100644 --- a/app/assets/javascripts/diffs/components/diff_file.vue +++ b/app/assets/javascripts/diffs/components/diff_file.vue @@ -208,11 +208,6 @@ export default { this.manageViewedEffects(); }, }, - 'file.viewer.forceOpen': { - handler: function fileForcedOpenHandler() { - this.handleToggle(); - }, - }, 'file.file_hash': { handler: function hashChangeWatch(newHash, oldHash) { if ( diff --git a/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_and_security_reports.query.graphql b/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_and_security_reports.query.graphql new file mode 100644 index 00000000000..bd8f408f5a1 --- /dev/null +++ b/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_and_security_reports.query.graphql @@ -0,0 +1,82 @@ +query getMRCodequalityAndSecurityReports($fullPath: ID!, $iid: String!) { + project(fullPath: $fullPath) { + id + mergeRequest(iid: $iid) { + id + title + project { + id + nameWithNamespace + fullPath + } + hasSecurityReports + codequalityReportsComparer { + status + report { + status + newErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + resolvedErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + existingErrors { + description + fingerprint + severity + filePath + line + webUrl + engineName + } + summary { + errored + resolved + total + } + } + } + sastReport: findingReportsComparer(reportType: SAST) { + status + report { + added { + identifiers { + externalId + externalType + name + url + } + uuid + title + description + state + severity + foundByPipelineIid + location { + ... on VulnerabilityLocationSast { + file + startLine + endLine + vulnerableClass + vulnerableMethod + blobPath + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql b/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql deleted file mode 100644 index b6920d0f6ec..00000000000 --- a/app/assets/javascripts/diffs/components/graphql/get_mr_codequality_reports.query.graphql +++ /dev/null @@ -1,46 +0,0 @@ -query getMRCodequalityReports($fullPath: ID!, $iid: String!) { - project(fullPath: $fullPath) { - id - mergeRequest(iid: $iid) { - id - title - codequalityReportsComparer { - report { - status - newErrors { - description - fingerprint - severity - filePath - line - webUrl - engineName - } - resolvedErrors { - description - fingerprint - severity - filePath - line - webUrl - engineName - } - existingErrors { - description - fingerprint - severity - filePath - line - webUrl - engineName - } - summary { - errored - resolved - total - } - } - } - } - } -} diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue index fddd455b17e..2c1a8305935 100644 --- a/app/assets/javascripts/diffs/components/shared/findings_drawer.vue +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer.vue @@ -1,46 +1,56 @@ <script> -import { GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; -import SafeHtml from '~/vue_shared/directives/safe_html'; -import { s__ } from '~/locale'; +import { GlBadge, GlDrawer, GlIcon, GlLink } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import { DRAWER_Z_INDEX } from '~/lib/utils/constants'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { getSeverity } from '~/ci/reports/utils'; import { getContentWrapperHeight } from '~/lib/utils/dom_utils'; +import DrawerItem from './findings_drawer_item.vue'; export const i18n = { - severity: s__('FindingsDrawer|Severity:'), - engine: s__('FindingsDrawer|Engine:'), - category: s__('FindingsDrawer|Category:'), - otherLocations: s__('FindingsDrawer|Other locations:'), + name: __('Name'), + description: __('Description'), + status: __('Status'), + sast: __('SAST'), + engine: __('Engine'), + identifiers: __('Identifiers'), + project: __('Project'), + file: __('File'), + tool: __('Tool'), + codeQualityFinding: s__('FindingsDrawer|Code Quality Finding'), + sastFinding: s__('FindingsDrawer|SAST Finding'), + codeQuality: s__('FindingsDrawer|Code Quality'), + detected: s__('FindingsDrawer|Detected in pipeline'), }; +export const codeQuality = 'codeQuality'; export default { i18n, - components: { GlDrawer, GlIcon, GlLink }, - directives: { - SafeHtml, - }, + codeQuality, + components: { GlBadge, GlDrawer, GlIcon, GlLink, DrawerItem }, props: { drawer: { type: Object, required: true, }, - }, - safeHtmlConfig: { - ALLOWED_TAGS: ['a', 'h1', 'h2', 'p'], - ALLOWED_ATTR: ['href', 'rel'], + project: { + type: Object, + required: false, + default: () => {}, + }, }, computed: { getDrawerHeaderHeight() { return getContentWrapperHeight(); }, + isCodeQuality() { + return this.drawer.scale === this.$options.codeQuality; + }, }, DRAWER_Z_INDEX, methods: { - severityClass(severity) { - return SEVERITY_CLASSES[severity] || SEVERITY_CLASSES.unknown; - }, - severityIcon(severity) { - return SEVERITY_ICONS[severity] || SEVERITY_ICONS.unknown; + getSeverity, + concatIdentifierName(name, index) { + return name + (index !== this.drawer.identifiers.length - 1 ? ', ' : ''); }, }, }; @@ -54,57 +64,82 @@ export default { @close="$emit('close')" > <template #title> - <h2 data-testid="findings-drawer-heading" class="gl-font-size-h2 gl-mt-0 gl-mb-0"> - {{ drawer.description }} + <h2 class="drawer-heading gl-font-base gl-mt-0 gl-mb-0"> + <gl-icon + :size="12" + :name="getSeverity(drawer).name" + :class="getSeverity(drawer).class" + class="inline-findings-severity-icon gl-vertical-align-baseline!" + /> + <span class="drawer-heading-severity">{{ drawer.severity }}</span> + {{ isCodeQuality ? $options.i18n.codeQualityFinding : $options.i18n.sastFinding }} </h2> </template> <template #default> <ul class="gl-list-style-none gl-border-b-initial gl-mb-0 gl-pb-0!"> - <li data-testid="findings-drawer-severity" class="gl-mb-4"> - <span class="gl-font-weight-bold">{{ $options.i18n.severity }}</span> - <gl-icon - data-testid="findings-drawer-severity-icon" - :size="12" - :name="severityIcon(drawer.severity)" - :class="severityClass(drawer.severity)" - class="inline-findings-severity-icon" - /> + <drawer-item v-if="drawer.title" :description="$options.i18n.name" :value="drawer.title" /> + + <drawer-item v-if="drawer.state" :description="$options.i18n.status"> + <template #value> + <gl-badge variant="warning" class="text-capitalize">{{ drawer.state }}</gl-badge> + </template> + </drawer-item> + + <drawer-item + v-if="drawer.description" + :description="$options.i18n.description" + :value="drawer.description" + /> + + <drawer-item + v-if="project && drawer.scale !== $options.codeQuality" + :description="$options.i18n.project" + > + <template #value> + <gl-link :href="`/${project.fullPath}`">{{ project.nameWithNamespace }}</gl-link> + </template> + </drawer-item> + + <drawer-item v-if="drawer.location || drawer.webUrl" :description="$options.i18n.file"> + <template #value> + <span v-if="drawer.webUrl && drawer.filePath && drawer.line"> + <gl-link :href="drawer.webUrl">{{ drawer.filePath }}:{{ drawer.line }}</gl-link> + </span> + <span v-else-if="drawer.location"> + {{ drawer.location.file }}:{{ drawer.location.startLine }} + </span> + </template> + </drawer-item> + + <drawer-item + v-if="drawer.identifiers && drawer.identifiers.length" + :description="$options.i18n.identifiers" + > + <template #value> + <span v-for="(identifier, index) in drawer.identifiers" :key="identifier.externalId"> + <gl-link v-if="identifier.url" :href="identifier.url"> + {{ concatIdentifierName(identifier.name, index) }} + </gl-link> + <span v-else> + {{ concatIdentifierName(identifier.name, index) }} + </span> + </span> + </template> + </drawer-item> + + <drawer-item + v-if="drawer.scale" + :description="$options.i18n.tool" + :value="isCodeQuality ? $options.i18n.codeQuality : $options.i18n.sast" + /> - {{ drawer.severity }} - </li> - <li data-testid="findings-drawer-engine" class="gl-mb-4"> - <span class="gl-font-weight-bold">{{ $options.i18n.engine }}</span> - {{ drawer.engineName }} - </li> - <li data-testid="findings-drawer-category" class="gl-mb-4"> - <span class="gl-font-weight-bold">{{ $options.i18n.category }}</span> - {{ drawer.categories ? drawer.categories[0] : '' }} - </li> - <li data-testid="findings-drawer-other-locations" class="gl-mb-4"> - <span class="gl-font-weight-bold gl-mb-3 gl-display-block">{{ - $options.i18n.otherLocations - }}</span> - <ul class="gl-pl-6"> - <li - v-for="otherLocation in drawer.otherLocations" - :key="otherLocation.path" - class="gl-mb-1" - > - <gl-link - data-testid="findings-drawer-other-locations-link" - :href="otherLocation.href" - >{{ otherLocation.path }}</gl-link - > - </li> - </ul> - </li> + <drawer-item + v-if="drawer.engineName" + :description="$options.i18n.engine" + :value="drawer.engineName" + /> </ul> - <span - v-safe-html:[$options.safeHtmlConfig]="drawer.content ? drawer.content.body : ''" - data-testid="findings-drawer-body" - class="drawer-body gl-display-block gl-px-3 gl-py-0!" - ></span> </template> </gl-drawer> </template> diff --git a/app/assets/javascripts/diffs/components/shared/findings_drawer_item.vue b/app/assets/javascripts/diffs/components/shared/findings_drawer_item.vue new file mode 100644 index 00000000000..f488e8e3bb1 --- /dev/null +++ b/app/assets/javascripts/diffs/components/shared/findings_drawer_item.vue @@ -0,0 +1,30 @@ +<script> +export default { + props: { + description: { + type: String, + required: false, + default: '', + }, + value: { + type: String, + required: false, + default: '', + }, + }, +}; +</script> +<template> + <li class="gl-mb-4"> + <p class="gl-line-height-20"> + <span + data-testid="findings-drawer-item-description" + class="gl-font-weight-bold gl-display-block gl-mb-1" + >{{ description }}</span + > + <slot name="value"> + <span data-testid="findings-drawer-item-value-prop">{{ value }}</span> + </slot> + </p> + </li> +</template> diff --git a/app/assets/javascripts/diffs/components/tree_list.vue b/app/assets/javascripts/diffs/components/tree_list.vue index f4715c591b2..07984beb709 100644 --- a/app/assets/javascripts/diffs/components/tree_list.vue +++ b/app/assets/javascripts/diffs/components/tree_list.vue @@ -3,22 +3,20 @@ import { GlTooltipDirective, GlIcon } from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import micromatch from 'micromatch'; -import { debounce } from 'lodash'; import { getModifierKey } from '~/constants'; import { s__, sprintf } from '~/locale'; import { RecycleScroller } from 'vendor/vue-virtual-scroller'; -import { contentTop } from '~/lib/utils/common_utils'; import DiffFileRow from './diff_file_row.vue'; +import TreeListHeight from './tree_list_height.vue'; const MODIFIER_KEY = getModifierKey(); -const MAX_ITEMS_ON_NARROW_SCREEN = 8; -const BOTTOM_MARGIN = 16; export default { directives: { GlTooltip: GlTooltipDirective, }, components: { + TreeListHeight, GlIcon, DiffFileRow, RecycleScroller, @@ -32,17 +30,10 @@ export default { data() { return { search: '', - scrollerHeight: 0, - rowHeight: 0, - debouncedHeightCalc: null, - reviewBarHeight: 0, - largeBreakpointSize: 0, }; }, computed: { ...mapState('diffs', ['tree', 'renderTreeList', 'currentDiffFileId', 'viewedDiffFileIds']), - ...mapState('batchComments', ['reviewBarRendered']), - ...mapGetters('batchComments', ['draftsCount']), ...mapGetters('diffs', ['allBlobs']), filteredTreeList() { let search = this.search.toLowerCase().trim(); @@ -95,76 +86,21 @@ export default { return result; }, - reviewBarEnabled() { - return this.draftsCount > 0; - }, - }, - watch: { - reviewBarEnabled() { - this.debouncedHeightCalc(); - }, - calculateReviewBarHeight() { - this.debouncedHeightCalc(); - }, - }, - created() { - this.debouncedHeightCalc = debounce(this.calculateScrollerHeight, 50); - }, - mounted() { - const heightProp = getComputedStyle(this.$refs.wrapper).getPropertyValue('--file-row-height'); - const breakpointProp = getComputedStyle(window.document.body).getPropertyValue( - '--breakpoint-lg', - ); - this.largeBreakpointSize = parseInt(breakpointProp, 10); - this.rowHeight = parseInt(heightProp, 10); - this.calculateScrollerHeight(); - let stop; - // eslint-disable-next-line prefer-const - stop = this.$watch( - () => this.reviewBarRendered, - (enabled) => { - if (!enabled) return; - this.calculateReviewBarHeight(); - stop(); - }, - { immediate: true }, - ); - window.addEventListener('resize', this.debouncedHeightCalc, { passive: true }); - }, - beforeDestroy() { - window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true }); }, methods: { ...mapActions('diffs', ['toggleTreeOpen', 'goToFile']), clearSearch() { this.search = ''; }, - calculateScrollerHeight() { - if (window.matchMedia(`(max-width: ${this.largeBreakpointSize - 1}px)`).matches) { - this.calculateMobileScrollerHeight(); - } else { - let clipping = BOTTOM_MARGIN; - if (this.reviewBarEnabled) clipping += this.reviewBarHeight; - this.scrollerHeight = this.$refs.scrollRoot.clientHeight - clipping; - } - }, - calculateMobileScrollerHeight() { - const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.flatFilteredTreeList.length); - this.scrollerHeight = Math.min(maxItems * this.rowHeight, window.innerHeight - contentTop()); - }, - calculateReviewBarHeight() { - this.reviewBarHeight = document.querySelector('.js-review-bar')?.offsetHeight || 0; - }, }, searchPlaceholder: sprintf(s__('MergeRequest|Search (e.g. *.vue) (%{MODIFIER_KEY}P)'), { MODIFIER_KEY, }), - DiffFileRow, }; </script> <template> - <div ref="wrapper" class="tree-list-holder d-flex flex-column" data-testid="file-tree-container"> + <div class="tree-list-holder d-flex flex-column" data-testid="file-tree-container"> <div class="gl-pb-3 position-relative tree-list-search d-flex"> <div class="flex-fill d-flex"> <gl-icon name="search" class="gl-absolute gl-top-3 gl-left-3 tree-list-icon" /> @@ -189,41 +125,41 @@ export default { </button> </div> </div> - <div - ref="scrollRoot" - :class="{ 'tree-list-blobs': !renderTreeList || search }" - class="gl-flex-grow-1 mr-tree-list" - > - <recycle-scroller - v-if="flatFilteredTreeList.length" - :style="{ height: `${scrollerHeight}px` }" - :items="flatFilteredTreeList" - :item-size="rowHeight" - :buffer="100" - key-field="key" - > - <template #default="{ item }"> - <diff-file-row - :file="item" - :level="item.level" - :viewed-files="viewedDiffFileIds" - :hide-file-stats="hideFileStats" - :current-diff-file-id="currentDiffFileId" - :style="{ '--level': item.level }" - :class="{ 'tree-list-parent': item.level > 0 }" - class="gl-relative" - @toggleTreeOpen="toggleTreeOpen" - @clickFile="(path) => goToFile({ path })" - /> - </template> - <template #after> - <div class="tree-list-gutter"></div> - </template> - </recycle-scroller> - <p v-else class="prepend-top-20 append-bottom-20 text-center"> - {{ s__('MergeRequest|No files found') }} - </p> - </div> + <tree-list-height class="gl-flex-grow-1 gl-min-h-0" :items-count="flatFilteredTreeList.length"> + <template #default="{ scrollerHeight, rowHeight }"> + <div :class="{ 'tree-list-blobs': !renderTreeList || search }" class="mr-tree-list"> + <recycle-scroller + v-if="flatFilteredTreeList.length" + :style="{ height: `${scrollerHeight}px` }" + :items="flatFilteredTreeList" + :item-size="rowHeight" + :buffer="100" + key-field="key" + > + <template #default="{ item }"> + <diff-file-row + :file="item" + :level="item.level" + :viewed-files="viewedDiffFileIds" + :hide-file-stats="hideFileStats" + :current-diff-file-id="currentDiffFileId" + :style="{ '--level': item.level }" + :class="{ 'tree-list-parent': item.level > 0 }" + class="gl-relative" + @toggleTreeOpen="toggleTreeOpen" + @clickFile="(path) => goToFile({ path })" + /> + </template> + <template #after> + <div class="tree-list-gutter"></div> + </template> + </recycle-scroller> + <p v-else class="prepend-top-20 append-bottom-20 text-center"> + {{ s__('MergeRequest|No files found') }} + </p> + </div> + </template> + </tree-list-height> </div> </template> diff --git a/app/assets/javascripts/diffs/components/tree_list_height.vue b/app/assets/javascripts/diffs/components/tree_list_height.vue new file mode 100644 index 00000000000..4da94cacd75 --- /dev/null +++ b/app/assets/javascripts/diffs/components/tree_list_height.vue @@ -0,0 +1,108 @@ +<script> +import { debounce } from 'lodash'; +// eslint-disable-next-line no-restricted-imports +import { mapState, mapGetters } from 'vuex'; +import { contentTop } from '~/lib/utils/common_utils'; + +const MAX_ITEMS_ON_NARROW_SCREEN = 8; +// Should be enough for the very long titles (10+ lines) on the max smallest screen +const MAX_SCROLL_Y = 600; +const BOTTOM_OFFSET = 16; + +export default { + name: 'TreeListHeight', + props: { + itemsCount: { + type: Number, + required: true, + }, + }, + data() { + return { + scrollerHeight: 0, + rowHeight: 0, + reviewBarHeight: 0, + scrollY: 0, + isNarrowScreen: false, + mediaQueryMatch: null, + }; + }, + computed: { + ...mapState('batchComments', ['reviewBarRendered']), + ...mapGetters('batchComments', ['draftsCount']), + reviewBarEnabled() { + return this.draftsCount > 0; + }, + debouncedHeightCalc() { + return debounce(this.calculateScrollerHeight, 100); + }, + debouncedRecordScroll() { + return debounce(this.recordScroll, 50); + }, + }, + watch: { + reviewBarRendered: { + handler(rendered) { + if (!rendered || this.reviewBarHeight) return; + this.reviewBarHeight = document.querySelector('.js-review-bar').offsetHeight; + this.debouncedHeightCalc(); + }, + immediate: true, + }, + reviewBarEnabled: 'debouncedHeightCalc', + scrollY: 'debouncedHeightCalc', + isNarrowScreen: 'recordScroll', + }, + mounted() { + const computedStyles = getComputedStyle(this.$refs.scrollRoot); + this.rowHeight = parseInt(computedStyles.getPropertyValue('--file-row-height'), 10); + + const largeBreakpointSize = parseInt(computedStyles.getPropertyValue('--breakpoint-lg'), 10); + this.mediaQueryMatch = window.matchMedia(`(max-width: ${largeBreakpointSize - 1}px)`); + this.isNarrowScreen = this.mediaQueryMatch.matches; + this.mediaQueryMatch.addEventListener('change', this.handleMediaMatch); + + window.addEventListener('resize', this.debouncedHeightCalc, { passive: true }); + window.addEventListener('scroll', this.debouncedRecordScroll, { passive: true }); + + this.calculateScrollerHeight(); + }, + beforeDestroy() { + this.mediaQueryMatch.removeEventListener('change', this.handleMediaMatch); + this.mediaQueryMatch = null; + window.removeEventListener('resize', this.debouncedHeightCalc, { passive: true }); + window.removeEventListener('scroll', this.debouncedRecordScroll, { passive: true }); + }, + methods: { + recordScroll() { + const { scrollY } = window; + if (scrollY > MAX_SCROLL_Y || this.isNarrowScreen) { + this.scrollY = MAX_SCROLL_Y; + } else { + this.scrollY = window.scrollY; + } + }, + handleMediaMatch({ matches }) { + this.isNarrowScreen = matches; + }, + calculateScrollerHeight() { + if (this.isNarrowScreen) { + const maxItems = Math.min(MAX_ITEMS_ON_NARROW_SCREEN, this.itemsCount); + const maxHeight = maxItems * this.rowHeight; + this.scrollerHeight = Math.min(maxHeight, window.innerHeight - contentTop()); + } else { + const { y } = this.$refs.scrollRoot.getBoundingClientRect(); + const reviewBarOffset = this.reviewBarEnabled ? this.reviewBarHeight : 0; + // distance from element's top vertical position in the viewport to the bottom of the viewport minus offsets + this.scrollerHeight = window.innerHeight - y - reviewBarOffset - BOTTOM_OFFSET; + } + }, + }, +}; +</script> + +<template> + <div ref="scrollRoot"> + <slot :scroller-height="scrollerHeight" :row-height="rowHeight"></slot> + </div> +</template> diff --git a/app/assets/javascripts/diffs/constants.js b/app/assets/javascripts/diffs/constants.js index 575cd05ceb8..e48eb10753c 100644 --- a/app/assets/javascripts/diffs/constants.js +++ b/app/assets/javascripts/diffs/constants.js @@ -82,6 +82,7 @@ export const RENAMED_DIFF_TRANSITIONS = { // MR Diffs known events export const EVT_MR_PREPARED = 'mr:asyncPreparationFinished'; export const EVT_EXPAND_ALL_FILES = 'mr:diffs:expandAllFiles'; +export const EVT_DISCUSSIONS_ASSIGNED = 'mr:diffs:discussionsAssigned'; export const EVT_PERF_MARK_FILE_TREE_START = 'mr:diffs:perf:fileTreeStart'; export const EVT_PERF_MARK_FILE_TREE_END = 'mr:diffs:perf:fileTreeEnd'; export const EVT_PERF_MARK_DIFF_FILES_START = 'mr:diffs:perf:filesStart'; diff --git a/app/assets/javascripts/diffs/index.js b/app/assets/javascripts/diffs/index.js index c0b6c8159dc..034dd4cf6d2 100644 --- a/app/assets/javascripts/diffs/index.js +++ b/app/assets/javascripts/diffs/index.js @@ -36,7 +36,7 @@ export default function initDiffsApp(store = notesStore) { iid: dataset.iid || '', endpointCoverage: dataset.endpointCoverage || '', endpointCodequality: dataset.endpointCodequality || '', - endpointSast: dataset.endpointSast || '', + sastReportAvailable: dataset.endpointSast, helpPagePath: dataset.helpPagePath, currentUser: JSON.parse(dataset.currentUserData) || {}, changesEmptyStateIllustration: dataset.changesEmptyStateIllustration, @@ -86,7 +86,7 @@ export default function initDiffsApp(store = notesStore) { iid: this.iid, endpointCoverage: this.endpointCoverage, endpointCodequality: this.endpointCodequality, - endpointSast: this.endpointSast, + sastReportAvailable: this.sastReportAvailable, currentUser: this.currentUser, helpPagePath: this.helpPagePath, shouldShow: this.activeTab === 'diffs', diff --git a/app/assets/javascripts/diffs/store/actions.js b/app/assets/javascripts/diffs/store/actions.js index ed8ae795bda..fcaf8e99b2d 100644 --- a/app/assets/javascripts/diffs/store/actions.js +++ b/app/assets/javascripts/diffs/store/actions.js @@ -49,6 +49,7 @@ import { TRACKING_MULTIPLE_FILES_MODE, EVT_MR_PREPARED, FILE_DIFF_POSITION_TYPE, + EVT_DISCUSSIONS_ASSIGNED, } from '../constants'; import { DISCUSSION_SINGLE_DIFF_FAILED, @@ -89,6 +90,7 @@ export const setBaseConfig = ({ commit }, options) => { viewDiffsFileByFile, mrReviews, diffViewType, + perPage, } = options; commit(types.SET_BASE_CONFIG, { endpoint, @@ -104,6 +106,7 @@ export const setBaseConfig = ({ commit }, options) => { viewDiffsFileByFile, mrReviews, diffViewType, + perPage, }); Array.from(new Set(Object.values(mrReviews).flat())).forEach((id) => { @@ -206,7 +209,7 @@ export const fetchFileByFile = async ({ state, getters, commit }) => { }; export const fetchDiffFilesBatch = ({ commit, state, dispatch }) => { - let perPage = state.viewDiffsFileByFile ? 1 : 5; + let perPage = state.viewDiffsFileByFile ? 1 : state.perPage; let increaseAmount = 1.4; const startPage = 0; const id = window?.location?.hash; @@ -413,12 +416,16 @@ export const assignDiscussionsToDiff = ( } Vue.nextTick(() => { - notesEventHub.$emit('scrollToDiscussion'); + eventHub.$emit(EVT_DISCUSSIONS_ASSIGNED); }); }; export const removeDiscussionsFromDiff = ({ commit }, removeDiscussion) => { - const { file_hash: fileHash, line_code: lineCode, id } = removeDiscussion; + const { + diff_file: { file_hash: fileHash }, + line_code: lineCode, + id, + } = removeDiscussion; commit(types.REMOVE_LINE_DISCUSSIONS_FOR_FILE, { fileHash, lineCode, id }); }; diff --git a/app/assets/javascripts/diffs/store/mutations.js b/app/assets/javascripts/diffs/store/mutations.js index 31369b169f5..08c195469e3 100644 --- a/app/assets/javascripts/diffs/store/mutations.js +++ b/app/assets/javascripts/diffs/store/mutations.js @@ -39,6 +39,7 @@ export default { viewDiffsFileByFile, mrReviews, diffViewType, + perPage, } = options; Object.assign(state, { endpoint, @@ -54,6 +55,7 @@ export default { viewDiffsFileByFile, mrReviews, diffViewType, + perPage, }); }, @@ -198,9 +200,10 @@ export default { return { ...line, discussionsExpanded: - line.discussions && line.discussions.length + line.discussionsExpanded || + (line.discussions && line.discussions.length ? line.discussions.some((disc) => !disc.resolved) || isLineNoteTargeted - : false, + : false), }; }; diff --git a/app/assets/javascripts/diffs/store/utils.js b/app/assets/javascripts/diffs/store/utils.js index 15d2ab71bc8..fb467a606b9 100644 --- a/app/assets/javascripts/diffs/store/utils.js +++ b/app/assets/javascripts/diffs/store/utils.js @@ -338,7 +338,7 @@ function prepareLine(line, file) { problems.brokenSymlink || problems.fileOnlyMoved || problems.brokenLineCode, ), rich_text: cleanRichText(line.rich_text), - discussionsExpanded: true, + discussionsExpanded: false, discussions: [], hasForm: false, text: undefined, diff --git a/app/assets/javascripts/diffs/utils/file_reviews.js b/app/assets/javascripts/diffs/utils/file_reviews.js index 227be4e4a6c..581d0b6055b 100644 --- a/app/assets/javascripts/diffs/utils/file_reviews.js +++ b/app/assets/javascripts/diffs/utils/file_reviews.js @@ -43,7 +43,7 @@ export function reviewable(file) { } export function markFileReview(reviews, file, reviewed = true) { - const usableReviews = { ...(reviews || {}) }; + const usableReviews = { ...reviews }; const updatedReviews = usableReviews; let fileReviews; diff --git a/app/assets/javascripts/diffs/utils/sort_findings_by_file.js b/app/assets/javascripts/diffs/utils/sort_findings_by_file.js index 3a285e80ace..3cf6dc169e4 100644 --- a/app/assets/javascripts/diffs/utils/sort_findings_by_file.js +++ b/app/assets/javascripts/diffs/utils/sort_findings_by_file.js @@ -1,10 +1,17 @@ export function sortFindingsByFile(newErrors = []) { const files = {}; - newErrors.forEach(({ filePath, line, description, severity }) => { + newErrors.forEach(({ line, description, severity, filePath, webUrl, engineName }) => { if (!files[filePath]) { files[filePath] = []; } - files[filePath].push({ line, description, severity: severity.toLowerCase() }); + files[filePath].push({ + line, + description, + severity: severity.toLowerCase(), + filePath, + webUrl, + engineName, + }); }); const sortedFiles = Object.keys(files) diff --git a/app/assets/javascripts/editor/constants.js b/app/assets/javascripts/editor/constants.js index 2be671ec7d8..2d50b7e4319 100644 --- a/app/assets/javascripts/editor/constants.js +++ b/app/assets/javascripts/editor/constants.js @@ -53,11 +53,6 @@ export const EDITOR_EXTENSION_STORE_IS_MISSING_ERROR = s__( export const EXTENSION_BASE_LINE_LINK_ANCHOR_CLASS = 'link-anchor'; export const EXTENSION_BASE_LINE_NUMBERS_CLASS = 'line-numbers'; -// For CI config schemas the filename must match -// '*.gitlab-ci.yml' regardless of project configuration. -// https://gitlab.com/gitlab-org/gitlab/-/issues/293641 -export const EXTENSION_CI_SCHEMA_FILE_NAME_MATCH = '.gitlab-ci.yml'; - export const EXTENSION_MARKDOWN_PREVIEW_PANEL_CLASS = 'md'; export const EXTENSION_MARKDOWN_PREVIEW_PANEL_PARENT_CLASS = 'source-editor-preview'; export const EXTENSION_MARKDOWN_PREVIEW_ACTION_ID = 'markdown-preview'; diff --git a/app/assets/javascripts/editor/schema/ci.json b/app/assets/javascripts/editor/schema/ci.json index 0420ffb82f5..308a68544bc 100644 --- a/app/assets/javascripts/editor/schema/ci.json +++ b/app/assets/javascripts/editor/schema/ci.json @@ -2093,9 +2093,15 @@ "description": "A path to a directory that contains the files to be published with Pages", "type": "string" }, - "pages_path_prefix": { - "description": "The path prefix identifier for this version of pages. Allows creation of multiple versions of the same site with different path prefixes", - "type": "string" + "pages": { + "type": "object", + "additionalProperties": false, + "properties": { + "path_prefix": { + "type": "string", + "markdownDescription": "The GitLab Pages URL path prefix used in this version of pages." + } + } } }, "oneOf": [ diff --git a/app/assets/javascripts/emoji/awards_app/store/actions.js b/app/assets/javascripts/emoji/awards_app/store/actions.js index 677c11277a3..54d98687684 100644 --- a/app/assets/javascripts/emoji/awards_app/store/actions.js +++ b/app/assets/javascripts/emoji/awards_app/store/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/emoji/components/picker.vue b/app/assets/javascripts/emoji/components/picker.vue index 238f0d81b22..462420ba4e5 100644 --- a/app/assets/javascripts/emoji/components/picker.vue +++ b/app/assets/javascripts/emoji/components/picker.vue @@ -3,8 +3,8 @@ import { GlIcon, GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; import { findLastIndex } from 'lodash'; import VirtualList from 'vue-virtual-scroll-list'; -import { CATEGORY_NAMES, getEmojiCategoryMap, state } from '~/emoji'; -import { CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from '../constants'; +import { getEmojiCategoryMap, state } from '~/emoji'; +import { CATEGORY_NAMES, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from '../constants'; import Category from './category.vue'; import EmojiList from './emoji_list.vue'; import { addToFrequentlyUsed, getEmojiCategories, hasFrequentlyUsedEmojis } from './utils'; diff --git a/app/assets/javascripts/emoji/constants.js b/app/assets/javascripts/emoji/constants.js index 215ecbfe605..c8bcb79ad15 100644 --- a/app/assets/javascripts/emoji/constants.js +++ b/app/assets/javascripts/emoji/constants.js @@ -1,6 +1,18 @@ export const FREQUENTLY_USED_KEY = 'frequently_used'; export const FREQUENTLY_USED_COOKIE_KEY = 'frequently_used_emojis'; +export const CATEGORY_NAMES = [ + FREQUENTLY_USED_KEY, + 'custom', + 'people', + 'activity', + 'nature', + 'food', + 'travel', + 'objects', + 'symbols', + 'flags', +]; export const CATEGORY_ICON_MAP = { [FREQUENTLY_USED_KEY]: 'history', custom: 'tanuki', diff --git a/app/assets/javascripts/emoji/index.js b/app/assets/javascripts/emoji/index.js index 1fa81a000a5..f98369c2fde 100644 --- a/app/assets/javascripts/emoji/index.js +++ b/app/assets/javascripts/emoji/index.js @@ -8,7 +8,7 @@ import { getEmojiScoreWithIntent } from '~/emoji/utils'; import AccessorUtilities from '../lib/utils/accessor'; import axios from '../lib/utils/axios_utils'; import customEmojiQuery from './queries/custom_emoji.query.graphql'; -import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_ICON_MAP, FREQUENTLY_USED_KEY } from './constants'; +import { CACHE_KEY, CACHE_VERSION_KEY, CATEGORY_NAMES, FREQUENTLY_USED_KEY } from './constants'; let emojiMap = null; let validEmojiNames = null; @@ -20,22 +20,27 @@ export const state = Vue.observable({ export const FALLBACK_EMOJI_KEY = 'grey_question'; // Keep the version in sync with `lib/gitlab/emoji.rb` -export const EMOJI_VERSION = '2'; +export const EMOJI_VERSION = '3'; const isLocalStorageAvailable = AccessorUtilities.canUseLocalStorage(); async function loadEmoji() { - if ( - isLocalStorageAvailable && - window.localStorage.getItem(CACHE_VERSION_KEY) === EMOJI_VERSION && - window.localStorage.getItem(CACHE_KEY) - ) { - const emojis = JSON.parse(window.localStorage.getItem(CACHE_KEY)); - // Workaround because the pride flag is broken in EMOJI_VERSION = '1' - if (emojis.gay_pride_flag) { - emojis.gay_pride_flag.e = '🏳️🌈'; + try { + window.localStorage.removeItem(CACHE_VERSION_KEY); + } catch { + // Cleanup after us and remove the old EMOJI_VERSION_KEY + } + + try { + if (isLocalStorageAvailable) { + const parsed = JSON.parse(window.localStorage.getItem(CACHE_KEY)); + if (parsed?.EMOJI_VERSION === EMOJI_VERSION && parsed.data) { + return parsed.data; + } } - return emojis; + } catch { + // Maybe the stored data was corrupted or the version didn't match. + // Let's not error out. } // We load the JSON file direct from the server @@ -44,21 +49,31 @@ async function loadEmoji() { const { data } = await axios.get( `${gon.relative_url_root || ''}/-/emojis/${EMOJI_VERSION}/emojis.json`, ); - window.localStorage.setItem(CACHE_VERSION_KEY, EMOJI_VERSION); - window.localStorage.setItem(CACHE_KEY, JSON.stringify(data)); + + try { + window.localStorage.setItem(CACHE_KEY, JSON.stringify({ data, EMOJI_VERSION })); + } catch { + // Setting data in localstorage may fail when storage quota is exceeded. + // We should continue even when this fails. + } + return data; } async function loadEmojiWithNames() { const emojiRegex = emojiRegexFactory(); - return Object.entries(await loadEmoji()).reduce((acc, [key, value]) => { - // Filter out entries which aren't emojis - if (value.e.match(emojiRegex)?.[0] === value.e) { - acc[key] = { ...value, name: key }; - } - return acc; - }, {}); + return (await loadEmoji()).reduce( + (acc, emoji) => { + // Filter out entries which aren't emojis + if (emoji.e.match(emojiRegex)?.[0] === emoji.e) { + acc.emojis[emoji.n] = { ...emoji, name: emoji.n }; + acc.names.push(emoji.n); + } + return acc; + }, + { emojis: {}, names: [] }, + ); } export async function loadCustomEmojiWithNames() { @@ -71,31 +86,35 @@ export async function loadCustomEmojiWithNames() { }, }); - return data?.group?.customEmoji?.nodes?.reduce((acc, e) => { - // Map the custom emoji into the format of the normal emojis - acc[e.name] = { - c: 'custom', - d: e.name, - e: undefined, - name: e.name, - src: e.url, - u: 'custom', - }; + return data?.group?.customEmoji?.nodes?.reduce( + (acc, e) => { + // Map the custom emoji into the format of the normal emojis + acc.emojis[e.name] = { + c: 'custom', + d: e.name, + e: undefined, + name: e.name, + src: e.url, + u: 'custom', + }; + acc.names.push(e.name); - return acc; - }, {}); + return acc; + }, + { emojis: {}, names: [] }, + ); } - return {}; + return { emojis: {}, names: [] }; } async function prepareEmojiMap() { return Promise.all([loadEmojiWithNames(), loadCustomEmojiWithNames()]).then((values) => { emojiMap = { - ...values[0], - ...values[1], + ...values[0].emojis, + ...values[1].emojis, }; - validEmojiNames = [...Object.keys(emojiMap), ...Object.keys(emojiAliases)]; + validEmojiNames = [...values[0].names, ...values[1].names]; state.loading = false; }); } @@ -109,10 +128,6 @@ export function normalizeEmojiName(name) { return Object.prototype.hasOwnProperty.call(emojiAliases, name) ? emojiAliases[name] : name; } -export function getValidEmojiNames() { - return validEmojiNames; -} - export function isEmojiNameValid(name) { if (!emojiMap) { // eslint-disable-next-line @gitlab/require-i18n-strings @@ -122,10 +137,14 @@ export function isEmojiNameValid(name) { return name in emojiMap || name in emojiAliases; } -export function getAllEmoji() { +export function getEmojiMap() { return emojiMap; } +export function getAllEmoji() { + return validEmojiNames.map((n) => emojiMap[n]); +} + export function findCustomEmoji(name) { return emojiMap[name]; } @@ -218,8 +237,6 @@ export function searchEmoji(query) { .sort(sortEmoji); } -export const CATEGORY_NAMES = Object.keys(CATEGORY_ICON_MAP); - let emojiCategoryMap; export function getEmojiCategoryMap() { if (!emojiCategoryMap && emojiMap) { @@ -229,7 +246,7 @@ export function getEmojiCategoryMap() { } return { ...acc, [category]: [] }; }, {}); - Object.keys(emojiMap).forEach((name) => { + validEmojiNames.forEach((name) => { const emoji = emojiMap[name]; if (emojiCategoryMap[emoji.c]) { emojiCategoryMap[emoji.c].push(name); diff --git a/app/assets/javascripts/ensure_data.js b/app/assets/javascripts/ensure_data.js index 4566ab20258..ddb34e59144 100644 --- a/app/assets/javascripts/ensure_data.js +++ b/app/assets/javascripts/ensure_data.js @@ -1,6 +1,6 @@ import emptySvg from '@gitlab/svgs/dist/illustrations/security-dashboard-empty-state.svg?raw'; import { GlEmptyState } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __ } from '~/locale'; export const ERROR_FETCHING_DATA_HEADER = __('Could not get the data properly'); diff --git a/app/assets/javascripts/entrypoints/analytics.js b/app/assets/javascripts/entrypoints/analytics.js index 8eb265cb1e8..e18c4bc8742 100644 --- a/app/assets/javascripts/entrypoints/analytics.js +++ b/app/assets/javascripts/entrypoints/analytics.js @@ -14,4 +14,10 @@ if (appId && host) { errorTracking: false, }, }); + + const userId = window.gl?.snowplowStandardContext?.data?.user_id; + + if (userId) { + window.glClient?.identify(userId); + } } diff --git a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue index 2cf71de7ea2..2bc65e4ad04 100644 --- a/app/assets/javascripts/environments/components/confirm_rollback_modal.vue +++ b/app/assets/javascripts/environments/components/confirm_rollback_modal.vue @@ -3,8 +3,8 @@ * Render modal to confirm rollback/redeploy. */ import { GlModal, GlSprintf, GlLink } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { escape } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import csrf from '~/lib/utils/csrf'; import { __, s__, sprintf } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; diff --git a/app/assets/javascripts/environments/components/environment_form.vue b/app/assets/javascripts/environments/components/environment_form.vue index 8ebba0e27bb..c6cf6b7e24b 100644 --- a/app/assets/javascripts/environments/components/environment_form.vue +++ b/app/assets/javascripts/environments/components/environment_form.vue @@ -193,6 +193,7 @@ export default { headers: { 'GitLab-Agent-Id': getIdFromGraphQLId(this.selectedAgentId), 'Content-Type': 'application/json', + Accept: 'application/json', ...csrf.headers, }, credentials: 'include', diff --git a/app/assets/javascripts/environments/components/environment_item.vue b/app/assets/javascripts/environments/components/environment_item.vue index 08a1eacec7a..47edec8dcb0 100644 --- a/app/assets/javascripts/environments/components/environment_item.vue +++ b/app/assets/javascripts/environments/components/environment_item.vue @@ -752,7 +752,7 @@ export default { :title="upcomingDeploymentTooltipText" data-testid="upcoming-deployment-status-link" > - <ci-icon class="gl-mr-2" :status="upcomingDeployment.deployable.status" /> + <ci-icon :status="upcomingDeployment.deployable.status" class="gl-mr-2" /> </gl-link> </div> <span diff --git a/app/assets/javascripts/environments/components/environments_app.vue b/app/assets/javascripts/environments/components/environments_app.vue index 795cbf5327a..4e8b75536a4 100644 --- a/app/assets/javascripts/environments/components/environments_app.vue +++ b/app/assets/javascripts/environments/components/environments_app.vue @@ -78,7 +78,7 @@ export default { newEnvironmentButtonLabel: s__('Environments|New environment'), reviewAppButtonLabel: s__('Environments|Enable review apps'), cleanUpEnvsButtonLabel: s__('Environments|Clean up environments'), - available: __('Available'), + active: __('Active'), stopped: __('Stopped'), prevPage: __('Go to previous page'), nextPage: __('Go to next page'), @@ -97,9 +97,7 @@ export default { isStopStaleEnvModalVisible: false, page: parseInt(page, 10), pageInfo: {}, - scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) - ? scope - : ENVIRONMENTS_SCOPE.AVAILABLE, + scope: Object.values(ENVIRONMENTS_SCOPE).includes(scope) ? scope : ENVIRONMENTS_SCOPE.ACTIVE, environmentToDelete: {}, environmentToRollback: {}, environmentToStop: {}, @@ -112,6 +110,9 @@ export default { canSetupReviewApp() { return this.environmentApp?.reviewApp?.canSetupReviewApp; }, + hasReviewApp() { + return this.environmentApp?.reviewApp?.hasReviewApp; + }, canCleanUpEnvs() { return this.environmentApp?.canStopStaleEnvironments; }, @@ -130,14 +131,14 @@ export default { hasSearch() { return Boolean(this.search); }, - availableCount() { - return this.environmentApp?.availableCount; + activeCount() { + return this.environmentApp?.activeCount ?? 0; }, stoppedCount() { - return this.environmentApp?.stoppedCount; + return this.environmentApp?.stoppedCount ?? 0; }, hasAnyEnvironment() { - return this.availableCount > 0 || this.stoppedCount > 0; + return this.activeCount > 0 || this.stoppedCount > 0; }, showContent() { return this.hasAnyEnvironment || this.hasSearch; @@ -157,7 +158,10 @@ export default { }; }, openReviewAppModal() { - if (!this.canSetupReviewApp) { + // we don't show the Enable review apps button + // if a user cannot setup a review app or review + // apps are already configured + if (!this.canSetupReviewApp || this.hasReviewApp) { return null; } @@ -272,13 +276,13 @@ export default { @primary="showCleanUpEnvsModal" > <gl-tab - :query-param-value="$options.ENVIRONMENTS_SCOPE.AVAILABLE" - @click="setScope($options.ENVIRONMENTS_SCOPE.AVAILABLE)" + :query-param-value="$options.ENVIRONMENTS_SCOPE.ACTIVE" + @click="setScope($options.ENVIRONMENTS_SCOPE.ACTIVE)" > <template #title> - <span>{{ $options.i18n.available }}</span> + <span>{{ $options.i18n.active }}</span> <gl-badge size="sm" class="gl-tab-counter-badge"> - {{ availableCount }} + {{ activeCount }} </gl-badge> </template> </gl-tab> diff --git a/app/assets/javascripts/environments/components/environments_table.vue b/app/assets/javascripts/environments/components/environments_table.vue index b47086a19da..2f49ed847bf 100644 --- a/app/assets/javascripts/environments/components/environments_table.vue +++ b/app/assets/javascripts/environments/components/environments_table.vue @@ -147,12 +147,7 @@ export default { </div> </div> <template v-for="(model, i) in sortedEnvironments"> - <environment-item - :key="`environment-item-${i}`" - :model="model" - :table-data="tableData" - data-qa-selector="environment_item" - /> + <environment-item :key="`environment-item-${i}`" :model="model" :table-data="tableData" /> <div v-if="shouldRenderDeployBoard(model)" @@ -185,7 +180,6 @@ export default { :key="`environment-row-${i}-${index}`" :model="child" :table-data="tableData" - data-qa-selector="environment_item" /> <div diff --git a/app/assets/javascripts/environments/components/kubernetes_overview.vue b/app/assets/javascripts/environments/components/kubernetes_overview.vue index 252ced6391d..36cce29d624 100644 --- a/app/assets/javascripts/environments/components/kubernetes_overview.vue +++ b/app/assets/javascripts/environments/components/kubernetes_overview.vue @@ -64,6 +64,7 @@ export default { headers: { 'GitLab-Agent-Id': this.gitlabAgentId, 'Content-Type': 'application/json', + Accept: 'application/json', ...csrf.headers, }, credentials: 'include', @@ -110,7 +111,6 @@ export default { <kubernetes-status-bar :cluster-health-status="clusterHealthStatus" :configuration="k8sAccessConfiguration" - :namespace="namespace" :environment-name="environmentName" :flux-resource-path="fluxResourcePath" class="gl-mb-3" /> diff --git a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue index c603d83db9c..8ecb61711ce 100644 --- a/app/assets/javascripts/environments/components/kubernetes_status_bar.vue +++ b/app/assets/javascripts/environments/components/kubernetes_status_bar.vue @@ -37,11 +37,6 @@ export default { required: true, type: String, }, - namespace: { - required: false, - type: String, - default: '', - }, fluxResourcePath: { required: false, type: String, @@ -54,14 +49,12 @@ export default { variables() { return { configuration: this.configuration, - namespace: this.namespace, - environmentName: this.environmentName.toLowerCase(), fluxResourcePath: this.fluxResourcePath, }; }, skip() { return Boolean( - !this.namespace || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE), + !this.fluxResourcePath || this.fluxResourcePath?.includes(HELM_RELEASES_RESOURCE_TYPE), ); }, error(err) { @@ -73,17 +66,12 @@ export default { variables() { return { configuration: this.configuration, - namespace: this.namespace, - environmentName: this.environmentName.toLowerCase(), fluxResourcePath: this.fluxResourcePath, }; }, skip() { return Boolean( - !this.namespace || - this.$apollo.queries.fluxKustomizationStatus.loading || - this.hasKustomizations || - this.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE), + !this.fluxResourcePath || this.fluxResourcePath?.includes(KUSTOMIZATIONS_RESOURCE_TYPE), ); }, error(err) { diff --git a/app/assets/javascripts/environments/constants.js b/app/assets/javascripts/environments/constants.js index 7214454c45c..e97720312b0 100644 --- a/app/assets/javascripts/environments/constants.js +++ b/app/assets/javascripts/environments/constants.js @@ -42,12 +42,12 @@ export const CANARY_STATUS = { export const CANARY_UPDATE_MODAL = 'confirm-canary-change'; export const ENVIRONMENTS_SCOPE = { - AVAILABLE: 'available', + ACTIVE: 'active', STOPPED: 'stopped', }; export const ENVIRONMENT_COUNT_BY_SCOPE = { - [ENVIRONMENTS_SCOPE.AVAILABLE]: 'availableCount', + [ENVIRONMENTS_SCOPE.ACTIVE]: 'activeCount', [ENVIRONMENTS_SCOPE.STOPPED]: 'stoppedCount', }; diff --git a/app/assets/javascripts/environments/environment_details/index.vue b/app/assets/javascripts/environments/environment_details/index.vue index 6e3ec04ba3b..bc535eb73aa 100644 --- a/app/assets/javascripts/environments/environment_details/index.vue +++ b/app/assets/javascripts/environments/environment_details/index.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlLoadingIcon } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { logError } from '~/lib/logger'; import { toggleQueryPollingByVisibility, etagQueryHeaders } from '~/graphql_shared/utils'; import ConfirmRollbackModal from '~/environments/components/confirm_rollback_modal.vue'; diff --git a/app/assets/javascripts/environments/graphql/client.js b/app/assets/javascripts/environments/graphql/client.js index 8faed710402..8f57069d89d 100644 --- a/app/assets/javascripts/environments/graphql/client.js +++ b/app/assets/javascripts/environments/graphql/client.js @@ -17,7 +17,6 @@ import typeDefs from './typedefs.graphql'; export const apolloProvider = (endpoint) => { const defaultClient = createDefaultClient(resolvers(endpoint), { typeDefs, - useGet: true, }); const { cache } = defaultClient; diff --git a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql index 7a50ded7d6c..ef5a8194dca 100644 --- a/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/environment_app.query.graphql @@ -1,6 +1,6 @@ query getEnvironmentApp($page: Int, $scope: String, $search: String) { environmentApp(page: $page, scope: $scope, search: $search) @client { - availableCount + activeCount stoppedCount environments reviewApp diff --git a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql index 544232dafd7..042bdc1992d 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_helm_release_status.query.graphql @@ -1,15 +1,6 @@ -query getFluxHelmReleaseStatusQuery( - $configuration: LocalConfiguration - $namespace: String - $environmentName: String - $fluxResourcePath: String -) { - fluxHelmReleaseStatus( - configuration: $configuration - namespace: $namespace - environmentName: $environmentName - fluxResourcePath: $fluxResourcePath - ) @client { +query getFluxHelmReleaseStatusQuery($configuration: LocalConfiguration, $fluxResourcePath: String) { + fluxHelmReleaseStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath) + @client { message status type diff --git a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql index 2884f95355e..458b8a4d9db 100644 --- a/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/flux_kustomization_status.query.graphql @@ -1,15 +1,9 @@ query getFluxHelmKustomizationStatusQuery( $configuration: LocalConfiguration - $namespace: String - $environmentName: String $fluxResourcePath: String ) { - fluxKustomizationStatus( - configuration: $configuration - namespace: $namespace - environmentName: $environmentName - fluxResourcePath: $fluxResourcePath - ) @client { + fluxKustomizationStatus(configuration: $configuration, fluxResourcePath: $fluxResourcePath) + @client { message status type diff --git a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql index c662acb8f93..ac6a68e450c 100644 --- a/app/assets/javascripts/environments/graphql/queries/folder.query.graphql +++ b/app/assets/javascripts/environments/graphql/queries/folder.query.graphql @@ -1,6 +1,6 @@ query getEnvironmentFolder($environment: NestedLocalEnvironment, $scope: String, $search: String) { folder(environment: $environment, scope: $scope, search: $search) @client { - availableCount + activeCount environments stoppedCount } diff --git a/app/assets/javascripts/environments/graphql/resolvers/base.js b/app/assets/javascripts/environments/graphql/resolvers/base.js index 9752a3a6634..4427b8ff2ef 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/base.js +++ b/app/assets/javascripts/environments/graphql/resolvers/base.js @@ -47,7 +47,7 @@ export const baseQueries = (endpoint) => ({ }); return { - availableCount: res.data.available_count, + activeCount: res.data.active_count, environments: res.data.environments.map(mapNestedEnvironment), reviewApp: { ...convertObjectPropsToCamelCase(res.data.review_app), @@ -61,7 +61,7 @@ export const baseQueries = (endpoint) => ({ }, folder(_, { environment: { folderPath }, scope, search }) { return axios.get(folderPath, { params: { scope, search, per_page: 3 } }).then((res) => ({ - availableCount: res.data.available_count, + activeCount: res.data.active_count, environments: res.data.environments.map(mapEnvironment), stoppedCount: res.data.stopped_count, __typename: 'LocalEnvironmentFolder', diff --git a/app/assets/javascripts/environments/graphql/resolvers/flux.js b/app/assets/javascripts/environments/graphql/resolvers/flux.js index d39b1bed7b6..5cb5db5d752 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/flux.js +++ b/app/assets/javascripts/environments/graphql/resolvers/flux.js @@ -1,35 +1,83 @@ +import { Configuration, WatchApi, EVENT_DATA } from '@gitlab/cluster-client'; import axios from '~/lib/utils/axios_utils'; import { HELM_RELEASES_RESOURCE_TYPE, KUSTOMIZATIONS_RESOURCE_TYPE, } from '~/environments/constants'; +import fluxKustomizationStatusQuery from '../queries/flux_kustomization_status.query.graphql'; +import fluxHelmReleaseStatusQuery from '../queries/flux_helm_release_status.query.graphql'; const helmReleasesApiVersion = 'helm.toolkit.fluxcd.io/v2beta1'; const kustomizationsApiVersion = 'kustomize.toolkit.fluxcd.io/v1beta1'; +const helmReleaseField = 'fluxHelmReleaseStatus'; +const kustomizationField = 'fluxKustomizationStatus'; + const handleClusterError = (err) => { const error = err?.response?.data?.message ? new Error(err.response.data.message) : err; throw error; }; -const buildFluxResourceUrl = ({ - basePath, - namespace, - apiVersion, - resourceType, - environmentName = '', -}) => { - return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}/${environmentName}`; +const buildFluxResourceUrl = ({ basePath, namespace, apiVersion, resourceType }) => { + return `${basePath}/apis/${apiVersion}/namespaces/${namespace}/${resourceType}`; }; -const getFluxResourceStatus = (configuration, url) => { - const { headers } = configuration; +const buildFluxResourceWatchPath = ({ namespace, apiVersion, resourceType }) => { + return `/apis/${apiVersion}/namespaces/${namespace}/${resourceType}`; +}; + +const watchFluxResource = ({ watchPath, resourceName, query, variables, field, client }) => { + const config = new Configuration(variables.configuration); + const watcherApi = new WatchApi(config); + const fieldSelector = `metadata.name=${decodeURIComponent(resourceName)}`; + + watcherApi + .subscribeToStream(watchPath, { watch: true, fieldSelector }) + .then((watcher) => { + let result = []; + + watcher.on(EVENT_DATA, (data) => { + result = data[0]?.status?.conditions; + + client.writeQuery({ + query, + variables, + data: { [field]: result }, + }); + }); + }) + .catch((err) => { + handleClusterError(err); + }); +}; + +const getFluxResourceStatus = ({ query, variables, field, resourceType, client }) => { + const { headers } = variables.configuration; const withCredentials = true; + const url = `${variables.configuration.basePath}/apis/${variables.fluxResourcePath}`; return axios .get(url, { withCredentials, headers }) .then((res) => { - return res?.data?.status?.conditions || []; + const fluxData = res?.data; + const resourceName = fluxData?.metadata?.name; + const namespace = fluxData?.metadata?.namespace; + const apiVersion = fluxData?.apiVersion; + + if (gon.features?.k8sWatchApi && resourceName) { + const watchPath = buildFluxResourceWatchPath({ namespace, apiVersion, resourceType }); + + watchFluxResource({ + watchPath, + resourceName, + query, + variables, + field, + client, + }); + } + + return fluxData?.status?.conditions || []; }) .catch((err) => { handleClusterError(err); @@ -62,37 +110,23 @@ const getFluxResources = (configuration, url) => { }; export default { - fluxKustomizationStatus(_, { configuration, namespace, environmentName, fluxResourcePath = '' }) { - let url; - - if (fluxResourcePath) { - url = `${configuration.basePath}/apis/${fluxResourcePath}`; - } else { - url = buildFluxResourceUrl({ - basePath: configuration.basePath, - resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, - apiVersion: kustomizationsApiVersion, - namespace, - environmentName, - }); - } - return getFluxResourceStatus(configuration, url); + fluxKustomizationStatus(_, { configuration, fluxResourcePath }, { client }) { + return getFluxResourceStatus({ + query: fluxKustomizationStatusQuery, + variables: { configuration, fluxResourcePath }, + field: kustomizationField, + resourceType: KUSTOMIZATIONS_RESOURCE_TYPE, + client, + }); }, - fluxHelmReleaseStatus(_, { configuration, namespace, environmentName, fluxResourcePath }) { - let url; - - if (fluxResourcePath) { - url = `${configuration.basePath}/apis/${fluxResourcePath}`; - } else { - url = buildFluxResourceUrl({ - basePath: configuration.basePath, - resourceType: HELM_RELEASES_RESOURCE_TYPE, - apiVersion: helmReleasesApiVersion, - namespace, - environmentName, - }); - } - return getFluxResourceStatus(configuration, url); + fluxHelmReleaseStatus(_, { configuration, fluxResourcePath }, { client }) { + return getFluxResourceStatus({ + query: fluxHelmReleaseStatusQuery, + variables: { configuration, fluxResourcePath }, + field: helmReleaseField, + resourceType: HELM_RELEASES_RESOURCE_TYPE, + client, + }); }, fluxKustomizations(_, { configuration, namespace }) { const url = buildFluxResourceUrl({ diff --git a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js index 67a472dac93..8375b8793d9 100644 --- a/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js +++ b/app/assets/javascripts/environments/graphql/resolvers/kubernetes.js @@ -1,5 +1,13 @@ -import { CoreV1Api, Configuration, AppsV1Api, BatchV1Api } from '@gitlab/cluster-client'; +import { + CoreV1Api, + Configuration, + AppsV1Api, + BatchV1Api, + WatchApi, + EVENT_DATA, +} from '@gitlab/cluster-client'; import { humanizeClusterErrors } from '../../helpers/k8s_integration_helper'; +import k8sPodsQuery from '../queries/k8s_pods.query.graphql'; const mapWorkloadItems = (items, kind) => { return items.map((item) => { @@ -53,15 +61,50 @@ const handleClusterError = async (err) => { throw errorData; }; +const watchPods = ({ configuration, namespace, client }) => { + const path = namespace ? `/api/v1/namespaces/${namespace}/pods` : '/api/v1/pods'; + const config = new Configuration(configuration); + const watcherApi = new WatchApi(config); + + watcherApi + .subscribeToStream(path, { watch: true }) + .then((watcher) => { + let result = []; + + watcher.on(EVENT_DATA, (data) => { + result = data.map((item) => { + return { status: { phase: item.status.phase } }; + }); + + client.writeQuery({ + query: k8sPodsQuery, + variables: { configuration, namespace }, + data: { k8sPods: result }, + }); + }); + }) + .catch((err) => { + handleClusterError(err); + }); +}; + export default { - k8sPods(_, { configuration, namespace }) { - const coreV1Api = new CoreV1Api(new Configuration(configuration)); + k8sPods(_, { configuration, namespace }, { client }) { + const config = new Configuration(configuration); + + const coreV1Api = new CoreV1Api(config); const podsApi = namespace ? coreV1Api.listCoreV1NamespacedPod({ namespace }) : coreV1Api.listCoreV1PodForAllNamespaces(); return podsApi - .then((res) => res?.items || []) + .then((res) => { + if (gon.features?.k8sWatchApi) { + watchPods({ configuration, namespace, client }); + } + + return res?.items || []; + }) .catch(async (err) => { try { await handleClusterError(err); diff --git a/app/assets/javascripts/error_tracking/components/error_details.vue b/app/assets/javascripts/error_tracking/components/error_details.vue index 1821aa073bc..01879a092ed 100644 --- a/app/assets/javascripts/error_tracking/components/error_details.vue +++ b/app/assets/javascripts/error_tracking/components/error_details.vue @@ -196,7 +196,7 @@ export default { text: __('Create issue'), action: this.createIssue, extraAttrs: { - 'data-qa-selector': 'create_issue_button', + 'data-testid': 'create-issue-button', }, }; }, @@ -309,7 +309,7 @@ export default { <div v-if="!loadingStacktrace && stacktrace" class="gl-my-auto gl-text-truncate" - data-qa-selector="reported_text" + data-testid="reported-text" > <gl-sprintf :message="__('Reported %{timeAgo} by %{reportedBy}')"> <template #reportedBy> @@ -367,7 +367,7 @@ export default { category="primary" variant="confirm" :loading="issueCreationInProgress" - data-qa-selector="create_issue_button" + data-testid="create-issue-button" @click="createIssue" > {{ __('Create issue') }} diff --git a/app/assets/javascripts/feature_flags/components/feature_flags.vue b/app/assets/javascripts/feature_flags/components/feature_flags.vue index daaeb5f8e85..e0a5e92564e 100644 --- a/app/assets/javascripts/feature_flags/components/feature_flags.vue +++ b/app/assets/javascripts/feature_flags/components/feature_flags.vue @@ -206,7 +206,6 @@ export default { v-gl-modal="'configure-feature-flags'" variant="confirm" category="secondary" - data-qa-selector="configure_feature_flags_button" data-testid="ff-configure-button" class="gl-mb-0 gl-mr-3" > diff --git a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js index 4d3647cdf5c..74d91734630 100644 --- a/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js +++ b/app/assets/javascripts/filtered_search/add_extra_tokens_for_merge_requests.js @@ -5,9 +5,10 @@ import { TOKEN_TYPE_APPROVED_BY, TOKEN_TYPE_REVIEWER, TOKEN_TYPE_TARGET_BRANCH, + TOKEN_TYPE_SOURCE_BRANCH, } from '~/vue_shared/components/filtered_search_bar/constants'; -export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { +export default (IssuableTokenKeys, disableBranchFilter = false) => { const reviewerToken = { formattedKey: TOKEN_TITLE_REVIEWER, key: TOKEN_TYPE_REVIEWER, @@ -57,7 +58,7 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { IssuableTokenKeys.tokenKeysWithAlternative.push(draftToken.token); IssuableTokenKeys.conditions.push(...draftToken.conditions); - if (!disableTargetBranchFilter) { + if (!disableBranchFilter) { const targetBranchToken = { formattedKey: __('Target-Branch'), key: TOKEN_TYPE_TARGET_BRANCH, @@ -68,8 +69,18 @@ export default (IssuableTokenKeys, disableTargetBranchFilter = false) => { tag: 'branch', }; - IssuableTokenKeys.tokenKeys.push(targetBranchToken); - IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken); + const sourceBranchToken = { + formattedKey: __('Source-Branch'), + key: TOKEN_TYPE_SOURCE_BRANCH, + type: 'string', + param: '', + symbol: '', + icon: 'branch', + tag: 'branch', + }; + + IssuableTokenKeys.tokenKeys.push(targetBranchToken, sourceBranchToken); + IssuableTokenKeys.tokenKeysWithAlternative.push(targetBranchToken, sourceBranchToken); } const approvedToken = { diff --git a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js index 892e9130fe8..a1782c549d6 100644 --- a/app/assets/javascripts/filtered_search/available_dropdown_mappings.js +++ b/app/assets/javascripts/filtered_search/available_dropdown_mappings.js @@ -11,6 +11,7 @@ import { TOKEN_TYPE_RELEASE, TOKEN_TYPE_REVIEWER, TOKEN_TYPE_TARGET_BRANCH, + TOKEN_TYPE_SOURCE_BRANCH, } from '~/vue_shared/components/filtered_search_bar/constants'; import DropdownEmoji from './dropdown_emoji'; import DropdownHint from './dropdown_hint'; @@ -157,6 +158,15 @@ export default class AvailableDropdownMappings { }, element: this.container.querySelector('#js-dropdown-target-branch'), }, + [TOKEN_TYPE_SOURCE_BRANCH]: { + reference: null, + gl: DropdownNonUser, + extraArguments: { + endpoint: this.getMergeRequestSourceBranchesEndpoint(), + symbol: '', + }, + element: this.container.querySelector('#js-dropdown-source-branch'), + }, environment: { reference: null, gl: DropdownNonUser, @@ -197,10 +207,17 @@ export default class AvailableDropdownMappings { } getMergeRequestTargetBranchesEndpoint() { - const endpoint = `${ - gon.relative_url_root || '' - }/-/autocomplete/merge_request_target_branches.json`; + const targetBranchEndpointPath = '/-/autocomplete/merge_request_target_branches.json'; + return this.getMergeRequestBranchesEndpoint(targetBranchEndpointPath); + } + + getMergeRequestSourceBranchesEndpoint() { + const sourceBranchEndpointPath = '/-/autocomplete/merge_request_source_branches.json'; + return this.getMergeRequestBranchesEndpoint(sourceBranchEndpointPath); + } + getMergeRequestBranchesEndpoint(endpointPath = '') { + const endpoint = `${gon.relative_url_root || ''}${endpointPath}`; const params = { group_id: this.getGroupId(), project_id: this.getProjectId(), diff --git a/app/assets/javascripts/gfm_auto_complete.js b/app/assets/javascripts/gfm_auto_complete.js index 99d22b1330b..39a8b1d0a9c 100644 --- a/app/assets/javascripts/gfm_auto_complete.js +++ b/app/assets/javascripts/gfm_auto_complete.js @@ -979,7 +979,7 @@ GfmAutoComplete.Emoji = { }, filter(query) { if (query.length === 0) { - return Object.values(Emoji.getAllEmoji()) + return Emoji.getAllEmoji() .map((emoji) => ({ emoji, fieldValue: emoji.name, diff --git a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue index f19e047061f..dcf6c90f7fa 100644 --- a/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue +++ b/app/assets/javascripts/gitlab_pages/components/pages_pipeline_wizard.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import { captureException } from '@sentry/browser'; +import { captureException } from '~/sentry/sentry_browser_wrapper'; import PipelineWizard from '~/pipeline_wizard/pipeline_wizard.vue'; import PagesWizardTemplate from '~/pipeline_wizard/templates/pages.yml?raw'; import { logError } from '~/lib/logger'; diff --git a/app/assets/javascripts/graphql_shared/constants.js b/app/assets/javascripts/graphql_shared/constants.js index 5ba46697496..2863f52bea9 100644 --- a/app/assets/javascripts/graphql_shared/constants.js +++ b/app/assets/javascripts/graphql_shared/constants.js @@ -27,5 +27,6 @@ export const TYPENAME_USER = 'User'; export const TYPENAME_VULNERABILITIES_SCANNER = 'Vulnerabilities::Scanner'; export const TYPENAME_VULNERABILITY = 'Vulnerability'; export const TYPENAME_WORK_ITEM = 'WorkItem'; +export const TYPENAME_ORGANIZATION = 'Organization'; export const TYPE_USERS_SAVED_REPLY = 'Users::SavedReply'; export const TYPE_WORKSPACE = 'RemoteDevelopment::Workspace'; diff --git a/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql b/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql index 85a28fe1f71..458fdb24e6d 100644 --- a/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql +++ b/app/assets/javascripts/graphql_shared/fragments/issue.fragment.graphql @@ -6,6 +6,7 @@ fragment IssueNode on Issue { iid title referencePath: reference(full: true) + closedAt dueDate timeEstimate totalTimeSpent diff --git a/app/assets/javascripts/graphql_shared/possible_types.json b/app/assets/javascripts/graphql_shared/possible_types.json index 4e0b1413f71..1439a3181b0 100644 --- a/app/assets/javascripts/graphql_shared/possible_types.json +++ b/app/assets/javascripts/graphql_shared/possible_types.json @@ -67,9 +67,11 @@ ], "MemberInterface": [ "GroupMember", + "PendingGroupMember", "ProjectMember" ], "NoteableInterface": [ + "AbuseReport", "AlertManagementAlert", "BoardEpic", "Design", diff --git a/app/assets/javascripts/graphql_shared/queries/groups_autocomplete.query.graphql b/app/assets/javascripts/graphql_shared/queries/groups_autocomplete.query.graphql new file mode 100644 index 00000000000..74da46e5a60 --- /dev/null +++ b/app/assets/javascripts/graphql_shared/queries/groups_autocomplete.query.graphql @@ -0,0 +1,10 @@ +query groupsAutocomplete($search: String) { + groups(search: $search) { + nodes { + id + name + fullName + avatarUrl + } + } +} diff --git a/app/assets/javascripts/groups/components/item_actions.vue b/app/assets/javascripts/groups/components/item_actions.vue index fc7cfffc22c..43689e6677b 100644 --- a/app/assets/javascripts/groups/components/item_actions.vue +++ b/app/assets/javascripts/groups/components/item_actions.vue @@ -57,7 +57,6 @@ export default { icon="ellipsis_v" no-caret :data-testid="`group-${group.id}-dropdown-button`" - data-qa-selector="group_dropdown_button" :data-qa-group-id="group.id" > <gl-dropdown-item diff --git a/app/assets/javascripts/groups/settings/init_access_dropdown.js b/app/assets/javascripts/groups/settings/init_access_dropdown.js index 4da38e0e641..f18f260097b 100644 --- a/app/assets/javascripts/groups/settings/init_access_dropdown.js +++ b/app/assets/javascripts/groups/settings/init_access_dropdown.js @@ -1,5 +1,5 @@ -import * as Sentry from '@sentry/browser'; import Vue from 'vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import AccessDropdown from './components/access_dropdown.vue'; export const initAccessDropdown = (el) => { diff --git a/app/assets/javascripts/groups_projects/components/transfer_locations.vue b/app/assets/javascripts/groups_projects/components/transfer_locations.vue index 997e2bc3138..5774065bff9 100644 --- a/app/assets/javascripts/groups_projects/components/transfer_locations.vue +++ b/app/assets/javascripts/groups_projects/components/transfer_locations.vue @@ -260,11 +260,7 @@ export default { > <gl-dropdown-divider /> </template> - <div - v-if="hasUserTransferLocations" - data-qa-selector="namespaces_list_users" - data-testid="user-transfer-locations" - > + <div v-if="hasUserTransferLocations" data-testid="user-transfer-locations"> <gl-dropdown-section-header>{{ $options.i18n.USERS }}</gl-dropdown-section-header> <gl-dropdown-item v-for="item in userTransferLocations" diff --git a/app/assets/javascripts/header.js b/app/assets/javascripts/header.js index 25a84d17379..095a2dc1324 100644 --- a/app/assets/javascripts/header.js +++ b/app/assets/javascripts/header.js @@ -1,3 +1,6 @@ +// TODO: Remove this with the removal of the old navigation. +// See https://gitlab.com/groups/gitlab-org/-/epics/11875. + import Vue from 'vue'; import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import { highCountTrim } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/header_search/index.js b/app/assets/javascripts/header_search/index.js index 2bbad5f3f98..7b26dd183ad 100644 --- a/app/assets/javascripts/header_search/index.js +++ b/app/assets/javascripts/header_search/index.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Translate from '~/vue_shared/translate'; import HeaderSearchApp from './components/app.vue'; import createStore from './store'; diff --git a/app/assets/javascripts/header_search/init.js b/app/assets/javascripts/header_search/init.js index 64502d13ee2..1c582ace480 100644 --- a/app/assets/javascripts/header_search/init.js +++ b/app/assets/javascripts/header_search/init.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { HEADER_INIT_EVENTS } from './constants'; async function eventHandler(callback = () => {}) { diff --git a/app/assets/javascripts/helpers/help_page_helper.js b/app/assets/javascripts/helpers/help_page_helper.js index 21d27b5fea9..fab0f17cd3b 100644 --- a/app/assets/javascripts/helpers/help_page_helper.js +++ b/app/assets/javascripts/helpers/help_page_helper.js @@ -1,6 +1,6 @@ import { joinPaths, setUrlFragment } from '~/lib/utils/url_utility'; -const HELP_PAGE_URL_ROOT = '/help/'; +const HELP_PAGE_URL_ROOT = '/help'; /** * Generate link to a GitLab documentation page. diff --git a/app/assets/javascripts/helpers/init_simple_app_helper.js b/app/assets/javascripts/helpers/init_simple_app_helper.js index 695fc455f13..f7bef8c563e 100644 --- a/app/assets/javascripts/helpers/init_simple_app_helper.js +++ b/app/assets/javascripts/helpers/init_simple_app_helper.js @@ -1,4 +1,26 @@ import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; + +/** + * @param {boolean|VueApollo} apolloProviderOption + * @returns {undefined | VueApollo} + */ +const getApolloProvider = (apolloProviderOption) => { + if (apolloProviderOption === true) { + Vue.use(VueApollo); + + return new VueApollo({ + defaultClient: createDefaultClient(), + }); + } + + if (apolloProviderOption instanceof VueApollo) { + return apolloProviderOption; + } + + return undefined; +}; /** * Initializes a component as a simple vue app, passing the necessary props. If the element @@ -8,6 +30,8 @@ import Vue from 'vue'; * * @param {string} selector css selector for where to build * @param {Vue.component} component The Vue compoment to be built as the root of the app + * @param {{withApolloProvider: boolean|VueApollo}} options. extra options to be passed to the vue app + * withApolloProvider: if true, instantiates a default apolloProvider. Also accepts and instance of VueApollo * * @example * ```html @@ -15,13 +39,13 @@ import Vue from 'vue'; * ``` * * ```javascript - * initSimpleApp('#mount-here', MyApp) + * initSimpleApp('#mount-here', MyApp, { withApolloProvider: true }) * ``` * * This will mount MyApp as root on '#mount-here'. It will receive {'some': 'object'} as it's * view model prop. */ -export const initSimpleApp = (selector, component) => { +export const initSimpleApp = (selector, component, { withApolloProvider } = {}) => { const element = document.querySelector(selector); if (!element) { @@ -32,6 +56,7 @@ export const initSimpleApp = (selector, component) => { return new Vue({ el: element, + apolloProvider: getApolloProvider(withApolloProvider), render(h) { return h(component, { props }); }, diff --git a/app/assets/javascripts/ide/components/ide_status_bar.vue b/app/assets/javascripts/ide/components/ide_status_bar.vue index 76b284b6185..984dc9edaf1 100644 --- a/app/assets/javascripts/ide/components/ide_status_bar.vue +++ b/app/assets/javascripts/ide/components/ide_status_bar.vue @@ -88,7 +88,6 @@ export default { @click="openRightPane($options.rightSidebarViews.pipelines)" > <ci-icon - v-gl-tooltip :status="latestPipeline.details.status" :title="latestPipeline.details.status.text" /> diff --git a/app/assets/javascripts/ide/components/jobs/detail/description.vue b/app/assets/javascripts/ide/components/jobs/detail/description.vue index f0c5b29e210..f5840661c17 100644 --- a/app/assets/javascripts/ide/components/jobs/detail/description.vue +++ b/app/assets/javascripts/ide/components/jobs/detail/description.vue @@ -25,9 +25,7 @@ export default { <template> <div class="d-flex align-items-center"> <ci-icon - is-borderless :status="job.status" - :size="24" class="gl-align-items-center gl-border gl-display-inline-flex gl-z-index-1" /> <span class="gl-ml-3"> diff --git a/app/assets/javascripts/ide/components/pipelines/list.vue b/app/assets/javascripts/ide/components/pipelines/list.vue index 6bf51ed06a6..0e07cc34dd8 100644 --- a/app/assets/javascripts/ide/components/pipelines/list.vue +++ b/app/assets/javascripts/ide/components/pipelines/list.vue @@ -63,7 +63,7 @@ export default { </div> <template v-else-if="hasLoadedPipeline"> <header v-if="latestPipeline" class="ide-tree-header ide-pipeline-header"> - <ci-icon :status="latestPipeline.details.status" :size="24" class="d-flex" /> + <ci-icon :status="latestPipeline.details.status" /> <span class="gl-ml-3"> <strong> {{ __('Pipeline') }} </strong> <a diff --git a/app/assets/javascripts/ide/components/repo_editor.vue b/app/assets/javascripts/ide/components/repo_editor.vue index 137df9aa102..3b59fe86764 100644 --- a/app/assets/javascripts/ide/components/repo_editor.vue +++ b/app/assets/javascripts/ide/components/repo_editor.vue @@ -8,7 +8,6 @@ import { EDITOR_TYPE_CODE, EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN, - EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, } from '~/editor/constants'; import { SourceEditorExtension } from '~/editor/extensions/source_editor_extension_base'; import { EditorWebIdeExtension } from '~/editor/extensions/source_editor_webide_ext'; @@ -30,6 +29,7 @@ import { viewerInformationForPath } from '~/vue_shared/components/content_viewer import DiffViewer from '~/vue_shared/components/diff_viewer/diff_viewer.vue'; import { markRaw } from '~/lib/utils/vue3compat/mark_raw'; import { readFileAsDataURL } from '~/lib/utils/file_utility'; +import { isDefaultCiConfig, hasCiConfigExtension } from '~/lib/utils/common_utils'; import { leftSidebarViews, @@ -152,8 +152,9 @@ export default { }, isCiConfigFile() { return ( - this.file.path === EXTENSION_CI_SCHEMA_FILE_NAME_MATCH && - this.editor?.getEditorType() === EDITOR_TYPE_CODE + // For CI config schemas the filename must match '*.gitlab-ci.yml' regardless of project configuration. + // https://gitlab.com/gitlab-org/gitlab/-/issues/293641 + hasCiConfigExtension(this.file.path) && this.editor?.getEditorType() === EDITOR_TYPE_CODE ); }, }, @@ -162,7 +163,7 @@ export default { handler() { this.stopWatchingCiYaml(); - if (this.file.name === '.gitlab-ci.yml') { + if (isDefaultCiConfig(this.file.name)) { this.startWatchingCiYaml(); } }, diff --git a/app/assets/javascripts/ide/lib/alerts/index.js b/app/assets/javascripts/ide/lib/alerts/index.js index c9db9779b1f..ac4eeb0386f 100644 --- a/app/assets/javascripts/ide/lib/alerts/index.js +++ b/app/assets/javascripts/ide/lib/alerts/index.js @@ -1,3 +1,4 @@ +import { isDefaultCiConfig } from '~/lib/utils/common_utils'; import { leftSidebarViews } from '../../constants'; import EnvironmentsMessage from './environments.vue'; @@ -6,7 +7,7 @@ const alerts = [ key: Symbol('ALERT_ENVIRONMENT'), show: (state, file) => state.currentActivityView === leftSidebarViews.commit.name && - file.path === '.gitlab-ci.yml' && + isDefaultCiConfig(file.path) && state.environmentsGuidanceAlertDetected && !state.environmentsGuidanceAlertDismissed, props: { variant: 'tip' }, diff --git a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js index bf0d3ed337c..5681f6cdec5 100644 --- a/app/assets/javascripts/ide/stores/modules/file_templates/getters.js +++ b/app/assets/javascripts/ide/stores/modules/file_templates/getters.js @@ -1,9 +1,10 @@ import { __ } from '~/locale'; +import { DEFAULT_CI_CONFIG_PATH } from '~/lib/utils/constants'; import { leftSidebarViews } from '../../../constants'; export const templateTypes = () => [ { - name: '.gitlab-ci.yml', + name: DEFAULT_CI_CONFIG_PATH, key: 'gitlab_ci_ymls', }, { diff --git a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js index 37f40af9c2e..8adde8f6b4e 100644 --- a/app/assets/javascripts/ide/stores/modules/terminal/mutations.js +++ b/app/assets/javascripts/ide/stores/modules/terminal/mutations.js @@ -48,7 +48,7 @@ export default { }, [types.SET_SESSION_STATUS](state, status) { const session = { - ...(state.session || {}), + ...state.session, status, }; diff --git a/app/assets/javascripts/import/constants.js b/app/assets/javascripts/import/constants.js index ddf69a8fcdf..b02eb3c4307 100644 --- a/app/assets/javascripts/import/constants.js +++ b/app/assets/javascripts/import/constants.js @@ -1,6 +1,18 @@ import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { __, s__ } from '~/locale'; +export const BULK_IMPORT_STATIC_ITEMS = { + badges: __('Badge'), + boards: s__('IssueBoards|Board'), + epics: __('Epic'), + issues: __('Issue'), + labels: __('Label'), + members: __('Member'), + merge_requests: __('Merge request'), + milestones: __('Milestone'), + project: __('Project'), +}; + const STATISTIC_ITEMS = { diff_note: __('Diff notes'), issue: __('Issues'), diff --git a/app/assets/javascripts/import/details/components/bulk_import_details_app.vue b/app/assets/javascripts/import/details/components/bulk_import_details_app.vue new file mode 100644 index 00000000000..5da16454032 --- /dev/null +++ b/app/assets/javascripts/import/details/components/bulk_import_details_app.vue @@ -0,0 +1,44 @@ +<script> +import { __ } from '~/locale'; +import ImportDetailsTable from '~/import/details/components/import_details_table.vue'; + +export default { + name: 'BulkImportDetailsApp', + components: { ImportDetailsTable }, + + fields: [ + { + key: 'relation', + label: __('Type'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'source_title', + label: __('Title'), + tdClass: 'gl-md-w-30 gl-word-break-word', + }, + { + key: 'error', + label: __('Error'), + }, + { + key: 'correlation_id_value', + label: __('Correlation ID'), + }, + ], + + LOCAL_STORAGE_KEY: 'gl-bulk-import-details-page-size', +}; +</script> + +<template> + <div> + <h1>{{ s__('Import|GitLab Migration details') }}</h1> + + <import-details-table + bulk-import + :fields="$options.fields" + :local-storage-key="$options.LOCAL_STORAGE_KEY" + /> + </div> +</template> diff --git a/app/assets/javascripts/import/details/components/import_details_app.vue b/app/assets/javascripts/import/details/components/import_details_app.vue index 13483fa8ba2..f654dc61e07 100644 --- a/app/assets/javascripts/import/details/components/import_details_app.vue +++ b/app/assets/javascripts/import/details/components/import_details_app.vue @@ -1,18 +1,44 @@ <script> -import { s__ } from '~/locale'; +import { __ } from '~/locale'; import ImportDetailsTable from './import_details_table.vue'; export default { + name: 'ImportDetailsApp', components: { ImportDetailsTable }, - i18n: { - pageTitle: s__('Import|GitHub import details'), - }, + + fields: [ + { + key: 'type', + label: __('Type'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'title', + label: __('Title'), + tdClass: 'gl-md-w-30 gl-word-break-word', + }, + { + key: 'provider_url', + label: __('URL'), + tdClass: 'gl-white-space-nowrap', + }, + { + key: 'details', + label: __('Details'), + }, + ], + + LOCAL_STORAGE_KEY: 'gl-import-details-page-size', }; </script> <template> <div> - <h1>{{ $options.i18n.pageTitle }}</h1> - <import-details-table /> + <h1>{{ s__('Import|GitHub import details') }}</h1> + + <import-details-table + :fields="$options.fields" + :local-storage-key="$options.LOCAL_STORAGE_KEY" + /> </div> </template> diff --git a/app/assets/javascripts/import/details/components/import_details_table.vue b/app/assets/javascripts/import/details/components/import_details_table.vue index 813dc1f2645..535ccb525ac 100644 --- a/app/assets/javascripts/import/details/components/import_details_table.vue +++ b/app/assets/javascripts/import/details/components/import_details_table.vue @@ -1,12 +1,13 @@ <script> import { GlEmptyState, GlIcon, GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import { createAlert } from '~/alert'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { getParameterValues } from '~/lib/utils/url_utility'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; -import { STATISTIC_ITEMS } from '../../constants'; +import { getBulkImportFailures } from '~/rest_api'; +import { BULK_IMPORT_STATIC_ITEMS, STATISTIC_ITEMS } from '../../constants'; import { fetchImportFailures } from '../api'; const DEFAULT_PAGE_SIZE = 20; @@ -21,28 +22,6 @@ export default { PaginationBar, }, STATISTIC_ITEMS, - LOCAL_STORAGE_KEY: 'gl-import-details-page-size', - fields: [ - { - key: 'type', - label: __('Type'), - tdClass: 'gl-white-space-nowrap', - }, - { - key: 'title', - label: __('Title'), - tdClass: 'gl-md-w-30 gl-word-break-word', - }, - { - key: 'provider_url', - label: __('URL'), - tdClass: 'gl-white-space-nowrap', - }, - { - key: 'details', - label: __('Details'), - }, - ], i18n: { fetchErrorMessage: s__('Import|An error occurred while fetching import details.'), @@ -55,6 +34,25 @@ export default { }, }, + props: { + bulkImport: { + type: Boolean, + required: false, + default: false, + }, + + fields: { + type: Array, + required: true, + }, + + localStorageKey: { + type: String, + required: false, + default: '', + }, + }, + data() { return { items: [], @@ -97,18 +95,28 @@ export default { this.loadImportFailures(); }, + fetchFn(params) { + return this.bulkImport + ? getBulkImportFailures( + getParameterValues('id')[0], + getParameterValues('entity_id')[0], + params, + ) + : fetchImportFailures(this.failuresPath, { + projectId: getParameterValues('project_id')[0], + ...params, + }); + }, + async loadImportFailures() { - if (!this.failuresPath) { + if (!this.bulkImport && !this.failuresPath) { return; } this.loading = true; + try { - const response = await fetchImportFailures(this.failuresPath, { - projectId: getParameterValues('project_id')[0], - page: this.page, - perPage: this.perPage, - }); + const response = await this.fetchFn({ page: this.page, perPage: this.perPage }); const { page, perPage, totalPages, total } = parseIntPagination( normalizeHeaders(response.headers), @@ -123,13 +131,17 @@ export default { } this.loading = false; }, + + itemTypeText(type) { + return (this.bulkImport ? BULK_IMPORT_STATIC_ITEMS[type] : STATISTIC_ITEMS[type]) || type; + }, }, }; </script> <template> <div> - <gl-table :fields="$options.fields" :items="items" class="gl-mt-5" :busy="loading" show-empty> + <gl-table :fields="fields" :items="items" class="gl-mt-5" :busy="loading" show-empty> <template #table-busy> <gl-loading-icon size="lg" class="gl-my-5" /> </template> @@ -139,7 +151,7 @@ export default { </template> <template #cell(type)="{ item: { type } }"> - {{ $options.STATISTIC_ITEMS[type] }} + {{ itemTypeText(type) }} </template> <template #cell(provider_url)="{ item: { provider_url } }"> <gl-link v-if="provider_url" :href="provider_url" target="_blank"> @@ -147,12 +159,30 @@ export default { <gl-icon name="external-link" /> </gl-link> </template> + + <template #cell(relation)="{ item: { relation } }"> + {{ itemTypeText(relation) }} + </template> + <template #cell(source_title)="{ item: { source_title, source_url } }"> + <gl-link v-if="source_url" :href="source_url" target="_blank"> + {{ source_title }} + <gl-icon name="external-link" /> + </gl-link> + <span v-else> + {{ source_title }} + </span> + </template> + <template #cell(error)="{ item: { exception_class, exception_message } }"> + <strong>{{ exception_class }}</strong> + <p>{{ exception_message }}</p> + </template> </gl-table> + <pagination-bar v-if="hasItems" :page-info="pageInfo" class="gl-mt-5" - :storage-key="$options.LOCAL_STORAGE_KEY" + :storage-key="localStorageKey" @set-page="setPage" @set-page-size="setPageSize" /> diff --git a/app/assets/javascripts/import_entities/components/import_status.vue b/app/assets/javascripts/import_entities/components/import_status.vue index 91436457b03..3cde3a8df3c 100644 --- a/app/assets/javascripts/import_entities/components/import_status.vue +++ b/app/assets/javascripts/import_entities/components/import_status.vue @@ -1,46 +1,9 @@ <script> import { GlAccordion, GlAccordionItem, GlBadge, GlIcon, GlLink } from '@gitlab/ui'; -import { __, s__ } from '~/locale'; +import { s__ } from '~/locale'; import { STATISTIC_ITEMS } from '~/import/constants'; -import { STATUSES } from '../constants'; - -const SCHEDULED_STATUS = { - icon: 'status-scheduled', - text: __('Pending'), - variant: 'muted', -}; - -const STATUS_MAP = { - [STATUSES.NONE]: { - icon: 'status-waiting', - text: __('Not started'), - variant: 'muted', - }, - [STATUSES.SCHEDULING]: SCHEDULED_STATUS, - [STATUSES.SCHEDULED]: SCHEDULED_STATUS, - [STATUSES.CREATED]: SCHEDULED_STATUS, - [STATUSES.STARTED]: { - icon: 'status-running', - text: __('Importing...'), - variant: 'info', - }, - [STATUSES.FAILED]: { - icon: 'status-failed', - text: __('Failed'), - variant: 'danger', - }, - [STATUSES.TIMEOUT]: { - icon: 'status-failed', - text: __('Timeout'), - variant: 'danger', - }, - [STATUSES.CANCELED]: { - icon: 'status-stopped', - text: __('Cancelled'), - variant: 'neutral', - }, -}; +import { STATUSES, STATUS_ICON_MAP } from '../constants'; function isIncompleteImport(stats) { return Object.keys(stats?.fetched ?? []).some( @@ -96,21 +59,11 @@ export default { }, mappedStatus() { - if (this.status === STATUSES.FINISHED) { - return this.isIncomplete - ? { - icon: 'status-alert', - text: s__('Import|Partially completed'), - variant: 'warning', - } - : { - icon: 'status-success', - text: __('Complete'), - variant: 'success', - }; + if (this.isIncomplete) { + return STATUS_ICON_MAP[STATUSES.PARTIAL]; } - return STATUS_MAP[this.status]; + return STATUS_ICON_MAP[this.status]; }, showDetails() { diff --git a/app/assets/javascripts/import_entities/constants.js b/app/assets/javascripts/import_entities/constants.js index 48b7febca4b..23604c7fb44 100644 --- a/app/assets/javascripts/import_entities/constants.js +++ b/app/assets/javascripts/import_entities/constants.js @@ -1,18 +1,65 @@ -// The `scheduling` status is only present on the client-side, -// it is used as the status when we are requesting to start an import. +import { __, s__ } from '~/locale'; export const STATUSES = { FINISHED: 'finished', FAILED: 'failed', SCHEDULED: 'scheduled', + SCHEDULING: 'scheduling', // only present client-side, used when user is requesting to start an import CREATED: 'created', STARTED: 'started', NONE: 'none', - SCHEDULING: 'scheduling', CANCELED: 'canceled', TIMEOUT: 'timeout', + PARTIAL: 'partial', // only present client-side, finished but with failures }; export const PROVIDERS = { GITHUB: 'github', }; + +const SCHEDULED_STATUS_ICON = { + icon: 'status-scheduled', + text: __('Pending'), + variant: 'muted', +}; + +export const STATUS_ICON_MAP = { + [STATUSES.NONE]: { + icon: 'status-waiting', + text: __('Not started'), + variant: 'muted', + }, + [STATUSES.SCHEDULING]: SCHEDULED_STATUS_ICON, + [STATUSES.SCHEDULED]: SCHEDULED_STATUS_ICON, + [STATUSES.CREATED]: SCHEDULED_STATUS_ICON, + [STATUSES.STARTED]: { + icon: 'status-running', + text: __('Importing...'), + variant: 'info', + }, + [STATUSES.FAILED]: { + icon: 'status-failed', + text: __('Failed'), + variant: 'danger', + }, + [STATUSES.TIMEOUT]: { + icon: 'status-failed', + text: __('Timeout'), + variant: 'danger', + }, + [STATUSES.CANCELED]: { + icon: 'status-stopped', + text: __('Cancelled'), + variant: 'neutral', + }, + [STATUSES.FINISHED]: { + icon: 'status-success', + text: __('Complete'), + variant: 'success', + }, + [STATUSES.PARTIAL]: { + icon: 'status-alert', + text: s__('Import|Partially completed'), + variant: 'warning', + }, +}; diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_status.vue b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue new file mode 100644 index 00000000000..cdb38cdf7f1 --- /dev/null +++ b/app/assets/javascripts/import_entities/import_groups/components/import_status.vue @@ -0,0 +1,83 @@ +<script> +import { GlBadge, GlLink } from '@gitlab/ui'; +import { mergeUrlParams } from '~/lib/utils/url_utility'; +import { STATUSES, STATUS_ICON_MAP } from '~/import_entities/constants'; + +export default { + components: { + GlBadge, + GlLink, + }, + + inject: { + detailsPath: { + default: undefined, + }, + }, + + props: { + id: { + type: Number, + required: false, + default: null, + }, + entityId: { + type: Number, + required: false, + default: null, + }, + hasFailures: { + type: Boolean, + required: false, + default: false, + }, + showDetailsLink: { + type: Boolean, + required: false, + default: false, + }, + status: { + type: String, + required: true, + }, + }, + + computed: { + isPartial() { + return this.status === STATUSES.FINISHED && this.hasFailures; + }, + + mappedStatus() { + if (this.isPartial) { + return STATUS_ICON_MAP[STATUSES.PARTIAL]; + } + + return STATUS_ICON_MAP[this.status]; + }, + + showDetails() { + return this.showDetailsLink && Boolean(this.detailsPathWithId) && this.hasFailures; + }, + + detailsPathWithId() { + if (!this.id || !this.entityId || !this.detailsPath) { + return null; + } + + return mergeUrlParams({ id: this.id, entity_id: this.entityId }, this.detailsPath); + }, + }, +}; +</script> + +<template> + <div> + <gl-badge :icon="mappedStatus.icon" :variant="mappedStatus.variant" size="md" icon-size="sm"> + {{ mappedStatus.text }} + </gl-badge> + + <div v-if="showDetails" class="gl-mt-2"> + <gl-link :href="detailsPathWithId">{{ s__('Import|See failures') }}</gl-link> + </div> + </div> +</template> diff --git a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue index 24197c680eb..df1e50cb433 100644 --- a/app/assets/javascripts/import_entities/import_groups/components/import_table.vue +++ b/app/assets/javascripts/import_entities/import_groups/components/import_table.vue @@ -42,9 +42,6 @@ import ImportTargetCell from './import_target_cell.vue'; const VALIDATION_DEBOUNCE_TIME = DEFAULT_DEBOUNCE_AND_THROTTLE_MS; const PAGE_SIZES = [20, 50, 100]; const DEFAULT_PAGE_SIZE = PAGE_SIZES[0]; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; -const DEFAULT_TD_CLASSES = 'gl-vertical-align-top!'; export default { components: { @@ -129,36 +126,28 @@ export default { { key: 'selected', label: '', - // eslint-disable-next-line @gitlab/require-i18n-strings - thClass: `${DEFAULT_TH_CLASSES} gl-w-3 gl-pr-3!`, - // eslint-disable-next-line @gitlab/require-i18n-strings - tdClass: `${DEFAULT_TD_CLASSES} gl-pr-3!`, + thClass: 'gl-w-3 gl-pr-3!', + tdClass: 'gl-pr-3!', }, { key: 'webUrl', label: s__('BulkImport|Source group'), - thClass: `${DEFAULT_TH_CLASSES} gl-pl-0! gl-w-half`, - // eslint-disable-next-line @gitlab/require-i18n-strings - tdClass: `${DEFAULT_TD_CLASSES} gl-pl-0!`, + thClass: 'gl-pl-0! gl-w-half', + tdClass: 'gl-pl-0!', }, { key: 'importTarget', label: s__('BulkImport|New group'), - thClass: `${DEFAULT_TH_CLASSES} gl-w-half`, - tdClass: DEFAULT_TD_CLASSES, + thClass: `gl-w-half`, }, { key: 'progress', label: __('Status'), - thClass: `${DEFAULT_TH_CLASSES}`, - tdClass: DEFAULT_TD_CLASSES, tdAttr: { 'data-qa-selector': 'import_status_indicator' }, }, { key: 'actions', label: '', - thClass: `${DEFAULT_TH_CLASSES}`, - tdClass: DEFAULT_TD_CLASSES, }, ], diff --git a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js index 1aad22f0f3f..c2e35ce8270 100644 --- a/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js +++ b/app/assets/javascripts/import_entities/import_groups/graphql/services/local_storage_cache.js @@ -60,7 +60,7 @@ export class LocalStorageCache { updateStatusByJobId(jobId, status) { this.getCacheKeysByJobId(jobId).forEach((webUrl) => this.set(webUrl, { - ...(this.get(webUrl) ?? {}), + ...this.get(webUrl), progress: { id: jobId, status, diff --git a/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue index cf1a4de68ed..d22a52df326 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/advanced_settings.vue @@ -41,7 +41,7 @@ export default { :key="name" :checked="value[name]" :data-qa-option-name="name" - data-qa-selector="advanced_settings_checkbox" + data-testid="advanced-settings-checkbox" @change="$emit('input', { ...value, [name]: $event })" > {{ label }} diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue index 5d5965e33da..72c6f45cdc9 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/github_organizations_box.vue @@ -1,6 +1,6 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlCollapsibleListbox } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import { __, s__ } from '~/locale'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue index cb3476c48db..5931e0d307a 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/github_status_table.vue @@ -78,7 +78,6 @@ export default { @input="setFilter({ organization_login: $event })" /> <gl-search-box-by-click - data-qa-selector="githubish_import_filter_field" name="filter" :disabled="isNameFilterDisabled" :value="nameFilter" diff --git a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue index 009945f8b9b..d98132382c6 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/import_projects_table.vue @@ -155,7 +155,6 @@ export default { <slot name="actions"></slot> <form v-if="filterable" class="gl-ml-auto" novalidate @submit.prevent> <gl-search-box-by-click - data-qa-selector="githubish_import_filter_field" name="filter" :placeholder="__('Filter by name')" autofocus diff --git a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue index d75ba53d727..9b5aff45375 100644 --- a/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue +++ b/app/assets/javascripts/import_entities/import_projects/components/provider_repo_table_row.vue @@ -159,7 +159,7 @@ export default { <template> <tr class="gl-h-11" - data-qa-selector="project_import_row" + data-testid="project-import-row" :data-qa-source-project="repo.importSource.fullName" > <td> @@ -174,7 +174,7 @@ export default { :href="repo.importedProject.fullPath" class="gl-font-sm" target="_blank" - data-qa-selector="go_to_project_link" + data-testid="go-to-project-link" > {{ displayFullPath }} </gl-link> @@ -182,7 +182,7 @@ export default { </gl-sprintf> </div> </td> - <td data-testid="fullPath" data-qa-selector="project_path_content"> + <td data-testid="fullPath"> <div class="gl-display-flex gl-sm-flex-wrap"> <template v-if="repo.importSource.target">{{ repo.importSource.target }}</template> <template v-else-if="isImportNotStarted || isSelectedForReimport"> @@ -201,14 +201,14 @@ export default { ref="newNameInput" v-model="newNameInput" class="gl-rounded-top-left-none gl-rounded-bottom-left-none" - data-qa-selector="project_path_field" + data-testid="project-path-field" /> </div> </template> <template v-else-if="repo.importedProject">{{ displayFullPath }}</template> </div> </td> - <td data-qa-selector="import_status_indicator"> + <td data-testid="import-status-indicator"> <import-status :project-id="importedProjectId" :status="importStatus" :stats="stats" /> </td> <td data-testid="actions" class="gl-white-space-nowrap"> @@ -235,7 +235,7 @@ export default { <gl-button v-if="isImportNotStarted || isFinished" type="button" - data-qa-selector="import_button" + data-testid="import-button" @click="handleImportRepo()" > {{ importButtonText }} diff --git a/app/assets/javascripts/import_entities/import_projects/store/actions.js b/app/assets/javascripts/import_entities/import_projects/store/actions.js index 4305f8d4db5..e5cbac71ce0 100644 --- a/app/assets/javascripts/import_entities/import_projects/store/actions.js +++ b/app/assets/javascripts/import_entities/import_projects/store/actions.js @@ -83,7 +83,7 @@ const fetchReposFactory = ({ reposPath = isRequired() }) => ({ state, commit }) .get( pathWithParams({ path: reposPath, - ...(filter ?? {}), + ...filter, ...paginationParams({ state }), }), ) diff --git a/app/assets/javascripts/incidents/components/incidents_list.vue b/app/assets/javascripts/incidents/components/incidents_list.vue index 0e1afebbe2b..727ab43435d 100644 --- a/app/assets/javascripts/incidents/components/incidents_list.vue +++ b/app/assets/javascripts/incidents/components/incidents_list.vue @@ -87,12 +87,11 @@ export default { { key: 'incidentSla', label: s__('IncidentManagement|Time to SLA'), - thClass: `gl-text-right gl-w-10p`, + thClass: `${thClass} gl-text-right gl-w-10p`, tdClass: `${tdClass} gl-text-right`, thAttr: TH_INCIDENT_SLA_TEST_ID, actualSortKey: 'SLA_DUE_AT', sortable: true, - sortDirection: 'asc', }, { key: 'assignees', diff --git a/app/assets/javascripts/integrations/edit/components/integration_form.vue b/app/assets/javascripts/integrations/edit/components/integration_form.vue index fa9a59212eb..281666a021d 100644 --- a/app/assets/javascripts/integrations/edit/components/integration_form.vue +++ b/app/assets/javascripts/integrations/edit/components/integration_form.vue @@ -1,9 +1,9 @@ <script> import { GlAlert, GlForm } from '@gitlab/ui'; import axios from 'axios'; -import * as Sentry from '@sentry/browser'; // eslint-disable-next-line no-restricted-imports import { mapState, mapActions, mapGetters } from 'vuex'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__ } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { diff --git a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue index a8389e32b40..356557442db 100644 --- a/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue +++ b/app/assets/javascripts/integrations/overrides/components/integration_overrides.vue @@ -1,6 +1,6 @@ <script> import { GlLink, GlLoadingIcon, GlPagination, GlTable, GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { DEFAULT_PER_PAGE } from '~/api'; import { fetchOverrides } from '~/integrations/overrides/api'; diff --git a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue index 4b492e48095..ceb9200dfad 100644 --- a/app/assets/javascripts/invite_members/components/invite_groups_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_groups_modal.vue @@ -1,7 +1,7 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlAlert } from '@gitlab/ui'; import { uniqueId } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api from '~/api'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; diff --git a/app/assets/javascripts/invite_members/components/invite_members_modal.vue b/app/assets/javascripts/invite_members/components/invite_members_modal.vue index 509efd31dcd..1a10130e969 100644 --- a/app/assets/javascripts/invite_members/components/invite_members_modal.vue +++ b/app/assets/javascripts/invite_members/components/invite_members_modal.vue @@ -1,13 +1,17 @@ <script> -import { GlAlert, GlButton, GlCollapse, GlIcon } from '@gitlab/ui'; +import { GlAlert, GlButton, GlCollapse, GlLink, GlIcon, GlSprintf } from '@gitlab/ui'; import { partition, isString, uniqueId, isEmpty } from 'lodash'; import SafeHtml from '~/vue_shared/directives/safe_html'; import InviteModalBase from 'ee_else_ce/invite_members/components/invite_modal_base.vue'; import Api from '~/api'; import Tracking from '~/tracking'; import { BV_SHOW_MODAL, BV_HIDE_MODAL } from '~/lib/utils/constants'; -import { n__, sprintf } from '~/locale'; -import { memberName, triggerExternalAlert } from 'ee_else_ce/invite_members/utils/member_utils'; +import { n__, s__, sprintf } from '~/locale'; +import { + memberName, + triggerExternalAlert, + inviteMembersTrackingOptions, +} from 'ee_else_ce/invite_members/utils/member_utils'; import { captureException } from '~/ci/runner/sentry_utils'; import { USERS_FILTER_ALL, @@ -31,7 +35,9 @@ export default { GlAlert, GlButton, GlCollapse, + GlLink, GlIcon, + GlSprintf, InviteModalBase, MembersTokenSelect, ModalConfetti, @@ -43,6 +49,17 @@ export default { SafeHtml, }, mixins: [Tracking.mixin({ category: INVITE_MEMBER_MODAL_TRACKING_CATEGORY })], + inject: { + isCurrentUserAdmin: { + default: false, + }, + isEmailSignupEnabled: { + default: true, + }, + newUsersUrl: { + default: '', + }, + }, props: { id: { type: String, @@ -122,6 +139,12 @@ export default { isCelebration() { return this.mode === 'celebrate'; }, + baseTrackingDetails() { + return { label: this.source, celebrate: this.isCelebration }; + }, + isTextForAdmin() { + return this.isCurrentUserAdmin && Boolean(this.newUsersUrl); + }, modalTitle() { return this.$options.labels.modal[this.mode].title; }, @@ -131,6 +154,11 @@ export default { labelIntroText() { return this.$options.labels[this.inviteTo][this.mode].introText; }, + labelSearchField() { + return this.isEmailSignupEnabled + ? this.$options.labels.searchField + : s__('InviteMembersModal|Username'); + }, isEmptyInvites() { return Boolean(this.newUsersToInvite.length); }, @@ -144,6 +172,14 @@ export default { this.errorList.length, ); }, + signupDisabledText() { + return s__( + "InviteMembersModal|Administrators can %{linkStart}add new users by email manually%{linkEnd}. After they've been added, you can invite them to this group with their username.", + ); + }, + signupDisabledTitle() { + return s__('InviteMembersModal|Inviting users by email is disabled'); + }, showUserLimitNotification() { return !isEmpty(this.usersLimitDataset.alertVariant); }, @@ -173,8 +209,13 @@ export default { count: this.errorsExpanded.length, }); }, + formGroupDescriptionText() { + return this.isEmailSignupEnabled + ? this.$options.labels.placeHolder + : s__('InviteMembersModal|Select members'); + }, formGroupDescription() { - return this.invalidFeedbackMessage ? null : this.$options.labels.placeHolder; + return this.invalidFeedbackMessage ? null : this.formGroupDescriptionText; }, }, watch: { @@ -218,13 +259,13 @@ export default { this.source = source; this.$root.$emit(BV_SHOW_MODAL, this.modalId); - this.track('render', { label: this.source }); + this.track('render', inviteMembersTrackingOptions(this.baseTrackingDetails)); }, closeModal() { this.$root.$emit(BV_HIDE_MODAL, this.modalId); }, showEmptyInvitesAlert() { - this.invalidFeedbackMessage = this.$options.labels.placeHolder; + this.invalidFeedbackMessage = this.formGroupDescriptionText; this.shouldShowEmptyInvitesAlert = true; this.$refs.alerts.focus(); }, @@ -287,10 +328,10 @@ export default { return this.newUsersToInvite.find((member) => memberName(member) === username)?.name; }, onCancel() { - this.track('click_cancel', { label: this.source }); + this.track('click_cancel', inviteMembersTrackingOptions(this.baseTrackingDetails)); }, onClose() { - this.track('click_x', { label: this.source }); + this.track('click_x', inviteMembersTrackingOptions(this.baseTrackingDetails)); }, resetFields() { this.clearValidation(); @@ -299,7 +340,7 @@ export default { this.newUsersToInvite = []; }, onInviteSuccess() { - this.track('invite_successful', { label: this.source }); + this.track('invite_successful', inviteMembersTrackingOptions(this.baseTrackingDetails)); if (this.reloadPageOnSubmit) { reloadOnInvitationSuccess(); @@ -345,7 +386,7 @@ export default { :default-access-level="defaultAccessLevel" :help-link="helpLink" :label-intro-text="labelIntroText" - :label-search-field="$options.labels.searchField" + :label-search-field="labelSearchField" :form-group-description="formGroupDescription" :invalid-feedback-message="invalidFeedbackMessage" :is-loading="isLoading" @@ -429,6 +470,24 @@ export default { </gl-button> </template> </gl-alert> + <gl-alert + v-if="!isEmailSignupEnabled" + id="signup-disabled-alert" + :dismissible="false" + :title="signupDisabledTitle" + class="gl-mb-4" + variant="warning" + data-testid="email-signup-disabled-alert" + > + <gl-sprintf :message="signupDisabledText"> + <template #link="{ content }"> + <gl-link v-if="isTextForAdmin" :href="newUsersUrl" target="_blank">{{ + content + }}</gl-link> + <span v-else>{{ content }}</span> + </template> + </gl-sprintf> + </gl-alert> <user-limit-notification v-else-if="showUserLimitNotification" class="gl-mb-5" @@ -447,6 +506,7 @@ export default { v-model="newUsersToInvite" class="gl-mb-2" aria-labelledby="empty-invites-alert" + :can-use-email-token="isEmailSignupEnabled" :input-id="inputId" :exception-state="exceptionState" :users-filter="usersFilter" diff --git a/app/assets/javascripts/invite_members/components/invite_modal_base.vue b/app/assets/javascripts/invite_members/components/invite_modal_base.vue index 18d22395104..a14dcd38aa7 100644 --- a/app/assets/javascripts/invite_members/components/invite_modal_base.vue +++ b/app/assets/javascripts/invite_members/components/invite_modal_base.vue @@ -297,7 +297,7 @@ export default { </gl-form-group> <gl-form-group - class="gl-w-half gl-xs-w-full" + class="gl-sm-w-half gl-w-full" :label="$options.ACCESS_LEVEL" :label-for="dropdownId" > @@ -317,7 +317,7 @@ export default { </gl-form-group> <gl-form-group - class="gl-w-half gl-xs-w-full" + class="gl-sm-w-half gl-w-full" :label="$options.ACCESS_EXPIRE_DATE" :label-for="datepickerId" > @@ -338,10 +338,10 @@ export default { <template #modal-footer> <div - class="gl-m-0 gl-xs-w-full gl-display-flex gl-xs-flex-direction-column! gl-flex-direction-row-reverse" + class="gl-m-0 gl-w-full gl-display-flex gl-xs-flex-direction-column! gl-flex-direction-row-reverse" > <gl-button - class="gl-xs-w-full gl-xs-mb-3! gl-sm-ml-3!" + class="gl-w-full gl-sm-w-auto gl-xs-mb-3! gl-sm-ml-3!" data-testid="invite-modal-submit" v-bind="actionPrimary.attributes" @click="onSubmit" @@ -350,7 +350,7 @@ export default { </gl-button> <gl-button - class="gl-xs-w-full" + class="gl-w-full gl-sm-w-auto" data-testid="invite-modal-cancel" v-bind="actionCancel.attributes" @click="onCancel" diff --git a/app/assets/javascripts/invite_members/components/members_token_select.vue b/app/assets/javascripts/invite_members/components/members_token_select.vue index 8493787f075..0be04b7af35 100644 --- a/app/assets/javascripts/invite_members/components/members_token_select.vue +++ b/app/assets/javascripts/invite_members/components/members_token_select.vue @@ -21,6 +21,11 @@ export default { GlSprintf, }, props: { + canUseEmailToken: { + type: Boolean, + required: false, + default: true, + }, placeholder: { type: String, required: false, @@ -68,6 +73,10 @@ export default { }, computed: { emailIsValid() { + if (!this.canUseEmailToken) { + return false; + } + const regex = /^\S+@\S+$/; return this.originalInput.match(regex) !== null; @@ -137,9 +146,8 @@ export default { username: token.username, avatar_url: token.avatar_url, })); - this.loading = false; }) - .catch(() => { + .finally(() => { this.loading = false; }); }, SEARCH_DELAY), diff --git a/app/assets/javascripts/invite_members/components/project_select.vue b/app/assets/javascripts/invite_members/components/project_select.vue index 640df5cdb88..6c2f53afe3c 100644 --- a/app/assets/javascripts/invite_members/components/project_select.vue +++ b/app/assets/javascripts/invite_members/components/project_select.vue @@ -115,7 +115,6 @@ export default { :search-placeholder="$options.i18n.searchPlaceholder" :no-results-text="$options.i18n.emptySearchResult" data-testid="project-select-dropdown" - data-qa-selector="project_select_dropdown" class="gl-collapsible-listbox-w-full" @search="searchTerm = $event" @select="selectProject" diff --git a/app/assets/javascripts/invite_members/init_invite_members_modal.js b/app/assets/javascripts/invite_members/init_invite_members_modal.js index 41ed0179364..8dfe697e2cb 100644 --- a/app/assets/javascripts/invite_members/init_invite_members_modal.js +++ b/app/assets/javascripts/invite_members/init_invite_members_modal.js @@ -25,6 +25,9 @@ export default (function initInviteMembersModal() { name: 'InviteMembersModalRoot', provide: { name: el.dataset.name, + newUsersUrl: el.dataset.newUsersUrl, + isCurrentUserAdmin: parseBoolean(el.dataset.isCurrentUserAdmin), + isEmailSignupEnabled: parseBoolean(el.dataset.isSignupEnabled), }, render: (createElement) => createElement(InviteMembersModal, { diff --git a/app/assets/javascripts/invite_members/utils/member_utils.js b/app/assets/javascripts/invite_members/utils/member_utils.js index 7998cb69445..52fb5e98f27 100644 --- a/app/assets/javascripts/invite_members/utils/member_utils.js +++ b/app/assets/javascripts/invite_members/utils/member_utils.js @@ -6,3 +6,7 @@ export function memberName(member) { export function triggerExternalAlert() { return false; } + +export function inviteMembersTrackingOptions(options) { + return { label: options.label }; +} diff --git a/app/assets/javascripts/issuable/components/locked_badge.vue b/app/assets/javascripts/issuable/components/locked_badge.vue index f97ac888417..652d02e8f9d 100644 --- a/app/assets/javascripts/issuable/components/locked_badge.vue +++ b/app/assets/javascripts/issuable/components/locked_badge.vue @@ -20,9 +20,12 @@ export default { }, computed: { title() { - return sprintf(__('This %{issuable} is locked. Only project members can comment.'), { - issuable: issuableTypeText[this.issuableType], - }); + return sprintf( + __('The discussion in this %{issuable} is locked. Only project members can comment.'), + { + issuable: issuableTypeText[this.issuableType], + }, + ); }, }, }; diff --git a/app/assets/javascripts/issuable/components/related_issuable_item.vue b/app/assets/javascripts/issuable/components/related_issuable_item.vue index 71bd301162e..126a3a84d66 100644 --- a/app/assets/javascripts/issuable/components/related_issuable_item.vue +++ b/app/assets/javascripts/issuable/components/related_issuable_item.vue @@ -88,6 +88,9 @@ export default { workItemIid() { return String(this.iid); }, + pipelinePath() { + return this.pipelineStatus?.details_path || this.pipelineStatus?.detailsPath; + }, }, methods: { handleTitleClick(event) { @@ -191,16 +194,16 @@ export default { <div class="item-attributes-area gl-display-flex gl-align-items-center gl-flex-wrap gl-gap-3" > - <span v-if="hasPipeline" class="mr-ci-status order-md-last"> - <a :href="pipelineStatus.details_path"> - <ci-icon v-gl-tooltip :status="pipelineStatus" :title="pipelineStatusTooltip" /> + <span v-if="hasPipeline" class="mr-ci-status order-md-last gl-md-ml-3 gl-mr-n2"> + <a :href="pipelinePath"> + <ci-icon :status="pipelineStatus" :title="pipelineStatusTooltip" /> </a> </span> <issue-milestone v-if="hasMilestone" :milestone="milestone" - class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first" + class="item-milestone gl-font-sm gl-display-flex gl-align-items-center order-md-first gl-ml-2" /> <!-- Flex order for slots is defined in the parent component: e.g. related_issues_block.vue --> diff --git a/app/assets/javascripts/issuable/components/status_badge.vue b/app/assets/javascripts/issuable/components/status_badge.vue index 949fb3c1ce5..35f6446d582 100644 --- a/app/assets/javascripts/issuable/components/status_badge.vue +++ b/app/assets/javascripts/issuable/components/status_badge.vue @@ -14,29 +14,29 @@ import { const badgePropertiesMap = { [TYPE_EPIC]: { [STATUS_OPEN]: { - icon: 'epic', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, [STATUS_CLOSED]: { - icon: 'epic-closed', + icon: 'issue-close', text: __('Closed'), variant: 'info', }, }, [TYPE_ISSUE]: { [STATUS_OPEN]: { - icon: 'issues', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, [STATUS_CLOSED]: { - icon: 'issue-closed', + icon: 'issue-close', text: __('Closed'), variant: 'info', }, [STATUS_LOCKED]: { - icon: 'issues', + icon: 'issue-open-m', text: __('Open'), variant: 'success', }, diff --git a/app/assets/javascripts/issuable/popover/components/mr_popover.vue b/app/assets/javascripts/issuable/popover/components/mr_popover.vue index e2c2181684f..80ae8ed8cf6 100644 --- a/app/assets/javascripts/issuable/popover/components/mr_popover.vue +++ b/app/assets/javascripts/issuable/popover/components/mr_popover.vue @@ -96,14 +96,14 @@ export default { </gl-skeleton-loader> <div v-else-if="showDetails" class="d-flex align-items-center justify-content-between"> <div class="d-inline-flex align-items-center"> - <gl-badge class="gl-mr-3" :variant="badgeVariant"> + <gl-badge class="gl-mr-2" :variant="badgeVariant"> {{ stateHumanName }} </gl-badge> <span class="gl-text-secondary"> {{ __('Opened') }} <time v-text="formattedTime"></time ></span> </div> - <ci-icon v-if="detailedStatus" :status="detailedStatus" /> + <ci-icon v-if="detailedStatus" :status="detailedStatus" class="gl-ml-2" /> </div> <h5 v-if="!$apollo.queries.mergeRequest.loading" class="my-2">{{ title }}</h5> <!-- eslint-disable @gitlab/vue-require-i18n-strings --> diff --git a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue index a756229e6ca..b6465cf6c68 100644 --- a/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue +++ b/app/assets/javascripts/issues/dashboard/components/issues_dashboard_app.vue @@ -5,7 +5,7 @@ import { GlFilteredSearchToken, GlTooltipDirective, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; @@ -165,9 +165,6 @@ export default { skip() { return !this.hasSearch; }, - context: { - isSingleRequest: true, - }, }, }, computed: { diff --git a/app/assets/javascripts/issues/issue.js b/app/assets/javascripts/issues/issue.js index 06bbcdc12ea..b83db65caa6 100644 --- a/app/assets/javascripts/issues/issue.js +++ b/app/assets/javascripts/issues/issue.js @@ -53,6 +53,8 @@ export default class Issue { $(document).trigger('issuable:change', isClosed); + // TODO: Remove this with the removal of the old navigation. + // See https://gitlab.com/groups/gitlab-org/-/epics/11875. let numProjectIssues = Number( projectIssuesCounter.first().text().trim().replace(/[^\d]/, ''), ); diff --git a/app/assets/javascripts/issues/list/components/issues_list_app.vue b/app/assets/javascripts/issues/list/components/issues_list_app.vue index 16e687cff10..72bb88ef1d5 100644 --- a/app/assets/javascripts/issues/list/components/issues_list_app.vue +++ b/app/assets/javascripts/issues/list/components/issues_list_app.vue @@ -9,11 +9,11 @@ import { GlDrawer, GlLink, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import produce from 'immer'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isEmpty } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; @@ -277,9 +277,6 @@ export default { skip() { return !this.hasAnyIssues || isEmpty(this.pageParams); }, - context: { - isSingleRequest: true, - }, }, }, computed: { @@ -910,7 +907,7 @@ export default { v-if="issuesDrawerEnabled" :open="isIssuableSelected" header-height="calc(var(--top-bar-height) + var(--performance-bar-height))" - class="gl-w-40p gl-xs-w-full" + class="gl-w-full gl-sm-w-40p" @close="activeIssuable = null" > <template #title> @@ -1030,7 +1027,10 @@ export default { :export-csv-path="exportCsvPathWithQuery" :issuable-count="currentTabCount" /> - <gl-disclosure-dropdown-group :bordered="true" :group="subscribeDropdownOptions" /> + <gl-disclosure-dropdown-group + :bordered="showCsvButtons" + :group="subscribeDropdownOptions" + /> </gl-disclosure-dropdown> </template> diff --git a/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue index 4b59672428b..eb7bcf70563 100644 --- a/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue +++ b/app/assets/javascripts/issues/service_desk/components/service_desk_list_app.vue @@ -1,7 +1,7 @@ <script> -import * as Sentry from '@sentry/browser'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { isEmpty } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { fetchPolicies } from '~/lib/graphql'; import { isPositiveInteger } from '~/lib/utils/number_utils'; import axios from '~/lib/utils/axios_utils'; @@ -166,9 +166,6 @@ export default { skip() { return this.shouldSkipQuery; }, - context: { - isSingleRequest: true, - }, }, }, computed: { diff --git a/app/assets/javascripts/issues/show/components/fields/description.vue b/app/assets/javascripts/issues/show/components/fields/description.vue index 10323b99665..1f159e71da9 100644 --- a/app/assets/javascripts/issues/show/components/fields/description.vue +++ b/app/assets/javascripts/issues/show/components/fields/description.vue @@ -79,7 +79,6 @@ export default { :autocomplete-data-sources="autocompleteDataSources" supports-quick-actions autofocus - data-qa-selector="description_field" @input="$emit('input', $event)" @keydown.meta.enter="saveIssuable" @keydown.ctrl.enter="saveIssuable" diff --git a/app/assets/javascripts/issues/show/components/header_actions.vue b/app/assets/javascripts/issues/show/components/header_actions.vue index dee4c536afa..32df19dfe44 100644 --- a/app/assets/javascripts/issues/show/components/header_actions.vue +++ b/app/assets/javascripts/issues/show/components/header_actions.vue @@ -1,17 +1,17 @@ <script> import { GlButton, - GlDropdown, + GlDisclosureDropdown, GlDropdownDivider, - GlDropdownItem, + GlDisclosureDropdownItem, GlLink, GlModal, GlModalDirective, GlTooltipDirective, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import { STATUS_CLOSED, TYPE_ISSUE, issuableTypeText } from '~/issues/constants'; @@ -59,9 +59,9 @@ export default { components: { DeleteIssueModal, GlButton, - GlDropdown, + GlDisclosureDropdown, GlDropdownDivider, - GlDropdownItem, + GlDisclosureDropdownItem, GlLink, GlModal, AbuseCategorySelector, @@ -184,6 +184,18 @@ export default { showMovedSidebarOptions() { return this.isMrSidebarMoved && this.isUserSignedIn; }, + newIssueItem() { + return { + text: this.newIssueTypeText, + href: this.newIssuePath, + }; + }, + submitSpamItem() { + return { + text: __('Submit as spam'), + href: this.submitAsSpamPath, + }; + }, }, created() { eventHub.$on('toggle.issuable.state', this.toggleIssueState); @@ -197,6 +209,7 @@ export default { toggleIssueState() { if (!this.isClosed && this.getBlockedByIssues?.length) { this.$refs.blockedByIssuesModal.show(); + this.closeActionsDropdown(); return; } @@ -204,6 +217,7 @@ export default { }, toggleReportAbuseDrawer(isOpen) { this.isReportAbuseDrawerOpen = isOpen; + this.closeActionsDropdown(); }, invokeUpdateIssueMutation() { this.toggleStateButtonLoading(true); @@ -237,6 +251,7 @@ export default { .catch(() => createAlert({ message: __('Error occurred while updating the issue status') })) .finally(() => { this.toggleStateButtonLoading(false); + this.closeActionsDropdown(); }); }, promoteToEpic() { @@ -267,16 +282,24 @@ export default { .catch(() => createAlert({ message: this.$options.i18n.promoteErrorMessage })) .finally(() => { this.toggleStateButtonLoading(false); + this.closeActionsDropdown(); }); }, edit() { issuesEventHub.$emit('open.form'); + this.closeActionsDropdown(); }, copyReference() { toast(__('Reference copied')); + this.closeActionsDropdown(); }, copyEmailAddress() { toast(__('Email address copied')); + this.closeActionsDropdown(); + }, + closeActionsDropdown() { + this.$refs.issuableActionsDropdownMobile?.close(); + this.$refs.issuableActionsDropdownDesktop?.close(); }, }, TYPE_ISSUE, @@ -285,87 +308,90 @@ export default { <template> <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-sm-gap-3"> - <gl-dropdown - v-if="hasMobileDropdown" - class="gl-sm-display-none! w-100" - block - :text="dropdownText" - data-testid="mobile-dropdown" - :loading="isToggleStateButtonLoading" - > - <template v-if="showMovedSidebarOptions"> - <sidebar-subscriptions-widget - :iid="String(iid)" - :full-path="fullPath" - :issuable-type="$options.TYPE_ISSUE" - data-testid="notification-toggle" - /> + <div class="gl-sm-display-none! w-100"> + <gl-disclosure-dropdown + v-if="hasMobileDropdown" + ref="issuableActionsDropdownMobile" + toggle-class="gl-w-full" + block + :toggle-text="dropdownText" + :auto-close="false" + data-testid="mobile-dropdown" + :loading="isToggleStateButtonLoading" + placement="right" + > + <template v-if="showMovedSidebarOptions"> + <sidebar-subscriptions-widget + :iid="String(iid)" + :full-path="fullPath" + :issuable-type="$options.TYPE_ISSUE" + data-testid="notification-toggle" + /> - <gl-dropdown-divider /> - </template> + <gl-dropdown-divider /> + </template> - <template v-if="showLockIssueOption"> - <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" /> - </template> + <template v-if="showLockIssueOption"> + <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" /> + </template> - <gl-dropdown-item v-if="canUpdateIssue" @click="edit"> - {{ $options.i18n.edit }} - </gl-dropdown-item> - <gl-dropdown-item - v-if="showToggleIssueStateButton" - :data-testid="`mobile_${qaSelector}`" - @click="toggleIssueState" - > - {{ buttonText }} - </gl-dropdown-item> - <gl-dropdown-item v-if="canCreateIssue" :href="newIssuePath"> - {{ newIssueTypeText }} - </gl-dropdown-item> - <gl-dropdown-item v-if="canPromoteToEpic" @click="promoteToEpic"> - {{ __('Promote to epic') }} - </gl-dropdown-item> - <template v-if="isMrSidebarMoved"> - <gl-dropdown-item - :data-clipboard-text="issuableReference" - button-class="js-copy-reference" - data-testid="copy-reference" - @click="copyReference" - >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item - > - <gl-dropdown-item - v-if="issuableEmailAddress && showMovedSidebarOptions" - :data-clipboard-text="issuableEmailAddress" - data-testid="copy-email" - @click="copyEmailAddress" - >{{ copyMailAddressText }}</gl-dropdown-item + <gl-disclosure-dropdown-item v-if="canUpdateIssue" @action="edit"> + <template #list-item>{{ $options.i18n.edit }}</template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item + v-if="showToggleIssueStateButton" + :data-testid="`mobile_${qaSelector}`" + @action="toggleIssueState" > - </template> - <gl-dropdown-item - v-if="canReportSpam" - :href="submitAsSpamPath" - data-method="post" - rel="nofollow" - > - {{ __('Submit as spam') }} - </gl-dropdown-item> - <template v-if="canDestroyIssue"> - <gl-dropdown-divider /> - <gl-dropdown-item - v-gl-modal="$options.deleteModalId" - variant="danger" - @click="track('click_dropdown')" + <template #list-item>{{ buttonText }}</template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item v-if="canCreateIssue" :item="newIssueItem" /> + <gl-disclosure-dropdown-item v-if="canPromoteToEpic" @action="promoteToEpic"> + <template #list-item>{{ __('Promote to epic') }}</template> + </gl-disclosure-dropdown-item> + <template v-if="isMrSidebarMoved"> + <gl-disclosure-dropdown-item + :data-clipboard-text="issuableReference" + button-class="js-copy-reference" + data-testid="copy-reference" + @action="copyReference" + ><template #list-item>{{ + $options.i18n.copyReferenceText + }}</template></gl-disclosure-dropdown-item + > + <gl-disclosure-dropdown-item + v-if="issuableEmailAddress && showMovedSidebarOptions" + :data-clipboard-text="issuableEmailAddress" + data-testid="copy-email" + @action="copyEmailAddress" + >{{ copyMailAddressText }}</gl-disclosure-dropdown-item + > + </template> + <gl-disclosure-dropdown-item + v-if="canReportSpam" + :item="submitSpamItem" + data-method="post" + rel="nofollow" + /> + <template v-if="canDestroyIssue"> + <gl-dropdown-divider /> + <gl-disclosure-dropdown-item + v-gl-modal="$options.deleteModalId" + variant="danger" + @action="track('click_dropdown')" + > + <template #list-item>{{ deleteButtonText }}</template> + </gl-disclosure-dropdown-item> + </template> + <gl-disclosure-dropdown-item + v-if="!isIssueAuthor && isUserSignedIn" + data-testid="report-abuse-item" + @action="toggleReportAbuseDrawer(true)" > - {{ deleteButtonText }} - </gl-dropdown-item> - </template> - <gl-dropdown-item - v-if="!isIssueAuthor && isUserSignedIn" - data-testid="report-abuse-item" - @click="toggleReportAbuseDrawer(true)" - > - {{ $options.i18n.reportAbuse }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item>{{ $options.i18n.reportAbuse }}</template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> + </div> <gl-button v-if="canUpdateIssue" @@ -379,20 +405,22 @@ export default { {{ $options.i18n.edit }} </gl-button> - <gl-dropdown + <gl-disclosure-dropdown v-if="hasDesktopDropdown" id="new-actions-header-dropdown" + ref="issuableActionsDropdownDesktop" v-gl-tooltip.hover class="gl-display-none gl-sm-display-inline-flex!" icon="ellipsis_v" category="tertiary" - :text="dropdownText" - :text-sr-only="true" + placement="left" + :toggle-text="dropdownText" + text-sr-only :title="dropdownText" :aria-label="dropdownText" + :auto-close="false" data-testid="desktop-dropdown" no-caret - right > <template v-if="showMovedSidebarOptions && !glFeatures.notificationsTodosButtons"> <sidebar-subscriptions-widget @@ -401,73 +429,70 @@ export default { :issuable-type="$options.TYPE_ISSUE" data-testid="notification-toggle" /> - <gl-dropdown-divider /> </template> - <gl-dropdown-item + <gl-disclosure-dropdown-item v-if="showToggleIssueStateButton" data-testid="toggle-issue-state-button" - @click="toggleIssueState" + @action="toggleIssueState" > - {{ buttonText }} - </gl-dropdown-item> - <gl-dropdown-item v-if="canCreateIssue && isUserSignedIn" :href="newIssuePath"> - {{ newIssueTypeText }} - </gl-dropdown-item> - <gl-dropdown-item + <template #list-item>{{ buttonText }}</template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item v-if="canCreateIssue && isUserSignedIn" :item="newIssueItem" /> + <gl-disclosure-dropdown-item v-if="canPromoteToEpic" :disabled="isToggleStateButtonLoading" data-testid="promote-button" - @click="promoteToEpic" + @action="promoteToEpic" > - {{ __('Promote to epic') }} - </gl-dropdown-item> + <template #list-item>{{ __('Promote to epic') }}</template> + </gl-disclosure-dropdown-item> <template v-if="showLockIssueOption"> <issuable-lock-form :is-editable="false" data-testid="lock-issue-toggle" /> </template> <template v-if="isMrSidebarMoved"> - <gl-dropdown-item + <gl-disclosure-dropdown-item :data-clipboard-text="issuableReference" button-class="js-copy-reference" data-testid="copy-reference" - @click="copyReference" - >{{ $options.i18n.copyReferenceText }}</gl-dropdown-item + @action="copyReference" + ><template #list-item>{{ + $options.i18n.copyReferenceText + }}</template></gl-disclosure-dropdown-item > - <gl-dropdown-item + <gl-disclosure-dropdown-item v-if="issuableEmailAddress && showMovedSidebarOptions" :data-clipboard-text="issuableEmailAddress" data-testid="copy-email" - @click="copyEmailAddress" - >{{ copyMailAddressText }}</gl-dropdown-item + @action="copyEmailAddress" + ><template #list-item>{{ copyMailAddressText }}</template></gl-disclosure-dropdown-item > </template> <gl-dropdown-divider v-if="canDestroyIssue || canReportSpam || !isIssueAuthor" /> - <gl-dropdown-item + <gl-disclosure-dropdown-item v-if="canReportSpam" - :href="submitAsSpamPath" + :item="submitSpamItem" data-method="post" rel="nofollow" - > - {{ __('Submit as spam') }} - </gl-dropdown-item> - <gl-dropdown-item + /> + <gl-disclosure-dropdown-item v-if="!isIssueAuthor && isUserSignedIn" data-testid="report-abuse-item" - @click="toggleReportAbuseDrawer(true)" + @action="toggleReportAbuseDrawer(true)" > - {{ $options.i18n.reportAbuse }} - </gl-dropdown-item> + <template #list-item>{{ $options.i18n.reportAbuse }}</template> + </gl-disclosure-dropdown-item> <template v-if="canDestroyIssue"> - <gl-dropdown-item + <gl-disclosure-dropdown-item v-gl-modal="$options.deleteModalId" variant="danger" data-testid="delete-issue-button" - @click="track('click_dropdown')" + @action="track('click_dropdown')" > - {{ deleteButtonText }} - </gl-dropdown-item> + <template #list-item>{{ deleteButtonText }}</template> + </gl-disclosure-dropdown-item> </template> - </gl-dropdown> + </gl-disclosure-dropdown> <gl-modal ref="blockedByIssuesModal" diff --git a/app/assets/javascripts/issues/show/components/issue_header.vue b/app/assets/javascripts/issues/show/components/issue_header.vue index 211f3217ddc..96eb8fbb3c7 100644 --- a/app/assets/javascripts/issues/show/components/issue_header.vue +++ b/app/assets/javascripts/issues/show/components/issue_header.vue @@ -82,7 +82,7 @@ export default { return this.issuableState === STATUS_OPEN || this.issuableState === STATUS_REOPENED; }, statusIcon() { - return this.isOpen ? 'issues' : 'issue-closed'; + return this.isOpen ? 'issue-open-m' : 'issue-close'; }, statusText() { if (this.isOpen) { @@ -115,11 +115,9 @@ export default { <template #status-badge> <gl-sprintf v-if="closedStatusLink" :message="statusText"> <template #link> - <gl-link - class="gl-reset-color! gl-reset-font-size gl-text-decoration-underline" - :href="closedStatusLink" - >{{ closedStatusText }}</gl-link - > + <gl-link class="gl-reset-color! gl-text-decoration-underline" :href="closedStatusLink">{{ + closedStatusText + }}</gl-link> </template> </gl-sprintf> <template v-else>{{ statusText }}</template> diff --git a/app/assets/javascripts/issues/show/components/sticky_header.vue b/app/assets/javascripts/issues/show/components/sticky_header.vue index 738bb2c2aa0..18e37c4216c 100644 --- a/app/assets/javascripts/issues/show/components/sticky_header.vue +++ b/app/assets/javascripts/issues/show/components/sticky_header.vue @@ -2,12 +2,7 @@ import { GlBadge, GlIcon, GlIntersectionObserver, GlLink } from '@gitlab/ui'; import HiddenBadge from '~/issuable/components/hidden_badge.vue'; import LockedBadge from '~/issuable/components/locked_badge.vue'; -import { - issuableStatusText, - STATUS_CLOSED, - TYPE_EPIC, - WORKSPACE_PROJECT, -} from '~/issues/constants'; +import { issuableStatusText, STATUS_CLOSED, WORKSPACE_PROJECT } from '~/issues/constants'; import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; export default { @@ -60,10 +55,7 @@ export default { return this.issuableStatus === STATUS_CLOSED; }, statusIcon() { - if (this.issuableType === TYPE_EPIC) { - return this.isClosed ? 'epic-closed' : 'epic'; - } - return this.isClosed ? 'issue-closed' : 'issues'; + return this.isClosed ? 'issue-close' : 'issue-open-m'; }, statusText() { return issuableStatusText[this.issuableStatus]; @@ -84,7 +76,7 @@ export default { data-testid="issue-sticky-header" > <div - class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-gap-2 gl-mx-auto gl-px-5" + class="issue-sticky-header-text gl-display-flex gl-align-items-center gl-gap-2 gl-mx-auto" > <gl-badge :variant="statusVariant"> <gl-icon :name="statusIcon" /> diff --git a/app/assets/javascripts/issues/show/utils/parse_data.js b/app/assets/javascripts/issues/show/utils/parse_data.js index f1e6bd2419a..23d5292da00 100644 --- a/app/assets/javascripts/issues/show/utils/parse_data.js +++ b/app/assets/javascripts/issues/show/utils/parse_data.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { sanitize } from '~/lib/dompurify'; // We currently load + parse the data from the issue app and related merge request diff --git a/app/assets/javascripts/jira_connect/subscriptions/constants.js b/app/assets/javascripts/jira_connect/subscriptions/constants.js index 1a10360ed30..85e250b14a0 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/constants.js +++ b/app/assets/javascripts/jira_connect/subscriptions/constants.js @@ -37,9 +37,15 @@ export const I18N_OAUTH_FAILED_MESSAGE = s__( export const INTEGRATIONS_DOC_LINK = helpPagePath('integration/jira/development_panel', { anchor: 'use-the-integration', }); +export const PREREQUISITES_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { + anchor: 'prerequisites', +}); export const OAUTH_SELF_MANAGED_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { anchor: 'set-up-oauth-authentication', }); +export const SET_UP_INSTANCE_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { + anchor: 'set-up-your-instance', +}); export const FAILED_TO_UPDATE_DOC_LINK = helpPagePath('administration/settings/jira_cloud_app', { anchor: 'failed-to-update-the-gitlab-instance', }); diff --git a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue index d8d2db18d9f..9f8fae5b476 100644 --- a/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue +++ b/app/assets/javascripts/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue @@ -1,13 +1,44 @@ <script> -import { GlButton, GlLink } from '@gitlab/ui'; -import { OAUTH_SELF_MANAGED_DOC_LINK } from '~/jira_connect/subscriptions/constants'; +import { GlButton, GlFormCheckbox, GlLink } from '@gitlab/ui'; +import { s__ } from '~/locale'; +import { + PREREQUISITES_DOC_LINK, + OAUTH_SELF_MANAGED_DOC_LINK, + SET_UP_INSTANCE_DOC_LINK, +} from '~/jira_connect/subscriptions/constants'; export default { components: { GlButton, + GlFormCheckbox, GlLink, }, - OAUTH_SELF_MANAGED_DOC_LINK, + data() { + return { + requiredSteps: [ + { + name: s__('JiraConnect|Prerequisites'), + link: PREREQUISITES_DOC_LINK, + checked: false, + }, + { + name: s__('JiraConnect|Set up OAuth authentication'), + link: OAUTH_SELF_MANAGED_DOC_LINK, + checked: false, + }, + { + name: s__('JiraConnect|Set up your instance'), + link: SET_UP_INSTANCE_DOC_LINK, + checked: false, + }, + ], + }; + }, + computed: { + nextDisabled() { + return !this.requiredSteps.every((step) => step.checked); + }, + }, }; </script> @@ -17,20 +48,25 @@ export default { <p> {{ s__( - 'JiraConnect|In order to complete the set up, you’ll need to complete a few steps in GitLab.', + 'JiraConnect|In order to complete the set up, you’ll need to complete a few steps in GitLab:', ) }} - <gl-link - class="gl-reset-font-size!" - :href="$options.OAUTH_SELF_MANAGED_DOC_LINK" - target="_blank" - >{{ __('Learn more') }}</gl-link - > </p> + <div class="gl-mb-5"> + <div v-for="step in requiredSteps" :key="step.name" class="gl-mb-2"> + <gl-form-checkbox v-model="step.checked"> + <gl-link :href="step.link" target="_blank"> + {{ step.name }} + </gl-link> + </gl-form-checkbox> + </div> + </div> <div class="gl-display-flex gl-justify-content-space-between"> <gl-button @click="$emit('back')">{{ __('Back') }}</gl-button> - <gl-button variant="confirm" @click="$emit('next')">{{ __('Next') }}</gl-button> + <gl-button variant="confirm" :disabled="nextDisabled" @click="$emit('next')" + >{{ __('Next') }} + </gl-button> </div> </div> </template> diff --git a/app/assets/javascripts/lib/graphql.js b/app/assets/javascripts/lib/graphql.js index 6ab530576fc..5285fa363a5 100644 --- a/app/assets/javascripts/lib/graphql.js +++ b/app/assets/javascripts/lib/graphql.js @@ -1,5 +1,4 @@ import { ApolloClient, InMemoryCache, ApolloLink, HttpLink } from '@apollo/client/core'; -import { BatchHttpLink } from '@apollo/client/link/batch-http'; import { createUploadLink } from 'apollo-upload-client'; import { persistCache } from 'apollo3-cache-persist'; import ActionCableLink from '~/actioncable_link'; @@ -116,18 +115,14 @@ Object.defineProperty(window, 'pendingApolloRequests', { function createApolloClient(resolvers = {}, config = {}) { const { baseUrl, - batchMax = 10, cacheConfig = { typePolicies: {}, possibleTypes: {} }, fetchPolicy = fetchPolicies.CACHE_FIRST, typeDefs, httpHeaders = {}, fetchCredentials = 'same-origin', path = '/api/graphql', - useGet = false, } = config; - const shouldUnbatch = gon.features?.unbatchGraphqlQueries; - let ac = null; let uri = `${gon.relative_url_root || ''}${path}`; @@ -146,7 +141,6 @@ function createApolloClient(resolvers = {}, config = {}) { // We set to `same-origin` which is default value in modern browsers. // See https://github.com/whatwg/fetch/pull/585 for more information. credentials: fetchCredentials, - batchMax, }; /* @@ -165,14 +159,10 @@ function createApolloClient(resolvers = {}, config = {}) { return fetch(stripWhitespaceFromQuery(url, uri), options); }; - const requestLink = ApolloLink.split( - () => useGet || shouldUnbatch, - new HttpLink({ ...httpOptions, fetch: fetchIntervention }), - new BatchHttpLink(httpOptions), - ); + const requestLink = new HttpLink({ ...httpOptions, fetch: fetchIntervention }); const uploadsLink = ApolloLink.split( - (operation) => operation.getContext().hasUpload || operation.getContext().isSingleRequest, + (operation) => operation.getContext().hasUpload, createUploadLink(httpOptions), ); diff --git a/app/assets/javascripts/lib/utils/color_utils.js b/app/assets/javascripts/lib/utils/color_utils.js index a9f4257e28b..74c9f7de8c1 100644 --- a/app/assets/javascripts/lib/utils/color_utils.js +++ b/app/assets/javascripts/lib/utils/color_utils.js @@ -46,5 +46,5 @@ export function darkModeEnabled() { if (isWebIde) { return ideDarkThemes.includes(window.gon?.user_color_scheme); } - return document.body.classList.contains('gl-dark'); + return document.documentElement.classList.contains('gl-dark'); } diff --git a/app/assets/javascripts/lib/utils/common_utils.js b/app/assets/javascripts/lib/utils/common_utils.js index 7d16af003e4..27da2ac6ce1 100644 --- a/app/assets/javascripts/lib/utils/common_utils.js +++ b/app/assets/javascripts/lib/utils/common_utils.js @@ -7,6 +7,7 @@ import $ from 'jquery'; import { isFunction, defer, escape, partial, toLower } from 'lodash'; import Cookies from '~/lib/utils/cookies'; import { SCOPED_LABEL_DELIMITER } from '~/sidebar/components/labels/labels_select_widget/constants'; +import { DEFAULT_CI_CONFIG_PATH, CI_CONFIG_PATH_EXTENSION } from '~/lib/utils/constants'; import { convertToCamelCase, convertToSnakeCase } from './text_utility'; import { isObject } from './type_utility'; import { getLocationHash } from './url_utility'; @@ -737,3 +738,17 @@ export const isCurrentUser = (userId) => { export const cloneWithoutReferences = (obj) => { return JSON.parse(JSON.stringify(obj)); }; + +/** + * Returns true if the given path is the default CI config path. + */ +export const isDefaultCiConfig = (path) => { + return path === DEFAULT_CI_CONFIG_PATH; +}; + +/** + * Returns true if the given path has the CI config path extension. + */ +export const hasCiConfigExtension = (path) => { + return CI_CONFIG_PATH_EXTENSION.test(path); +}; diff --git a/app/assets/javascripts/lib/utils/constants.js b/app/assets/javascripts/lib/utils/constants.js index da5fb831ae5..d9ac0abf7b3 100644 --- a/app/assets/javascripts/lib/utils/constants.js +++ b/app/assets/javascripts/lib/utils/constants.js @@ -23,3 +23,6 @@ export const BYTES_FORMAT_BYTES = 'B'; export const BYTES_FORMAT_KIB = 'KiB'; export const BYTES_FORMAT_MIB = 'MiB'; export const BYTES_FORMAT_GIB = 'GiB'; + +export const DEFAULT_CI_CONFIG_PATH = '.gitlab-ci.yml'; +export const CI_CONFIG_PATH_EXTENSION = /(\.gitlab-ci\.yml)/; diff --git a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js index a973cd890ba..89170ecc55d 100644 --- a/app/assets/javascripts/lib/utils/datetime/timeago_utility.js +++ b/app/assets/javascripts/lib/utils/datetime/timeago_utility.js @@ -108,15 +108,27 @@ timeago.register(`${timeagoLanguageCode}-remaining`, memoizedLocaleRemaining()); timeago.register(`${timeagoLanguageCode}-duration`, memoizedLocaleDuration()); const setupAbsoluteFormatters = () => { - const cache = {}; + let cache = {}; // Intl.DateTimeFormat options (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/DateTimeFormat#using_options) + // For hourCycle please check https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Intl/Locale/hourCycle + const hourCycle = [undefined, 'h12', 'h23']; const formats = { - [DATE_WITH_TIME_FORMAT]: () => ({ dateStyle: 'medium', timeStyle: 'short' }), + [DATE_WITH_TIME_FORMAT]: () => ({ + dateStyle: 'medium', + timeStyle: 'short', + hourCycle: hourCycle[window.gon?.time_display_format || 0], + }), [DATE_ONLY_FORMAT]: () => ({ dateStyle: 'medium' }), }; return (formatName = DEFAULT_DATE_TIME_FORMAT) => { + if (cache.time_display_format !== window.gon?.time_display_format) { + cache = { + time_display_format: window.gon?.time_display_format, + }; + } + if (cache[formatName]) { return cache[formatName]; } diff --git a/app/assets/javascripts/lib/utils/forms.js b/app/assets/javascripts/lib/utils/forms.js index 652ae337506..6713a18cbf3 100644 --- a/app/assets/javascripts/lib/utils/forms.js +++ b/app/assets/javascripts/lib/utils/forms.js @@ -69,18 +69,32 @@ export const isIntegerGreaterThan = (value, greaterThan) => isParseableAsInteger(value) && parseInt(value, 10) > greaterThan; /** - * Regexp that matches email structure. + * Regexp that matches service desk setting email structure. * Taken from app/models/service_desk_setting.rb custom_email */ -export const EMAIL_REGEXP = /^[\w\-._]+@[\w\-.]+\.[a-zA-Z]{2,}$/; +const SERVICE_DESK_SETTING_EMAIL_REGEXP = /^[\w\-._]+@[\w\-.]+\.[a-zA-Z]{2,}$/; /** - * Checks if the input is a valid email address + * Checks if the input is a valid service desk setting email address * * @param {String} - value * @returns {Boolean} */ -export const isEmail = (value) => EMAIL_REGEXP.test(value); +export const isServiceDeskSettingEmail = (value) => SERVICE_DESK_SETTING_EMAIL_REGEXP.test(value); + +/** + * Regexp that matches user email structure. + * Taken from DeviseEmailValidator + */ +const USER_EMAIL_REGEXP = /^[^@\s]+@[^@\s]+$/; + +/** + * Checks if the input is a valid user email address + * + * @param {String} - value + * @returns {Boolean} + */ +export const isUserEmail = (value) => USER_EMAIL_REGEXP.test(value); /** * A form object serializer diff --git a/app/assets/javascripts/lib/utils/keys.js b/app/assets/javascripts/lib/utils/keys.js index 7cfcd11ece9..e5022551b97 100644 --- a/app/assets/javascripts/lib/utils/keys.js +++ b/app/assets/javascripts/lib/utils/keys.js @@ -1,5 +1,6 @@ export const ESC_KEY = 'Escape'; export const ENTER_KEY = 'Enter'; +export const NUMPAD_ENTER_KEY = 'NumpadEnter'; export const BACKSPACE_KEY = 'Backspace'; export const ARROW_DOWN_KEY = 'ArrowDown'; export const ARROW_UP_KEY = 'ArrowUp'; diff --git a/app/assets/javascripts/main.js b/app/assets/javascripts/main.js index 5bfdd174694..29189e3ac2f 100644 --- a/app/assets/javascripts/main.js +++ b/app/assets/javascripts/main.js @@ -29,7 +29,6 @@ import initBreadcrumbs from './breadcrumb'; import initPersistentUserCallouts from './persistent_user_callouts'; import { initUserTracking, initDefaultTrackers } from './tracking'; import { initSidebarTracking } from './pages/shared/nav/sidebar_tracking'; -import initServicePingConsent from './service_ping_consent'; import GlFieldErrors from './gl_field_errors'; import initUserPopovers from './user_popovers'; import initBroadcastNotifications from './broadcast_notification'; @@ -93,7 +92,6 @@ function deferredInitialisation() { initBreadcrumbs(); initPrefetchLinks('.js-prefetch-document'); initLogoAnimation(); - initServicePingConsent(); initUserPopovers(); initBroadcastNotifications(); initPersistentUserCallouts(); diff --git a/app/assets/javascripts/members/components/avatars/group_avatar.vue b/app/assets/javascripts/members/components/avatars/group_avatar.vue index 3b176bf2b43..83b5855492b 100644 --- a/app/assets/javascripts/members/components/avatars/group_avatar.vue +++ b/app/assets/javascripts/members/components/avatars/group_avatar.vue @@ -1,11 +1,18 @@ <script> -import { GlAvatarLink, GlAvatarLabeled } from '@gitlab/ui'; +import { GlAvatarLink, GlAvatarLabeled, GlTooltipDirective } from '@gitlab/ui'; +import { __ } from '~/locale'; +import PrivateIcon from '../icons/private_icon.vue'; import { AVATAR_SIZE } from '../../constants'; export default { name: 'GroupAvatar', - avatarSize: AVATAR_SIZE, - components: { GlAvatarLink, GlAvatarLabeled }, + components: { GlAvatarLink, GlAvatarLabeled, PrivateIcon }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + private: __('Private'), + }, props: { member: { type: Object, @@ -16,19 +23,36 @@ export default { group() { return this.member.sharedWithGroup; }, + isPrivate() { + return this.member.isSharedWithGroupPrivate; + }, + avatarLabeledProps() { + const label = this.isPrivate ? this.$options.i18n.private : this.group.fullName; + + return { + label, + src: this.group.avatarUrl, + alt: label, + size: AVATAR_SIZE, + entityName: this.isPrivate ? this.$options.i18n.private : this.group.name, + entityId: this.group.id, + }; + }, }, }; </script> <template> - <gl-avatar-link :href="group.webUrl"> - <gl-avatar-labeled - :label="group.fullName" - :src="group.avatarUrl" - :alt="group.fullName" - :size="$options.avatarSize" - :entity-name="group.name" - :entity-id="group.id" - /> + <div v-if="isPrivate"> + <gl-avatar-labeled v-bind="avatarLabeledProps"> + <template #meta> + <div class="gl-p-1"> + <private-icon /> + </div> + </template> + </gl-avatar-labeled> + </div> + <gl-avatar-link v-else :href="group.webUrl"> + <gl-avatar-labeled v-bind="avatarLabeledProps" /> </gl-avatar-link> </template> diff --git a/app/assets/javascripts/members/components/icons/private_icon.vue b/app/assets/javascripts/members/components/icons/private_icon.vue new file mode 100644 index 00000000000..6168ea955f3 --- /dev/null +++ b/app/assets/javascripts/members/components/icons/private_icon.vue @@ -0,0 +1,19 @@ +<script> +import { GlIcon, GlTooltipDirective } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + name: 'GroupAvatar', + components: { GlIcon }, + directives: { + GlTooltip: GlTooltipDirective, + }, + i18n: { + tooltip: s__('Members|Private group information is only accessible to its members.'), + }, +}; +</script> + +<template> + <gl-icon v-gl-tooltip="$options.i18n.tooltip" name="eye-slash" /> +</template> diff --git a/app/assets/javascripts/members/components/table/member_source.vue b/app/assets/javascripts/members/components/table/member_source.vue index ed1971d020b..f1a1c4cecaa 100644 --- a/app/assets/javascripts/members/components/table/member_source.vue +++ b/app/assets/javascripts/members/components/table/member_source.vue @@ -1,10 +1,12 @@ <script> import { GlSprintf, GlTooltipDirective } from '@gitlab/ui'; import { s__, __ } from '~/locale'; +import PrivateIcon from '../icons/private_icon.vue'; export default { name: 'MemberSource', i18n: { + private: __('Private'), inherited: __('Inherited'), directMember: __('Direct member'), directMemberWithCreatedBy: s__('Members|Direct member by %{createdBy}'), @@ -13,16 +15,24 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - components: { GlSprintf }, + components: { GlSprintf, PrivateIcon }, props: { memberSource: { type: Object, - required: true, + required: false, + default() { + return {}; + }, }, isDirectMember: { type: Boolean, required: true, }, + isSharedWithGroupPrivate: { + type: Boolean, + required: false, + default: false, + }, createdBy: { type: Object, required: false, @@ -43,7 +53,11 @@ export default { </script> <template> - <span v-if="showCreatedBy"> + <div v-if="isSharedWithGroupPrivate" class="gl-display-flex gl-column-gap-2"> + <span>{{ $options.i18n.private }}</span> + <private-icon /> + </div> + <span v-else-if="showCreatedBy"> <gl-sprintf :message="messageWithCreatedBy"> <template #group> <a v-gl-tooltip.hover="$options.i18n.inherited" :href="memberSource.webUrl">{{ diff --git a/app/assets/javascripts/members/components/table/members_table.vue b/app/assets/javascripts/members/components/table/members_table.vue index 68f624e9a3d..2b3294c1c79 100644 --- a/app/assets/javascripts/members/components/table/members_table.vue +++ b/app/assets/javascripts/members/components/table/members_table.vue @@ -270,6 +270,7 @@ export default { :is-direct-member="isDirectMember" :member-source="member.source" :created-by="member.createdBy" + :is-shared-with-group-private="member.isSharedWithGroupPrivate" /> </members-table-cell> </template> diff --git a/app/assets/javascripts/members/components/table/role_dropdown.vue b/app/assets/javascripts/members/components/table/role_dropdown.vue index 4b39c000b8f..2b72a3fe6e8 100644 --- a/app/assets/javascripts/members/components/table/role_dropdown.vue +++ b/app/assets/javascripts/members/components/table/role_dropdown.vue @@ -3,9 +3,10 @@ import { GlCollapsibleListbox } from '@gitlab/ui'; import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; // eslint-disable-next-line no-restricted-imports import { mapActions } from 'vuex'; -import * as Sentry from '@sentry/browser'; -import { s__ } from '~/locale'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { guestOverageConfirmAction } from 'ee_else_ce/members/guest_overage_confirm_action'; +import { roleDropdownItems, initialSelectedRole } from 'ee_else_ce/members/utils'; +import { s__ } from '~/locale'; export default { name: 'RoleDropdown', @@ -29,7 +30,7 @@ export default { return { isDesktop: false, busy: false, - selectedRoleValue: this.member.accessLevel.integerValue, + selectedRole: null, }; }, computed: { @@ -37,12 +38,12 @@ export default { return this.permissions.canOverride && !this.member.isOverridden; }, dropdownItems() { - return Object.entries(this.member.validRoles).map(([name, value]) => ({ - value, - text: name, - })); + return roleDropdownItems(this.member); }, }, + created() { + this.selectedRole = initialSelectedRole(this.dropdownItems.flatten, this.member); + }, mounted() { this.isDesktop = bp.isDesktop(); }, @@ -52,44 +53,39 @@ export default { return dispatch(`${this.namespace}/updateMemberRole`, payload); }, }), - async handleOverageConfirm(currentRoleValue, newRoleValue, newRoleName) { - return guestOverageConfirmAction({ - currentRoleValue, - newRoleValue, - newRoleName, - group: this.group, - memberId: this.member.id, - memberType: this.namespace, - }); - }, - async handleSelect(newRoleValue) { - const currentRoleValue = this.member.accessLevel.integerValue; - if (newRoleValue === currentRoleValue) { - return; - } - + async handleSelect(value) { this.busy = true; - const { text: newRoleName } = this.dropdownItems.find((item) => item.value === newRoleValue); - const confirmed = await this.handleOverageConfirm( - currentRoleValue, - newRoleValue, - newRoleName, - ); - if (!confirmed) { - this.selectedRoleValue = currentRoleValue; - this.busy = false; - return; - } + const newRole = this.dropdownItems.flatten.find((item) => item.value === value); + const previousRole = this.selectedRole; try { + const confirmed = await guestOverageConfirmAction({ + currentRoleValue: this.member.accessLevel.integerValue, + newRoleValue: newRole.accessLevel, + newRoleName: newRole.text, + newMemberRoleId: newRole.memberRoleId, + group: this.group, + memberId: this.member.id, + memberType: this.namespace, + }); + if (!confirmed) { + return; + } + + this.selectedRole = value; + await this.updateMemberRole({ memberId: this.member.id, - accessLevel: { integerValue: newRoleValue, stringValue: newRoleName }, + accessLevel: { + integerValue: newRole.accessLevel, + memberRoleId: newRole.memberRoleId, + }, }); this.$toast.show(s__('Members|Role updated successfully.')); } catch (error) { + this.selectedRole = previousRole; Sentry.captureException(error); } finally { this.busy = false; @@ -101,14 +97,14 @@ export default { <template> <gl-collapsible-listbox - v-model="selectedRoleValue" :placement="isDesktop ? 'left' : 'right'" :toggle-text="member.accessLevel.stringValue" :header-text="__('Change role')" :disabled="disabled" :loading="busy" data-qa-selector="access_level_dropdown" - :items="dropdownItems" + :items="dropdownItems.formatted" + :selected="selectedRole" @select="handleSelect" > <template #list-item="{ item }"> diff --git a/app/assets/javascripts/members/store/actions.js b/app/assets/javascripts/members/store/actions.js index 712f0d6caa7..d696f618a3c 100644 --- a/app/assets/javascripts/members/store/actions.js +++ b/app/assets/javascripts/members/store/actions.js @@ -6,7 +6,10 @@ export const updateMemberRole = async ({ state, commit }, { memberId, accessLeve try { await axios.put( state.memberPath.replace(/:id$/, memberId), - state.requestFormatter({ accessLevel: accessLevel.integerValue }), + state.requestFormatter({ + accessLevel: accessLevel.integerValue, + memberRoleId: accessLevel.memberRoleId, + }), ); commit(types.RECEIVE_MEMBER_ROLE_SUCCESS, { memberId, accessLevel }); diff --git a/app/assets/javascripts/members/utils.js b/app/assets/javascripts/members/utils.js index 09e4b5e8a6f..1304fb0fee1 100644 --- a/app/assets/javascripts/members/utils.js +++ b/app/assets/javascripts/members/utils.js @@ -1,4 +1,4 @@ -import { isUndefined } from 'lodash'; +import { isUndefined, uniqueId } from 'lodash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getParameterByName, setUrlParams } from '~/lib/utils/url_utility'; import { @@ -35,6 +35,36 @@ export const generateBadges = ({ member, isCurrentUser, canManageMembers }) => [ }, ]; +/** + * Creates the dropdowns options for static roles + * + * @param {object} member + * @param {Map<string, number>} member.validRoles + */ +export const roleDropdownItems = ({ validRoles }) => { + const staticRoleDropdownItems = Object.entries(validRoles).map(([name, value]) => ({ + accessLevel: value, + memberRoleId: null, // The value `null` is need to downgrade from custom role to static role. See: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133430#note_1595153555 + text: name, + value: uniqueId('role-static-'), + })); + + return { flatten: staticRoleDropdownItems, formatted: staticRoleDropdownItems }; +}; + +/** + * Finds and returns unique value + * + * @param {Array<{accessLevel: number, memberRoleId: null, text: string, value: string}>} flattenDropdownItems + * @param {object} member + * @param {{integerValue: number}} member.accessLevel + */ +export const initialSelectedRole = (flattenDropdownItems, member) => { + return flattenDropdownItems.find( + ({ accessLevel }) => accessLevel === member.accessLevel.integerValue, + )?.value; +}; + export const isGroup = (member) => { return Boolean(member.sharedWithGroup); }; @@ -128,6 +158,7 @@ export const parseDataAttributes = (el) => { export const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) => ({ accessLevel, + memberRoleId, ...otherProperties }) => { const accessLevelProperty = !isUndefined(accessLevel) @@ -137,6 +168,7 @@ export const baseRequestFormatter = (basePropertyName, accessLevelPropertyName) return { [basePropertyName]: { ...accessLevelProperty, + member_role_id: memberRoleId ?? null, ...otherProperties, }, }; diff --git a/app/assets/javascripts/merge_request_tabs.js b/app/assets/javascripts/merge_request_tabs.js index 2095f24eb84..8ea995b8b4e 100644 --- a/app/assets/javascripts/merge_request_tabs.js +++ b/app/assets/javascripts/merge_request_tabs.js @@ -21,12 +21,7 @@ import syntaxHighlight from './syntax_highlight'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient( - {}, - { - useGet: true, - }, - ), + defaultClient: createDefaultClient(), }); // MergeRequestTabs diff --git a/app/assets/javascripts/merge_requests/components/sticky_header.vue b/app/assets/javascripts/merge_requests/components/sticky_header.vue index e8bdb854334..877e6142bae 100644 --- a/app/assets/javascripts/merge_requests/components/sticky_header.vue +++ b/app/assets/javascripts/merge_requests/components/sticky_header.vue @@ -1,5 +1,12 @@ <script> -import { GlIntersectionObserver, GlLink, GlSprintf, GlBadge } from '@gitlab/ui'; +import { + GlIntersectionObserver, + GlLink, + GlSprintf, + GlBadge, + GlIcon, + GlTooltipDirective, +} from '@gitlab/ui'; // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; import SafeHtml from '~/vue_shared/directives/safe_html'; @@ -45,6 +52,7 @@ export default { GlLink, GlSprintf, GlBadge, + GlIcon, DiscussionCounter, StatusBadge, TodoWidget, @@ -53,10 +61,12 @@ export default { }, directives: { SafeHtml, + GlTooltip: GlTooltipDirective, }, mixins: [glFeatureFlagsMixin()], inject: { projectPath: { default: null }, + sourceProjectPath: { default: null }, title: { default: '' }, tabs: { default: () => [] }, isFluidLayout: { default: false }, @@ -89,6 +99,16 @@ export default { isNotificationsTodosButtons() { return this.glFeatures.notificationsTodosButtons && this.glFeatures.movedMrSidebar; }, + isForked() { + return this.projectPath !== this.sourceProjectPath; + }, + sourceBranch() { + if (this.isForked) { + return `${this.sourceProjectPath}:${this.getNoteableData.source_branch}`; + } + + return this.getNoteableData.source_branch; + }, }, watch: { discussionTabCounter(val) { @@ -122,8 +142,8 @@ export default { :class="{ 'gl-visibility-hidden': !isStickyHeaderVisible }" > <div - class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-px-5 gl-w-full" - :class="{ 'gl-max-w-container-xl': !isFluidLayout }" + class="issue-sticky-header-text gl-display-flex gl-flex-direction-column gl-align-items-center gl-mx-auto gl-w-full" + :class="{ 'container-limited': !isFluidLayout }" > <div class="gl-w-full gl-display-flex gl-align-items-baseline"> <status-badge @@ -153,8 +173,17 @@ export default { :title="getNoteableData.source_branch" :href="getNoteableData.source_branch_path" class="gl-text-blue-500! gl-font-monospace gl-bg-blue-50 gl-rounded-base gl-font-sm gl-px-2 gl-text-truncate gl-max-w-26" + data-testid="source-branch" > - {{ getNoteableData.source_branch }} + <span + v-if="isForked" + v-gl-tooltip + class="gl-vertical-align-middle gl-mr-n2" + :title="__('The source project is a fork')" + > + <gl-icon name="fork" :size="12" class="gl-ml-1" /> + </span> + {{ sourceBranch }} </gl-link> </template> <template #target> diff --git a/app/assets/javascripts/milestones/components/milestone_combobox.vue b/app/assets/javascripts/milestones/components/milestone_combobox.vue index 2f7fb542d0e..a5e306b5372 100644 --- a/app/assets/javascripts/milestones/components/milestone_combobox.vue +++ b/app/assets/javascripts/milestones/components/milestone_combobox.vue @@ -1,19 +1,10 @@ <script> -import { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, -} from '@gitlab/ui'; +import { GlBadge, GlButton, GlCollapsibleListbox } from '@gitlab/ui'; import { debounce, isEqual } from 'lodash'; // eslint-disable-next-line no-restricted-imports import { mapActions, mapGetters, mapState } from 'vuex'; import { s__, __, sprintf } from '~/locale'; import createStore from '../stores'; -import MilestoneResultsSection from './milestone_results_section.vue'; const SEARCH_DEBOUNCE_MS = 250; @@ -21,14 +12,9 @@ export default { name: 'MilestoneCombobox', store: createStore(), components: { - GlDropdown, - GlDropdownDivider, - GlDropdownSectionHeader, - GlDropdownItem, - GlLoadingIcon, - GlSearchBoxByType, - GlIcon, - MilestoneResultsSection, + GlCollapsibleListbox, + GlBadge, + GlButton, }, props: { value: { @@ -56,27 +42,43 @@ export default { required: false, }, }, - data() { - return { - searchQuery: '', - }; - }, translations: { - milestone: s__('MilestoneCombobox|Milestone'), selectMilestone: s__('MilestoneCombobox|Select milestone'), noMilestone: s__('MilestoneCombobox|No milestone'), - noResultsLabel: s__('MilestoneCombobox|No matching results'), - searchMilestones: s__('MilestoneCombobox|Search Milestones'), - searchErrorMessage: s__('MilestoneCombobox|An error occurred while searching for milestones'), projectMilestones: s__('MilestoneCombobox|Project milestones'), groupMilestones: s__('MilestoneCombobox|Group milestones'), + unselect: __('Unselect'), }, computed: { ...mapState(['matches', 'selectedMilestones']), - ...mapGetters(['isLoading', 'groupMilestonesEnabled']), + ...mapGetters(['isLoading']), + allMilestones() { + const { groupMilestones, projectMilestones } = this.matches || {}; + const milestones = []; + + if (projectMilestones?.totalCount) { + milestones.push({ + id: 'project-milestones', + text: this.$options.translations.projectMilestones, + options: projectMilestones.list, + totalCount: projectMilestones.totalCount, + }); + } + + if (groupMilestones?.totalCount) { + milestones.push({ + id: 'group-milestones', + text: this.$options.translations.groupMilestones, + options: groupMilestones.list, + totalCount: groupMilestones.totalCount, + }); + } + + return milestones; + }, selectedMilestonesLabel() { const { selectedMilestones } = this; - const firstMilestoneName = selectedMilestones[0]; + const [firstMilestoneName] = selectedMilestones; if (selectedMilestones.length === 0) { return this.$options.translations.noMilestone; @@ -92,20 +94,6 @@ export default { numberOfOtherMilestones, }); }, - showProjectMilestoneSection() { - return Boolean( - this.matches.projectMilestones.totalCount > 0 || this.matches.projectMilestones.error, - ); - }, - showGroupMilestoneSection() { - return ( - this.groupMilestonesEnabled && - Boolean(this.matches.groupMilestones.totalCount > 0 || this.matches.groupMilestones.error) - ); - }, - showNoResults() { - return !this.showProjectMilestoneSection && !this.showGroupMilestoneSection; - }, }, watch: { // Keep the Vuex store synchronized if the parent @@ -127,8 +115,8 @@ export default { // because we need to access the .cancel() method // lodash attaches to the function, which is // made inaccessible by Vue. - this.debouncedSearch = debounce(function search() { - this.search(this.searchQuery); + this.debouncedSearch = debounce(function search(q) { + this.search(q); }, SEARCH_DEBOUNCE_MS); this.setProjectId(this.projectId); @@ -143,22 +131,14 @@ export default { 'setGroupMilestonesAvailable', 'setSelectedMilestones', 'clearSelectedMilestones', - 'toggleMilestones', 'search', 'fetchMilestones', ]), - focusSearchBox() { - this.$refs.searchBox.$el.querySelector('input').focus(); - }, - onSearchBoxEnter() { - this.debouncedSearch.cancel(); - this.search(this.searchQuery); + onSearchBoxInput(q) { + this.debouncedSearch(q); }, - onSearchBoxInput() { - this.debouncedSearch(); - }, - selectMilestone(milestone) { - this.toggleMilestones(milestone); + selectMilestone(milestones) { + this.setSelectedMilestones(milestones); this.$emit('input', this.selectedMilestones); }, selectNoMilestone() { @@ -170,84 +150,42 @@ export default { </script> <template> - <gl-dropdown v-bind="$attrs" class="milestone-combobox" @shown="focusSearchBox"> - <template #button-content> - <span data-testid="milestone-combobox-button-content" class="gl-flex-grow-1 text-muted">{{ - selectedMilestonesLabel - }}</span> - <gl-icon name="chevron-down" /> - </template> - - <gl-dropdown-section-header> - <span class="text-center d-block">{{ $options.translations.selectMilestone }}</span> - </gl-dropdown-section-header> - - <gl-dropdown-divider /> - - <gl-search-box-by-type - ref="searchBox" - v-model.trim="searchQuery" - class="gl-m-3" - :placeholder="$options.translations.searchMilestones" - @input="onSearchBoxInput" - @keydown.enter.prevent="onSearchBoxEnter" - /> - - <gl-dropdown-item - :is-checked="selectedMilestones.length === 0" - is-check-item - @click="selectNoMilestone()" - > - {{ $options.translations.noMilestone }} - </gl-dropdown-item> - - <gl-dropdown-divider /> - - <template v-if="isLoading"> - <gl-loading-icon size="sm" /> - <gl-dropdown-divider /> + <gl-collapsible-listbox + :header-text="$options.translations.selectMilestone" + :items="allMilestones" + :reset-button-label="$options.translations.unselect" + :searching="isLoading" + :selected="selectedMilestones" + :toggle-text="selectedMilestonesLabel" + block + multiple + searchable + @reset="selectNoMilestone" + @search="onSearchBoxInput" + @select="selectMilestone" + > + <template #group-label="{ group }"> + <span :data-testid="`${group.id}-section`" + >{{ group.text }}<gl-badge size="sm" class="gl-ml-2">{{ group.totalCount }}</gl-badge></span + > </template> - <template v-else-if="showNoResults"> - <div class="dropdown-item-space"> - <span data-testid="milestone-combobox-no-results" class="gl-pl-6">{{ - $options.translations.noResultsLabel - }}</span> + <template #footer> + <div + class="gl-border-t-solid gl-border-t-1 gl-border-t-gray-200 gl-display-flex gl-flex-direction-column gl-p-2! gl-pt-0!" + > + <gl-button + v-for="(item, idx) in extraLinks" + :key="idx" + :href="item.url" + is-check-item + data-testid="milestone-combobox-extra-links" + category="tertiary" + block + class="gl-justify-content-start! gl-mt-2!" + > + {{ item.text }} + </gl-button> </div> - <gl-dropdown-divider /> - </template> - <template v-else> - <milestone-results-section - v-if="showProjectMilestoneSection" - :section-title="$options.translations.projectMilestones" - :total-count="matches.projectMilestones.totalCount" - :items="matches.projectMilestones.list" - :selected-milestones="selectedMilestones" - :error="matches.projectMilestones.error" - :error-message="$options.translations.searchErrorMessage" - data-testid="project-milestones-section" - @selected="selectMilestone($event)" - /> - - <milestone-results-section - v-if="showGroupMilestoneSection" - :section-title="$options.translations.groupMilestones" - :total-count="matches.groupMilestones.totalCount" - :items="matches.groupMilestones.list" - :selected-milestones="selectedMilestones" - :error="matches.groupMilestones.error" - :error-message="$options.translations.searchErrorMessage" - data-testid="group-milestones-section" - @selected="selectMilestone($event)" - /> </template> - <gl-dropdown-item - v-for="(item, idx) in extraLinks" - :key="idx" - :href="item.url" - is-check-item - data-testid="milestone-combobox-extra-links" - > - {{ item.text }} - </gl-dropdown-item> - </gl-dropdown> + </gl-collapsible-listbox> </template> diff --git a/app/assets/javascripts/milestones/stores/mutations.js b/app/assets/javascripts/milestones/stores/mutations.js index 1f88c0a1ea6..c0cd58cc5d2 100644 --- a/app/assets/javascripts/milestones/stores/mutations.js +++ b/app/assets/javascripts/milestones/stores/mutations.js @@ -37,7 +37,7 @@ export default { }, [types.RECEIVE_PROJECT_MILESTONES_SUCCESS](state, response) { state.matches.projectMilestones = { - list: response.data.map(({ title }) => ({ title })), + list: response.data.map(({ title }) => ({ text: title, value: title })), totalCount: parseInt(response.headers['x-total'], 10) || response.data.length, error: null, }; @@ -51,7 +51,7 @@ export default { }, [types.RECEIVE_GROUP_MILESTONES_SUCCESS](state, response) { state.matches.groupMilestones = { - list: response.data.map(({ title }) => ({ title })), + list: response.data.map(({ title }) => ({ text: title, value: title })), totalCount: parseInt(response.headers['x-total'], 10) || response.data.length, error: null, }; diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js index 3026bce0972..c94e7648d1d 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/index/constants.js @@ -2,9 +2,9 @@ import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; export const CREATE_EXPERIMENT_HELP_PATH = helpPagePath( - 'user/project/ml/experiment_tracking/index.md', + 'user/project/ml/experiment_tracking/index', { - anchor: 'tracking-new-experiments-and-trials', + anchor: 'track-new-experiments-and-candidates', }, ); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js index 4d34555ac2f..346c2453715 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/constants.js @@ -1,5 +1,4 @@ import { s__ } from '~/locale'; -import { helpPagePath } from '~/helpers/help_page_helper'; export const METRIC_KEY_PREFIX = 'metric.'; export const LIST_KEY_CREATED_AT = 'created_at'; @@ -13,9 +12,3 @@ export const BASE_SORT_FIELDS = Object.freeze([ label: s__('MlExperimentTracking|Created at'), }, ]); -export const CREATE_CANDIDATE_HELP_PATH = helpPagePath( - 'user/project/ml/experiment_tracking/index.md', - { - anchor: 'tracking-new-experiments-and-trials', - }, -); diff --git a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue index 28a27059b17..afd48df93e4 100644 --- a/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue +++ b/app/assets/javascripts/ml/experiment_tracking/routes/experiments/show/ml_experiments_show.vue @@ -10,12 +10,8 @@ import KeysetPagination from '~/vue_shared/components/incubation/pagination.vue' import ModelExperimentsHeader from '~/ml/experiment_tracking/components/model_experiments_header.vue'; import DeleteButton from '~/ml/experiment_tracking/components/delete_button.vue'; -import { - LIST_KEY_CREATED_AT, - BASE_SORT_FIELDS, - METRIC_KEY_PREFIX, - CREATE_CANDIDATE_HELP_PATH, -} from './constants'; +import { CREATE_EXPERIMENT_HELP_PATH as CREATE_CANDIDATE_HELP_PATH } from '../index/constants'; +import { LIST_KEY_CREATED_AT, BASE_SORT_FIELDS, METRIC_KEY_PREFIX } from './constants'; import * as translations from './translations'; export default { diff --git a/app/assets/javascripts/ml/model_registry/apps/index.js b/app/assets/javascripts/ml/model_registry/apps/index.js index f9e5f82e708..92d159f68be 100644 --- a/app/assets/javascripts/ml/model_registry/apps/index.js +++ b/app/assets/javascripts/ml/model_registry/apps/index.js @@ -1,3 +1,5 @@ import ShowMlModel from './show_ml_model.vue'; +import ShowMlModelVersion from './show_ml_model_version.vue'; +import IndexMlModels from './index_ml_models.vue'; -export { ShowMlModel }; +export { ShowMlModel, ShowMlModelVersion, IndexMlModels }; diff --git a/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue new file mode 100644 index 00000000000..5a55d5669a8 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/apps/index_ml_models.vue @@ -0,0 +1,61 @@ +<script> +import { isEmpty } from 'lodash'; +import Pagination from '~/vue_shared/components/incubation/pagination.vue'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import * as i18n from '../translations'; +import { BASE_SORT_FIELDS } from '../constants'; +import SearchBar from '../components/search_bar.vue'; +import ModelRow from '../components/model_row.vue'; + +export default { + name: 'IndexMlModels', + components: { + Pagination, + ModelRow, + SearchBar, + MetadataItem, + TitleArea, + }, + props: { + models: { + type: Array, + required: true, + }, + pageInfo: { + type: Object, + required: true, + }, + modelCount: { + type: Number, + required: false, + default: 0, + }, + }, + computed: { + hasModels() { + return !isEmpty(this.models); + }, + }, + i18n, + sortableFields: BASE_SORT_FIELDS, +}; +</script> + +<template> + <div> + <title-area :title="$options.i18n.TITLE_LABEL"> + <template #metadata-models-count> + <metadata-item icon="machine-learning" :text="$options.i18n.modelsCountLabel(modelCount)" /> + </template> + </title-area> + + <template v-if="hasModels"> + <search-bar :sortable-fields="$options.sortableFields" /> + <model-row v-for="model in models" :key="model.name" :model="model" /> + <pagination v-bind="pageInfo" /> + </template> + + <p v-else class="gl-text-secondary">{{ $options.i18n.NO_MODELS_LABEL }}</p> + </div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue index d4f17c840d7..e8ec8f157ef 100644 --- a/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model.vue @@ -1,16 +1,71 @@ <script> +import { GlTab, GlTabs, GlBadge } from '@gitlab/ui'; +import MetadataItem from '~/vue_shared/components/registry/metadata_item.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; +import * as i18n from '../translations'; + export default { name: 'ShowMlModelApp', - components: {}, + components: { + TitleArea, + GlTabs, + GlTab, + GlBadge, + MetadataItem, + }, props: { model: { type: Object, required: true, }, }, + computed: { + versionCount() { + return this.model.versionCount || 0; + }, + candidateCount() { + return this.model.candidateCount || 0; + }, + }, + i18n, }; </script> <template> - <div>{{ model.name }}</div> + <div> + <title-area :title="model.name"> + <template #metadata-versions-count> + <metadata-item + icon="machine-learning" + :text="$options.i18n.versionsCountLabel(model.versionCount)" + /> + </template> + + <template #sub-header> + {{ model.description }} + </template> + </title-area> + + <gl-tabs class="gl-mt-4"> + <gl-tab :title="$options.i18n.MODEL_DETAILS_TAB_LABEL"> + <h3 class="gl-font-lg">{{ $options.i18n.LATEST_VERSION_LABEL }}</h3> + <template v-if="model.latestVersion"> + {{ model.latestVersion.version }} + </template> + <div v-else class="gl-text-secondary">{{ $options.i18n.NO_VERSIONS_LABEL }}</div> + </gl-tab> + <gl-tab> + <template #title> + {{ $options.i18n.MODEL_OTHER_VERSIONS_TAB_LABEL }} + <gl-badge size="sm" class="gl-tab-counter-badge">{{ versionCount }}</gl-badge> + </template> + </gl-tab> + <gl-tab> + <template #title> + {{ $options.i18n.MODEL_CANDIDATES_TAB_LABEL }} + <gl-badge size="sm" class="gl-tab-counter-badge">{{ candidateCount }}</gl-badge> + </template> + </gl-tab> + </gl-tabs> + </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue new file mode 100644 index 00000000000..a9440aff1ce --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/apps/show_ml_model_version.vue @@ -0,0 +1,16 @@ +<script> +export default { + name: 'ShowMlModelVersionApp', + components: {}, + props: { + modelVersion: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div>{{ modelVersion.model.name }} - {{ modelVersion.version }}</div> +</template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/model_row.vue b/app/assets/javascripts/ml/model_registry/components/model_row.vue index 4f91f0939a8..ffae7e83099 100644 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/components/model_row.vue +++ b/app/assets/javascripts/ml/model_registry/components/model_row.vue @@ -1,6 +1,6 @@ <script> import { GlLink } from '@gitlab/ui'; -import { modelVersionCountMessage } from '../translations'; +import { s__, n__ } from '~/locale'; export default { name: 'MlModelRow', @@ -17,8 +17,16 @@ export default { hasVersions() { return this.model.version != null; }, + modelVersionCountMessage() { + if (!this.model.versionCount) return s__('MlModelRegistry|No registered versions'); + + return n__( + 'MlModelRegistry|· No other versions', + 'MlModelRegistry|· %d versions', + this.model.versionCount, + ); + }, }, - modelVersionCountMessage, }; </script> @@ -29,7 +37,9 @@ export default { </gl-link> <div class="gl-text-secondary"> - {{ $options.modelVersionCountMessage(model.version, model.versionCount) }} + <gl-link v-if="hasVersions" :href="model.versionPath">{{ model.version }}</gl-link> + + {{ modelVersionCountMessage }} </div> </div> </template> diff --git a/app/assets/javascripts/ml/model_registry/components/search_bar.vue b/app/assets/javascripts/ml/model_registry/components/search_bar.vue new file mode 100644 index 00000000000..2bcdabc403f --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/components/search_bar.vue @@ -0,0 +1,71 @@ +<script> +import { queryToObject, setUrlParams, visitUrl } from '~/lib/utils/url_utility'; +import { LIST_KEY_CREATED_AT } from '~/ml/experiment_tracking/routes/experiments/show/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; +import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue'; + +export default { + name: 'SearchBar', + components: { + RegistrySearch, + }, + props: { + sortableFields: { + type: Array, + required: true, + }, + }, + data() { + const query = queryToObject(window.location.search); + + const filter = query.name ? [{ value: { data: query.name }, type: FILTERED_SEARCH_TERM }] : []; + + const orderBy = query.orderBy || LIST_KEY_CREATED_AT; + + return { + filters: filter, + sorting: { + orderBy, + sort: (query.sort || 'desc').toLowerCase(), + }, + }; + }, + methods: { + submitFilters() { + return visitUrl(setUrlParams(this.parsedQuery())); + }, + parsedQuery() { + const name = this.filters + .map((f) => f.value.data) + .join(' ') + .trim(); + + const filterByQuery = name === '' ? {} : { name }; + + return { ...filterByQuery, ...this.sorting }; + }, + updateFilters(newValue) { + this.filters = newValue; + }, + updateSorting(newValue) { + this.sorting = { ...this.sorting, ...newValue }; + }, + updateSortingAndEmitUpdate(newValue) { + this.updateSorting(newValue); + this.submitFilters(); + }, + }, +}; +</script> + +<template> + <registry-search + :filters="filters" + :sorting="sorting" + :sortable-fields="sortableFields" + @sorting:changed="updateSortingAndEmitUpdate" + @filter:changed="updateFilters" + @filter:submit="submitFilters" + @filter:clear="filters = []" + /> +</template> diff --git a/app/assets/javascripts/ml/model_registry/constants.js b/app/assets/javascripts/ml/model_registry/constants.js new file mode 100644 index 00000000000..10c21ec4f12 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/constants.js @@ -0,0 +1,13 @@ +import { s__ } from '~/locale'; + +export const LIST_KEY_CREATED_AT = 'created_at'; +export const BASE_SORT_FIELDS = Object.freeze([ + { + orderBy: 'name', + label: s__('MlExperimentTracking|Name'), + }, + { + orderBy: LIST_KEY_CREATED_AT, + label: s__('MlExperimentTracking|Created at'), + }, +]); diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue b/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue deleted file mode 100644 index 3770b4ec3ac..00000000000 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/components/ml_models_index.vue +++ /dev/null @@ -1,49 +0,0 @@ -<script> -import { isEmpty } from 'lodash'; -import * as translations from '~/ml/model_registry/routes/models/index/translations'; -import Pagination from '~/vue_shared/components/incubation/pagination.vue'; -import ModelRow from './model_row.vue'; - -export default { - name: 'MlModelRegistryApp', - components: { - Pagination, - ModelRow, - }, - props: { - models: { - type: Array, - required: true, - }, - pageInfo: { - type: Object, - required: true, - }, - }, - computed: { - hasModels() { - return !isEmpty(this.models); - }, - }, - i18n: translations, -}; -</script> - -<template> - <div> - <div class="detail-page-header gl-flex-wrap"> - <div class="detail-page-header-body"> - <div class="page-title gl-flex-grow-1 gl-display-flex gl-align-items-center"> - <h2 class="gl-font-size-h-display gl-my-0">{{ $options.i18n.TITLE_LABEL }}</h2> - </div> - </div> - </div> - - <template v-if="hasModels"> - <model-row v-for="model in models" :key="model.name" :model="model" /> - <pagination v-bind="pageInfo" /> - </template> - - <p v-else class="gl-text-secondary">{{ $options.i18n.NO_MODELS_LABEL }}</p> - </div> -</template> diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/index.js b/app/assets/javascripts/ml/model_registry/routes/models/index/index.js deleted file mode 100644 index d303d9716af..00000000000 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import MlModelsIndex from './components/ml_models_index.vue'; - -export default MlModelsIndex; diff --git a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js b/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js deleted file mode 100644 index 9210d816373..00000000000 --- a/app/assets/javascripts/ml/model_registry/routes/models/index/translations.js +++ /dev/null @@ -1,16 +0,0 @@ -import { s__, n__, sprintf } from '~/locale'; - -export const TITLE_LABEL = s__('MlModelRegistry|Model registry'); -export const NO_MODELS_LABEL = s__('MlModelRegistry|No models registered in this project'); - -export const modelVersionCountMessage = (version, versionCount) => { - if (!versionCount) return s__('MlModelRegistry|No registered versions'); - - const message = n__( - 'MlModelRegistry|%{version} · No other versions', - 'MlModelRegistry|%{version} · %{versionCount} versions', - versionCount, - ); - - return sprintf(message, { version, versionCount }); -}; diff --git a/app/assets/javascripts/ml/model_registry/translations.js b/app/assets/javascripts/ml/model_registry/translations.js new file mode 100644 index 00000000000..89b3f45ed94 --- /dev/null +++ b/app/assets/javascripts/ml/model_registry/translations.js @@ -0,0 +1,16 @@ +import { s__, n__ } from '~/locale'; + +export const MODEL_DETAILS_TAB_LABEL = s__('MlModelRegistry|Details'); +export const MODEL_OTHER_VERSIONS_TAB_LABEL = s__('MlModelRegistry|Versions'); +export const MODEL_CANDIDATES_TAB_LABEL = s__('MlModelRegistry|Version candidates'); +export const LATEST_VERSION_LABEL = s__('MlModelRegistry|Latest version'); +export const NO_VERSIONS_LABEL = s__('MlModelRegistry|This model has no versions'); + +export const versionsCountLabel = (versionCount) => + n__('MlModelRegistry|%d version', 'MlModelRegistry|%d versions', versionCount); + +export const TITLE_LABEL = s__('MlModelRegistry|Model registry'); +export const NO_MODELS_LABEL = s__('MlModelRegistry|No models registered in this project'); + +export const modelsCountLabel = (modelCount) => + n__('MlModelRegistry|%d model', 'MlModelRegistry|%d models', modelCount); diff --git a/app/assets/javascripts/mr_notes/init.js b/app/assets/javascripts/mr_notes/init.js index 28f294589ae..5594e71641b 100644 --- a/app/assets/javascripts/mr_notes/init.js +++ b/app/assets/javascripts/mr_notes/init.js @@ -40,6 +40,7 @@ function setupMrNotesState(store, notesDataset, diffsDataset) { mrReviews: getReviewsForMergeRequest(mrPath), diffViewType: getParameterValues('view')[0] || getCookie(DIFF_VIEW_COOKIE_NAME) || INLINE_DIFF_VIEW_TYPE, + perPage: Number(diffsDataset.perPage), }); } diff --git a/app/assets/javascripts/notes/components/comment_field_layout.vue b/app/assets/javascripts/notes/components/comment_field_layout.vue index cefcc1b0c98..7673bd61631 100644 --- a/app/assets/javascripts/notes/components/comment_field_layout.vue +++ b/app/assets/javascripts/notes/components/comment_field_layout.vue @@ -1,5 +1,4 @@ <script> -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; import EmailParticipantsWarning from './email_participants_warning.vue'; import AttachmentsWarning from './attachments_warning.vue'; @@ -12,7 +11,6 @@ export default { EmailParticipantsWarning, NoteableWarning, }, - mixins: [glFeatureFlagsMixin()], props: { noteableData: { type: Object, @@ -56,11 +54,7 @@ export default { return this.emailParticipants.length && !this.isInternalNote; }, showAttachmentWarning() { - return ( - this.glFeatures.serviceDeskNewNoteEmailNativeAttachments && - this.showEmailParticipantsWarning && - this.containsLink - ); + return this.showEmailParticipantsWarning && this.containsLink; }, }, }; diff --git a/app/assets/javascripts/notes/components/discussion_locked_widget.vue b/app/assets/javascripts/notes/components/discussion_locked_widget.vue index bcf9b4cf893..a999b633f64 100644 --- a/app/assets/javascripts/notes/components/discussion_locked_widget.vue +++ b/app/assets/javascripts/notes/components/discussion_locked_widget.vue @@ -24,7 +24,9 @@ export default { }, lockedIssueWarning() { return sprintf( - __('This %{issuableDisplayName} is locked. Only project members can comment.'), + __( + 'The discussion in this %{issuableDisplayName} is locked. Only project members can comment.', + ), { issuableDisplayName: this.issuableDisplayName }, ); }, diff --git a/app/assets/javascripts/notes/components/discussion_resolve_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_button.vue index b1aee19d5b2..cc4f360a694 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_button.vue @@ -21,7 +21,11 @@ export default { </script> <template> - <gl-button :loading="isResolving" class="gl-xs-w-full ml-sm-2" @click="$emit('onClick')"> + <gl-button + :loading="isResolving" + class="gl-w-full gl-sm-w-auto ml-sm-2" + @click="$emit('onClick')" + > {{ buttonTitle }} </gl-button> </template> diff --git a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue index 4ccba011014..34cbba8ce43 100644 --- a/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue +++ b/app/assets/javascripts/notes/components/discussion_resolve_with_issue_button.vue @@ -29,7 +29,7 @@ export default { :href="url" :title="$options.i18n.buttonLabel" :aria-label="$options.i18n.buttonLabel" - class="new-issue-for-discussion discussion-create-issue-btn gl-xs-w-full" + class="new-issue-for-discussion discussion-create-issue-btn gl-w-full gl-sm-w-auto" icon="issue-new" /> </div> diff --git a/app/assets/javascripts/notes/components/note_signed_out_widget.vue b/app/assets/javascripts/notes/components/note_signed_out_widget.vue index 30d3bfcb989..738af4f6064 100644 --- a/app/assets/javascripts/notes/components/note_signed_out_widget.vue +++ b/app/assets/javascripts/notes/components/note_signed_out_widget.vue @@ -35,5 +35,5 @@ export default { </script> <template> - <div v-safe-html="signedOutText" class="disabled-comment text-center"></div> + <div v-safe-html="signedOutText" class="disabled-comment gl-text-center gl-text-secondary"></div> </template> diff --git a/app/assets/javascripts/notes/components/noteable_discussion.vue b/app/assets/javascripts/notes/components/noteable_discussion.vue index e0b1f7a8c6a..493beb8cea9 100644 --- a/app/assets/javascripts/notes/components/noteable_discussion.vue +++ b/app/assets/javascripts/notes/components/noteable_discussion.vue @@ -290,9 +290,6 @@ export default { parent: this.$el, }); }, - deleteNoteHandler(note) { - this.$emit('noteDeleted', this.discussion, note); - }, onStartReplying(discussionId) { if (this.discussion.id === discussionId) { this.showReplyForm(); @@ -329,7 +326,6 @@ export default { :is-overview-tab="isOverviewTab" :should-scroll-to-note="shouldScrollToNote" @startReplying="showReplyForm" - @deleteNote="deleteNoteHandler" > <template #avatar-badge> <slot name="avatar-badge"></slot> diff --git a/app/assets/javascripts/notes/components/noteable_note.vue b/app/assets/javascripts/notes/components/noteable_note.vue index 809b1716b91..c817655b649 100644 --- a/app/assets/javascripts/notes/components/noteable_note.vue +++ b/app/assets/javascripts/notes/components/noteable_note.vue @@ -105,6 +105,11 @@ export default { required: false, default: true, }, + discussion: { + type: Object, + required: false, + default: null, + }, }, data() { return { diff --git a/app/assets/javascripts/notes/components/notes_activity_header.vue b/app/assets/javascripts/notes/components/notes_activity_header.vue index ce642733396..b4eeea8db02 100644 --- a/app/assets/javascripts/notes/components/notes_activity_header.vue +++ b/app/assets/javascripts/notes/components/notes_activity_header.vue @@ -40,7 +40,7 @@ export default { showAiActions() { return ( this.resourceGlobalId && - this.glFeatures.openaiExperimentation && + (this.glFeatures.openaiExperimentation || this.glFeatures.aiGlobalSwitch) && this.glFeatures.summarizeNotes ); }, diff --git a/app/assets/javascripts/notes/stores/mutations.js b/app/assets/javascripts/notes/stores/mutations.js index 966f4184780..a995b9fa214 100644 --- a/app/assets/javascripts/notes/stores/mutations.js +++ b/app/assets/javascripts/notes/stores/mutations.js @@ -318,11 +318,6 @@ export default { const note = noteData; const selectedDiscussion = state.discussions.find((disc) => disc.id === note.id); note.expanded = true; // override expand flag to prevent collapse - if (note.diff_file) { - Object.assign(note, { - file_hash: note.diff_file.file_hash, - }); - } Object.assign(selectedDiscussion, { ...note }); }, diff --git a/app/assets/javascripts/observability/client.js b/app/assets/javascripts/observability/client.js index 2e976cd6230..32ff7fff128 100644 --- a/app/assets/javascripts/observability/client.js +++ b/app/assets/javascripts/observability/client.js @@ -1,12 +1,15 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; +import { logError } from '~/lib/logger'; +import { DEFAULT_SORTING_OPTION, SORTING_OPTIONS } from './constants'; function reportErrorAndThrow(e) { + logError(e); Sentry.captureException(e); throw e; } // Provisioning API spec: https://gitlab.com/gitlab-org/opstrace/opstrace/-/blob/main/provisioning-api/pkg/provisioningapi/routes.go#L59 -async function enableTraces(provisioningUrl) { +async function enableObservability(provisioningUrl) { try { // Note: axios.put(url, undefined, {withCredentials: true}) does not send cookies properly, so need to use the API below for the correct behaviour return await axios(provisioningUrl, { @@ -19,7 +22,7 @@ async function enableTraces(provisioningUrl) { } // Provisioning API spec: https://gitlab.com/gitlab-org/opstrace/opstrace/-/blob/main/provisioning-api/pkg/provisioningapi/routes.go#L37 -async function isTracingEnabled(provisioningUrl) { +async function isObservabilityEnabled(provisioningUrl) { try { const { data } = await axios.get(provisioningUrl, { withCredentials: true }); if (data && data.status) { @@ -42,18 +45,11 @@ async function fetchTrace(tracingUrl, traceId) { throw new Error('traceId is required.'); } - const { data } = await axios.get(tracingUrl, { + const { data } = await axios.get(`${tracingUrl}/${traceId}`, { withCredentials: true, - params: { - trace_id: traceId, - }, }); - if (!Array.isArray(data.traces) || data.traces.length === 0) { - throw new Error('traces are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings - } - - return data.traces[0]; + return data; } catch (e) { return reportErrorAndThrow(e); } @@ -65,9 +61,10 @@ async function fetchTrace(tracingUrl, traceId) { const SUPPORTED_FILTERS = { durationMs: ['>', '<'], operation: ['=', '!='], - serviceName: ['=', '!='], + service: ['=', '!='], period: ['='], traceId: ['=', '!='], + attribute: ['='], // free-text 'search' temporarily ignored https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2309 }; @@ -77,9 +74,10 @@ const SUPPORTED_FILTERS = { const FILTER_TO_QUERY_PARAM = { durationMs: 'duration_nano', operation: 'operation', - serviceName: 'service_name', + service: 'service_name', period: 'period', traceId: 'trace_id', + attribute: 'attribute', }; const FILTER_OPERATORS_PREFIX = { @@ -112,13 +110,32 @@ function getFilterParamName(filterName, operator) { } /** + * Process `filterValue` and append the proper query params to the `searchParams` arg + * + * It mutates `searchParams` + * + * @param {String} filterValue The filter value, in the format `attribute_name=attribute_value` + * @param {String} filterOperator The filter operator + * @param {URLSearchParams} searchParams The URLSearchParams object where to append the proper query params + */ +function handleAttributeFilter(filterValue, filterOperator, searchParams) { + const [attrName, attrValue] = filterValue.split('='); + if (attrName && attrValue) { + if (filterOperator === '=') { + searchParams.append('attr_name', attrName); + searchParams.append('attr_value', attrValue); + } + } +} + +/** * Builds URLSearchParams from a filter object of type { [filterName]: undefined | null | Array<{operator: String, value: any} } * e.g: * * filterObj = { * durationMs: [{operator: '>', value: '100'}, {operator: '<', value: '1000' }], * operation: [{operator: '=', value: 'someOp' }], - * serviceName: [{operator: '!=', value: 'foo' }] + * service: [{operator: '!=', value: 'foo' }] * } * * It handles converting the filter to the proper supported query params @@ -131,20 +148,22 @@ function filterObjToQueryParams(filterObj) { Object.keys(SUPPORTED_FILTERS).forEach((filterName) => { const filterValues = filterObj[filterName] || []; - const supportedFilters = filterValues.filter((f) => + const validFilters = filterValues.filter((f) => SUPPORTED_FILTERS[filterName].includes(f.operator), ); - supportedFilters.forEach(({ operator, value: rawValue }) => { - const paramName = getFilterParamName(filterName, operator); - - let value = rawValue; - if (filterName === 'durationMs') { - // converting durationMs to duration_nano - value *= 1000000; - } - - if (paramName && value) { - filterParams.append(paramName, value); + validFilters.forEach(({ operator, value: rawValue }) => { + if (filterName === 'attribute') { + handleAttributeFilter(rawValue, operator, filterParams); + } else { + const paramName = getFilterParamName(filterName, operator); + let value = rawValue; + if (filterName === 'durationMs') { + // converting durationMs to duration_nano + value *= 1000000; + } + if (paramName && value) { + filterParams.append(paramName, value); + } } }); }); @@ -161,12 +180,12 @@ function filterObjToQueryParams(filterObj) { * { * durationMs: [ {operator: '>', value: '100'}, {operator: '<', value: '1000'}], * operation: [ {operator: '=', value: 'someOp}], - * serviceName: [ {operator: '!=', value: 'foo}] + * service: [ {operator: '!=', value: 'foo}] * } * * @returns Array<Trace> : A list of traces */ -async function fetchTraces(tracingUrl, { filters = {}, pageToken, pageSize } = {}) { +async function fetchTraces(tracingUrl, { filters = {}, pageToken, pageSize, sortBy } = {}) { const params = filterObjToQueryParams(filters); if (pageToken) { params.append('page_token', pageToken); @@ -174,6 +193,10 @@ async function fetchTraces(tracingUrl, { filters = {}, pageToken, pageSize } = { if (pageSize) { params.append('page_size', pageSize); } + const sortOrder = Object.values(SORTING_OPTIONS).includes(sortBy) + ? sortBy + : DEFAULT_SORTING_OPTION; + params.append('sort', sortOrder); try { const { data } = await axios.get(tracingUrl, { @@ -228,18 +251,54 @@ async function fetchOperations(operationsUrl, serviceName) { } } -export function buildClient({ provisioningUrl, tracingUrl, servicesUrl, operationsUrl } = {}) { - if (!provisioningUrl || !tracingUrl || !servicesUrl || !operationsUrl) { - throw new Error( - 'missing required params. provisioningUrl, tracingUrl, servicesUrl, operationsUrl are required', - ); +async function fetchMetrics(metricsUrl) { + try { + const { data } = await axios.get(metricsUrl, { + withCredentials: true, + }); + if (!Array.isArray(data.metrics)) { + throw new Error('metrics are missing/invalid in the response'); // eslint-disable-line @gitlab/require-i18n-strings + } + return data; + } catch (e) { + return reportErrorAndThrow(e); + } +} + +export function buildClient(config) { + if (!config) { + throw new Error('No options object provided'); // eslint-disable-line @gitlab/require-i18n-strings + } + + const { provisioningUrl, tracingUrl, servicesUrl, operationsUrl, metricsUrl } = config; + + if (typeof provisioningUrl !== 'string') { + throw new Error('provisioningUrl param must be a string'); } + + if (typeof tracingUrl !== 'string') { + throw new Error('tracingUrl param must be a string'); + } + + if (typeof servicesUrl !== 'string') { + throw new Error('servicesUrl param must be a string'); + } + + if (typeof operationsUrl !== 'string') { + throw new Error('operationsUrl param must be a string'); + } + + if (typeof metricsUrl !== 'string') { + throw new Error('metricsUrl param must be a string'); + } + return { - enableTraces: () => enableTraces(provisioningUrl), - isTracingEnabled: () => isTracingEnabled(provisioningUrl), - fetchTraces: (filters) => fetchTraces(tracingUrl, filters), + enableObservability: () => enableObservability(provisioningUrl), + isObservabilityEnabled: () => isObservabilityEnabled(provisioningUrl), + fetchTraces: (options) => fetchTraces(tracingUrl, options), fetchTrace: (traceId) => fetchTrace(tracingUrl, traceId), fetchServices: () => fetchServices(servicesUrl), fetchOperations: (serviceName) => fetchOperations(operationsUrl, serviceName), + fetchMetrics: () => fetchMetrics(metricsUrl), }; } diff --git a/app/assets/javascripts/observability/components/loader/constants.js b/app/assets/javascripts/observability/components/loader/constants.js new file mode 100644 index 00000000000..5c2d8ad0d1b --- /dev/null +++ b/app/assets/javascripts/observability/components/loader/constants.js @@ -0,0 +1,20 @@ +import { __ } from '~/locale'; + +export const CONTENT_STATE = Object.freeze({ + ERROR: 'error', + LOADED: 'loaded', +}); + +export const LOADER_STATE = Object.freeze({ + ERROR: 'error', + VISIBLE: 'visible', + HIDDEN: 'hidden', +}); + +export const DEFAULT_TIMERS = Object.freeze({ + TIMEOUT_MS: 20000, + CONTENT_WAIT_MS: 500, +}); + +export const TIMEOUT_ERROR_LABEL = __('Unable to load the page'); +export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.'); diff --git a/app/assets/javascripts/observability/components/skeleton/index.vue b/app/assets/javascripts/observability/components/loader/index.vue index c3d0a7c90b1..6b92dc428d2 100644 --- a/app/assets/javascripts/observability/components/skeleton/index.vue +++ b/app/assets/javascripts/observability/components/loader/index.vue @@ -1,31 +1,30 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> -import { GlSkeletonLoader, GlAlert, GlLoadingIcon } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import { - SKELETON_STATE, + LOADER_STATE, + CONTENT_STATE, DEFAULT_TIMERS, TIMEOUT_ERROR_LABEL, TIMEOUT_ERROR_MESSAGE, - SKELETON_SPINNER_VARIANT, -} from '../../constants'; +} from './constants'; export default { components: { - GlSkeletonLoader, GlAlert, GlLoadingIcon, }, - SKELETON_STATE, + LOADER_STATE, i18n: { TIMEOUT_ERROR_LABEL, TIMEOUT_ERROR_MESSAGE, }, props: { - variant: { + contentState: { type: String, required: false, - default: '', + default: null, }, }, data() { @@ -35,18 +34,25 @@ export default { errorTimeout: null, }; }, + computed: { - skeletonVisible() { - return this.state === SKELETON_STATE.VISIBLE; + loaderVisible() { + return this.state === LOADER_STATE.VISIBLE; }, - skeletonHidden() { - return this.state === SKELETON_STATE.HIDDEN; + loaderHidden() { + return this.state === LOADER_STATE.HIDDEN; }, errorVisible() { - return this.state === SKELETON_STATE.ERROR; + return this.state === LOADER_STATE.ERROR; }, - spinnerVariant() { - return this.variant === SKELETON_SPINNER_VARIANT; + }, + watch: { + contentState(newValue) { + if (newValue === CONTENT_STATE.LOADED) { + this.onContentLoaded(); + } else if (newValue === CONTENT_STATE.ERROR) { + this.onError(); + } }, }, mounted() { @@ -62,7 +68,7 @@ export default { clearTimeout(this.errorTimeout); clearTimeout(this.loadingTimeout); - this.hideSkeleton(); + this.hideLoader(); }, onError() { clearTimeout(this.errorTimeout); @@ -74,10 +80,10 @@ export default { this.loadingTimeout = setTimeout(() => { /** * If content is not loaded within CONTENT_WAIT_MS, - * show the skeleton + * show the loader */ - if (this.state !== SKELETON_STATE.HIDDEN) { - this.showSkeleton(); + if (this.state !== LOADER_STATE.HIDDEN) { + this.showLoader(); } }, DEFAULT_TIMERS.CONTENT_WAIT_MS); }, @@ -87,19 +93,19 @@ export default { * If content is not loaded within TIMEOUT_MS, * show the error dialog */ - if (this.state !== SKELETON_STATE.HIDDEN) { + if (this.state !== LOADER_STATE.HIDDEN) { this.showError(); } }, DEFAULT_TIMERS.TIMEOUT_MS); }, - hideSkeleton() { - this.state = SKELETON_STATE.HIDDEN; + hideLoader() { + this.state = LOADER_STATE.HIDDEN; }, - showSkeleton() { - this.state = SKELETON_STATE.VISIBLE; + showLoader() { + this.state = LOADER_STATE.VISIBLE; }, showError() { - this.state = SKELETON_STATE.ERROR; + this.state = LOADER_STATE.ERROR; }, }, }; @@ -107,19 +113,12 @@ export default { <template> <div class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch"> <transition name="fade"> - <div v-if="skeletonVisible" class="gl-px-5 gl-my-5"> - <gl-loading-icon v-if="spinnerVariant" size="lg" /> - <gl-skeleton-loader v-else> - <rect y="2" width="10" height="8" /> - <rect y="2" x="15" width="15" height="8" /> - <rect y="2" x="35" width="15" height="8" /> - <rect y="15" width="400" height="30" /> - </gl-skeleton-loader> + <div v-if="loaderVisible" class="gl-px-5 gl-my-5"> + <gl-loading-icon size="lg" /> </div> - <!-- The double condition is only here temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 --> <div - v-else-if="spinnerVariant && skeletonHidden" + v-else-if="loaderHidden" data-testid="content-wrapper" class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" > @@ -136,16 +135,5 @@ export default { > {{ $options.i18n.TIMEOUT_ERROR_MESSAGE }} </gl-alert> - - <!-- This is only kept temporarily for back-compatibility reasons. Will be removed in next iteration https://gitlab.com/gitlab-org/opstrace/opstrace/-/issues/2275 --> - <transition v-if="!spinnerVariant"> - <div - v-show="skeletonHidden" - data-testid="content-wrapper" - class="gl-flex-grow-1 gl-display-flex gl-flex-direction-column gl-flex-align-items-stretch" - > - <slot></slot> - </div> - </transition> </div> </template> diff --git a/app/assets/javascripts/observability/components/observability_container.vue b/app/assets/javascripts/observability/components/observability_container.vue index 1518c132560..b89c2624f81 100644 --- a/app/assets/javascripts/observability/components/observability_container.vue +++ b/app/assets/javascripts/observability/components/observability_container.vue @@ -1,32 +1,17 @@ <script> +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import { logError } from '~/lib/logger'; import { buildClient } from '../client'; -import { SKELETON_SPINNER_VARIANT } from '../constants'; -import ObservabilitySkeleton from './skeleton/index.vue'; +import ObservabilityLoader from './loader/index.vue'; +import { CONTENT_STATE } from './loader/constants'; export default { - SKELETON_SPINNER_VARIANT, components: { - ObservabilitySkeleton, + ObservabilityLoader, }, props: { - oauthUrl: { - type: String, - required: true, - }, - provisioningUrl: { - type: String, - required: true, - }, - tracingUrl: { - type: String, - required: true, - }, - servicesUrl: { - type: String, - required: true, - }, - operationsUrl: { - type: String, + apiConfig: { + type: Object, required: true, }, }, @@ -34,6 +19,7 @@ export default { return { observabilityClient: null, authCompleted: false, + loaderContentState: null, }; }, mounted() { @@ -53,7 +39,7 @@ export default { }, methods: { messageHandler(e) { - const isExpectedOrigin = e.origin === new URL(this.oauthUrl).origin; + const isExpectedOrigin = e.origin === new URL(this.apiConfig.oauthUrl).origin; if (!isExpectedOrigin) return; const { data } = e; @@ -63,17 +49,14 @@ export default { const { status, message, statusCode } = data; if (status === 'success') { - this.observabilityClient = buildClient({ - provisioningUrl: this.provisioningUrl, - tracingUrl: this.tracingUrl, - servicesUrl: this.servicesUrl, - operationsUrl: this.operationsUrl, - }); - this.$refs.observabilitySkeleton?.onContentLoaded(); + this.observabilityClient = buildClient(this.apiConfig); + this.$emit('observability-client-ready', this.observabilityClient); + this.loaderContentState = CONTENT_STATE.LOADED; } else if (status === 'error') { - // eslint-disable-next-line @gitlab/require-i18n-strings,no-console - console.error('GOB auth failed with error:', message, statusCode); - this.$refs.observabilitySkeleton?.onError(); + const error = new Error(`GOB auth failed with error: ${message} - status: ${statusCode}`); + Sentry.captureException(error); + logError(error); + this.loaderContentState = CONTENT_STATE.ERROR; } this.authCompleted = true; } @@ -88,15 +71,12 @@ export default { v-if="!authCompleted" sandbox="allow-same-origin allow-forms allow-scripts" hidden - :src="oauthUrl" + :src="apiConfig.oauthUrl" data-testid="observability-oauth-iframe" ></iframe> - <observability-skeleton - ref="observabilitySkeleton" - :variant="$options.SKELETON_SPINNER_VARIANT" - > + <observability-loader :content-state="loaderContentState"> <slot v-if="observabilityClient" :observability-client="observabilityClient"></slot> - </observability-skeleton> + </observability-loader> </div> </template> diff --git a/app/assets/javascripts/observability/components/observability_empty_state.vue b/app/assets/javascripts/observability/components/observability_empty_state.vue new file mode 100644 index 00000000000..d4d8b887934 --- /dev/null +++ b/app/assets/javascripts/observability/components/observability_empty_state.vue @@ -0,0 +1,36 @@ +<script> +import EMPTY_TRACING_SVG from '@gitlab/svgs/dist/illustrations/monitoring/tracing.svg?url'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; +import { s__ } from '~/locale'; + +export default { + EMPTY_TRACING_SVG, + i18n: { + title: s__('Observability|Get started with GitLab Observability'), + description: s__('Observability|Monitor your applications with GitLab Observability.'), + enableButtonText: s__('Observability|Enable'), + }, + components: { + GlEmptyState, + GlButton, + }, +}; +</script> + +<template> + <gl-empty-state + :title="$options.i18n.title" + :svg-path="$options.EMPTY_TRACING_SVG" + :svg-height="null" + > + <template #description> + <span>{{ $options.i18n.description }}</span> + </template> + + <template #actions> + <gl-button variant="confirm" class="gl-mx-2 gl-mb-3" @click="$emit('enable-observability')"> + {{ $options.i18n.enableButtonText }} + </gl-button> + </template> + </gl-empty-state> +</template> diff --git a/app/assets/javascripts/observability/components/provisioned_observability_container.vue b/app/assets/javascripts/observability/components/provisioned_observability_container.vue new file mode 100644 index 00000000000..95ffd54fd1d --- /dev/null +++ b/app/assets/javascripts/observability/components/provisioned_observability_container.vue @@ -0,0 +1,95 @@ +<script> +import { GlLoadingIcon } from '@gitlab/ui'; +import ObservabilityContainer from '~/observability/components/observability_container.vue'; +import { s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import ObservabilityEmptyState from './observability_empty_state.vue'; + +export default { + components: { + ObservabilityContainer, + ObservabilityEmptyState, + GlLoadingIcon, + }, + props: { + apiConfig: { + type: Object, + required: true, + }, + }, + data() { + return { + loading: false, + /** + * observabilityEnabled: boolean | null. + * null identifies a state where we don't know if observability is enabled or not (e.g. when fetching the status from the API fails) + */ + observabilityEnabled: null, + observabilityClient: null, + }; + }, + computed: { + isObservabilityStatusKnown() { + return this.observabilityEnabled !== null; + }, + isObservabilityDisabled() { + return this.observabilityEnabled === false; + }, + isObservabilityEnabled() { + return this.observabilityEnabled; + }, + }, + methods: { + onObservabilityClientReady(client) { + this.observabilityClient = client; + this.checkEnabled(); + }, + async checkEnabled() { + this.loading = true; + try { + this.observabilityEnabled = await this.observabilityClient.isObservabilityEnabled(); + } catch (e) { + createAlert({ + message: s__('Observability|Error: Failed to load page. Try reloading the page.'), + }); + } finally { + this.loading = false; + } + }, + async onEnableObservability() { + this.loading = true; + try { + await this.observabilityClient.enableObservability(); + this.observabilityEnabled = true; + } catch (e) { + createAlert({ + message: s__( + 'Observability|Error: Failed to enable GitLab Observability. Please retry later.', + ), + }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <observability-container + :api-config="apiConfig" + @observability-client-ready="onObservabilityClientReady" + > + <div v-if="loading" class="gl-py-5"> + <gl-loading-icon size="lg" /> + </div> + + <template v-else-if="isObservabilityStatusKnown"> + <observability-empty-state + v-if="isObservabilityDisabled" + @enable-observability="onEnableObservability" + /> + <slot v-if="isObservabilityEnabled" :observability-client="observabilityClient"></slot> + </template> + </observability-container> +</template> diff --git a/app/assets/javascripts/observability/constants.js b/app/assets/javascripts/observability/constants.js index 83eaea185e5..34c43a10fc0 100644 --- a/app/assets/javascripts/observability/constants.js +++ b/app/assets/javascripts/observability/constants.js @@ -1,17 +1,7 @@ -import { __ } from '~/locale'; - -export const SKELETON_SPINNER_VARIANT = 'spinner'; - -export const SKELETON_STATE = Object.freeze({ - ERROR: 'error', - VISIBLE: 'visible', - HIDDEN: 'hidden', -}); - -export const DEFAULT_TIMERS = Object.freeze({ - TIMEOUT_MS: 20000, - CONTENT_WAIT_MS: 500, -}); - -export const TIMEOUT_ERROR_LABEL = __('Unable to load the page'); -export const TIMEOUT_ERROR_MESSAGE = __('Reload the page to try again.'); +export const SORTING_OPTIONS = { + TIMESTAMP_DESC: 'timestamp_desc', + TIMESTAMP_ASC: 'timestamp_asc', + DURATION_DESC: 'duration_desc', + DURATION_ASC: 'duration_asc', +}; +export const DEFAULT_SORTING_OPTION = SORTING_OPTIONS.TIMESTAMP_DESC; diff --git a/app/assets/javascripts/organizations/mock_data.js b/app/assets/javascripts/organizations/mock_data.js index d281a0d8a1c..725b6ac1ad8 100644 --- a/app/assets/javascripts/organizations/mock_data.js +++ b/app/assets/javascripts/organizations/mock_data.js @@ -281,10 +281,31 @@ export const organizationGroups = { ], }; -export const createOrganizationResponse = { +export const organizationCreateResponse = { + data: { + organizationCreate: { + organization: { + id: 'gid://gitlab/Organizations::Organization/1', + webUrl: 'http://127.0.0.1:3000/-/organizations/default', + }, + errors: [], + }, + }, +}; + +export const organizationCreateResponseWithErrors = { + data: { + organizationCreate: { + organization: null, + errors: ['Path is too short (minimum is 2 characters)'], + }, + }, +}; + +export const updateOrganizationResponse = { organization: { - name: 'Default', - path: '/-/organizations/default', + id: 'gid://gitlab/Organizations/1', + name: 'Default updated', }, errors: [], }; diff --git a/app/assets/javascripts/organizations/new/components/app.vue b/app/assets/javascripts/organizations/new/components/app.vue index 8f71fdfe68b..f7f7b79d52b 100644 --- a/app/assets/javascripts/organizations/new/components/app.vue +++ b/app/assets/javascripts/organizations/new/components/app.vue @@ -4,12 +4,13 @@ import { s__ } from '~/locale'; import { visitUrlWithAlerts } from '~/lib/utils/url_utility'; import { createAlert } from '~/alert'; import { helpPagePath } from '~/helpers/help_page_helper'; -import createOrganizationMutation from '../graphql/mutations/create_organization.mutation.graphql'; +import FormErrorsAlert from '~/vue_shared/components/form/errors_alert.vue'; +import organizationCreateMutation from '../graphql/mutations/organization_create.mutation.graphql'; import NewEditForm from '../../shared/components/new_edit_form.vue'; export default { name: 'OrganizationNewApp', - components: { NewEditForm, GlSprintf, GlLink }, + components: { NewEditForm, GlSprintf, GlLink, FormErrorsAlert }, i18n: { pageTitle: s__('Organization|New organization'), pageDescription: s__( @@ -22,6 +23,7 @@ export default { data() { return { loading: false, + errors: [], }; }, computed: { @@ -35,21 +37,22 @@ export default { try { const { data: { - createOrganization: { organization, errors }, + organizationCreate: { organization, errors }, }, } = await this.$apollo.mutate({ - mutation: createOrganizationMutation, + mutation: organizationCreateMutation, variables: { - ...formValues, + input: { name: formValues.name, path: formValues.path }, }, }); if (errors.length) { - // TODO: handle errors when using real API after https://gitlab.com/gitlab-org/gitlab/-/issues/417891 is complete. + this.errors = errors; + return; } - visitUrlWithAlerts(organization.path, [ + visitUrlWithAlerts(organization.webUrl, [ { id: 'organization-successfully-created', title: this.$options.i18n.successAlertTitle, @@ -69,6 +72,7 @@ export default { <template> <div class="gl-py-6"> + <form-errors-alert v-model="errors" /> <h1 class="gl-mt-0 gl-font-size-h-display">{{ $options.i18n.pageTitle }}</h1> <p> <gl-sprintf :message="$options.i18n.pageDescription"> diff --git a/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql b/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql deleted file mode 100644 index 766c7e96d14..00000000000 --- a/app/assets/javascripts/organizations/new/graphql/mutations/create_organization.mutation.graphql +++ /dev/null @@ -1,9 +0,0 @@ -mutation createOrganization($input: LocalCreateOrganizationInput!) { - createOrganization(input: $input) @client { - organization { - name - path - } - errors - } -} diff --git a/app/assets/javascripts/organizations/new/graphql/mutations/organization_create.mutation.graphql b/app/assets/javascripts/organizations/new/graphql/mutations/organization_create.mutation.graphql new file mode 100644 index 00000000000..81fbfddd1e4 --- /dev/null +++ b/app/assets/javascripts/organizations/new/graphql/mutations/organization_create.mutation.graphql @@ -0,0 +1,9 @@ +mutation organizationCreate($input: OrganizationCreateInput!) { + organizationCreate(input: $input) { + organization { + id + webUrl + } + errors + } +} diff --git a/app/assets/javascripts/organizations/new/graphql/typedefs.graphql b/app/assets/javascripts/organizations/new/graphql/typedefs.graphql deleted file mode 100644 index f708c4ad162..00000000000 --- a/app/assets/javascripts/organizations/new/graphql/typedefs.graphql +++ /dev/null @@ -1,5 +0,0 @@ -# TODO: Use real input type when https://gitlab.com/gitlab-org/gitlab/-/issues/417891 is complete. -input LocalCreateOrganizationInput { - name: String - path: String -} diff --git a/app/assets/javascripts/organizations/new/index.js b/app/assets/javascripts/organizations/new/index.js index a65603227f6..9c7e5344800 100644 --- a/app/assets/javascripts/organizations/new/index.js +++ b/app/assets/javascripts/organizations/new/index.js @@ -3,7 +3,6 @@ import VueApollo from 'vue-apollo'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import createDefaultClient from '~/lib/graphql'; -import resolvers from '../shared/graphql/resolvers'; import App from './components/app.vue'; export const initOrganizationsNew = () => { @@ -17,7 +16,7 @@ export const initOrganizationsNew = () => { const { organizationsPath, rootUrl } = convertObjectPropsToCamelCase(JSON.parse(appData)); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(resolvers), + defaultClient: createDefaultClient(), }); return new Vue({ diff --git a/app/assets/javascripts/organizations/profile/preferences/index.js b/app/assets/javascripts/organizations/profile/preferences/index.js new file mode 100644 index 00000000000..0b0dd313cd8 --- /dev/null +++ b/app/assets/javascripts/organizations/profile/preferences/index.js @@ -0,0 +1,41 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { s__ } from '~/locale'; +import OrganizationSelect from '~/vue_shared/components/entity_select/organization_select.vue'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import resolvers from '../../shared/graphql/resolvers'; + +export const initHomeOrganizationSetting = () => { + const el = document.getElementById('js-home-organization-setting'); + + if (!el) return false; + + const { + dataset: { appData }, + } = el; + const { initialSelection } = convertObjectPropsToCamelCase(JSON.parse(appData)); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'HomeOrganizationSetting', + apolloProvider, + render(createElement) { + return createElement(OrganizationSelect, { + props: { + block: true, + label: s__('Organization|Home organization'), + description: s__('Organization|Choose what organization you want to see by default.'), + inputName: 'home_organization', + inputId: 'home_organization', + initialSelection, + toggleClass: 'gl-form-input-xl', + }, + }); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/settings/general/components/app.vue b/app/assets/javascripts/organizations/settings/general/components/app.vue new file mode 100644 index 00000000000..134fcc17b54 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/components/app.vue @@ -0,0 +1,14 @@ +<script> +import OrganizationSettings from './organization_settings.vue'; + +export default { + name: 'OrganizationSettingsGeneralApp', + components: { OrganizationSettings }, +}; +</script> + +<template> + <div> + <organization-settings /> + </div> +</template> diff --git a/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue new file mode 100644 index 00000000000..14826825cd6 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/components/organization_settings.vue @@ -0,0 +1,77 @@ +<script> +import { s__, __ } from '~/locale'; +import { createAlert, VARIANT_INFO } from '~/alert'; +import NewEditForm from '~/organizations/shared/components/new_edit_form.vue'; +import { FORM_FIELD_NAME, FORM_FIELD_ID } from '~/organizations/shared/constants'; +import SettingsBlock from '~/vue_shared/components/settings/settings_block.vue'; +import updateOrganizationMutation from '../graphql/mutations/update_organization.mutation.graphql'; + +export default { + name: 'OrganizationSettings', + components: { NewEditForm, SettingsBlock }, + inject: ['organization'], + i18n: { + submitButtonText: __('Save changes'), + settingsBlock: { + title: s__('Organization|Organization settings'), + description: s__('Organization|Update your organization name, description, and avatar.'), + }, + errorMessage: s__( + 'Organization|An error occurred updating your organization. Please try again.', + ), + successMessage: s__('Organization|Organization was successfully updated.'), + }, + fieldsToRender: [FORM_FIELD_NAME, FORM_FIELD_ID], + data() { + return { + loading: false, + }; + }, + methods: { + async onSubmit(formValues) { + this.loading = true; + try { + const { + data: { + updateOrganization: { errors }, + }, + } = await this.$apollo.mutate({ + mutation: updateOrganizationMutation, + variables: { + id: this.organization.id, + name: formValues.name, + }, + }); + + if (errors.length) { + // TODO: handle errors when using real API after https://gitlab.com/gitlab-org/gitlab/-/issues/419608 is complete. + return; + } + + createAlert({ message: this.$options.i18n.successMessage, variant: VARIANT_INFO }); + } catch (error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + } finally { + this.loading = false; + } + }, + }, +}; +</script> + +<template> + <settings-block default-expanded slide-animated> + <template #title>{{ $options.i18n.settingsBlock.title }}</template> + <template #description>{{ $options.i18n.settingsBlock.description }}</template> + <template #default> + <new-edit-form + :loading="loading" + :initial-form-values="organization" + :fields-to-render="$options.fieldsToRender" + :submit-button-text="$options.i18n.submitButtonText" + :show-cancel-button="false" + @submit="onSubmit" + /> + </template> + </settings-block> +</template> diff --git a/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql b/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql new file mode 100644 index 00000000000..b571a523260 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/graphql/mutations/update_organization.mutation.graphql @@ -0,0 +1,9 @@ +mutation updateOrganization($input: LocalUpdateOrganizationInput!) { + updateOrganization(input: $input) @client { + organization { + id + name + } + errors + } +} diff --git a/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql b/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql new file mode 100644 index 00000000000..eb81a7b0321 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/graphql/typedefs.graphql @@ -0,0 +1,5 @@ +# TODO: Use real input type when https://gitlab.com/gitlab-org/gitlab/-/issues/419608 is complete. +input LocalUpdateOrganizationInput { + id: ID! + name: String +} diff --git a/app/assets/javascripts/organizations/settings/general/index.js b/app/assets/javascripts/organizations/settings/general/index.js new file mode 100644 index 00000000000..36303c32b94 --- /dev/null +++ b/app/assets/javascripts/organizations/settings/general/index.js @@ -0,0 +1,38 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; + +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import createDefaultClient from '~/lib/graphql'; +import resolvers from '../../shared/graphql/resolvers'; +import App from './components/app.vue'; + +export const initOrganizationsSettingsGeneral = () => { + const el = document.getElementById('js-organizations-settings-general'); + + if (!el) return false; + + const { + dataset: { appData }, + } = el; + const { organization, organizationsPath, rootUrl } = convertObjectPropsToCamelCase( + JSON.parse(appData), + ); + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(resolvers), + }); + + return new Vue({ + el, + name: 'OrganizationSettingsGeneralRoot', + apolloProvider, + provide: { + organization, + organizationsPath, + rootUrl, + }, + render(createElement) { + return createElement(App); + }, + }); +}; diff --git a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue index db33f240966..8aaa680036f 100644 --- a/app/assets/javascripts/organizations/shared/components/new_edit_form.vue +++ b/app/assets/javascripts/organizations/shared/components/new_edit_form.vue @@ -12,6 +12,7 @@ import { formValidators } from '@gitlab/ui/dist/utils'; import { s__, __ } from '~/locale'; import { slugify } from '~/lib/utils/text_utility'; import { joinPaths } from '~/lib/utils/url_utility'; +import { FORM_FIELD_NAME, FORM_FIELD_ID, FORM_FIELD_PATH } from '../constants'; export default { name: 'NewEditForm', @@ -25,43 +26,47 @@ export default { GlTruncate, }, i18n: { - createOrganization: s__('Organization|Create organization'), cancel: __('Cancel'), pathPlaceholder: s__('Organization|my-organization'), }, formId: 'new-organization-form', - fields: { - name: { - label: s__('Organization|Organization name'), - validators: [formValidators.required(s__('Organization|Organization name is required.'))], - groupAttrs: { - description: s__( - 'Organization|Must start with a letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.', - ), - }, - inputAttrs: { - class: 'gl-md-form-input-lg', - placeholder: s__('Organization|My organization'), - }, - }, - path: { - label: s__('Organization|Organization URL'), - validators: [formValidators.required(s__('Organization|Organization URL is required.'))], - }, - }, inject: ['organizationsPath', 'rootUrl'], props: { loading: { type: Boolean, required: true, }, + initialFormValues: { + type: Object, + required: false, + default() { + return { + [FORM_FIELD_NAME]: '', + [FORM_FIELD_PATH]: '', + }; + }, + }, + fieldsToRender: { + type: Array, + required: false, + default() { + return [FORM_FIELD_NAME, FORM_FIELD_PATH]; + }, + }, + submitButtonText: { + type: String, + required: false, + default: s__('Organization|Create organization'), + }, + showCancelButton: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { - formValues: { - name: '', - path: '', - }, + formValues: this.initialFormValues, hasPathBeenManuallySet: false, }; }, @@ -69,10 +74,63 @@ export default { baseUrl() { return joinPaths(this.rootUrl, this.organizationsPath, '/'); }, + fields() { + const fields = { + [FORM_FIELD_NAME]: { + label: s__('Organization|Organization name'), + validators: [formValidators.required(s__('Organization|Organization name is required.'))], + groupAttrs: { + class: this.fieldsToRender.includes(FORM_FIELD_ID) + ? 'gl-flex-grow-1 gl-md-form-input-lg' + : 'gl-flex-grow-1', + description: s__( + 'Organization|Must start with a letter, digit, emoji, or underscore. Can also contain periods, dashes, spaces, and parentheses.', + ), + }, + inputAttrs: { + class: !this.fieldsToRender.includes(FORM_FIELD_ID) ? 'gl-md-form-input-lg' : null, + placeholder: s__('Organization|My organization'), + }, + }, + [FORM_FIELD_ID]: { + label: s__('Organization|Organization ID'), + groupAttrs: { + class: 'gl-md-form-input-lg gl-flex-grow-1', + }, + inputAttrs: { + disabled: true, + }, + }, + [FORM_FIELD_PATH]: { + label: s__('Organization|Organization URL'), + validators: [ + formValidators.required(s__('Organization|Organization URL is required.')), + formValidators.factory( + s__('Organization|Organization URL must be a minimum of two characters.'), + (val) => val.length >= 2, + ), + ], + groupAttrs: { + class: 'gl-w-full', + }, + }, + }; + + return Object.entries(fields).reduce((accumulator, [fieldKey, fieldDefinition]) => { + if (!this.fieldsToRender.includes(fieldKey)) { + return accumulator; + } + + return { + ...accumulator, + [fieldKey]: fieldDefinition, + }; + }, {}); + }, }, watch: { 'formValues.name': function watchName(value) { - if (this.hasPathBeenManuallySet) { + if (this.hasPathBeenManuallySet || !this.fieldsToRender.includes(FORM_FIELD_PATH)) { return; } @@ -93,7 +151,8 @@ export default { <gl-form-fields v-model="formValues" :form-id="$options.formId" - :fields="$options.fields" + :fields="fields" + class="gl-display-flex gl-column-gap-5 gl-flex-wrap" @submit="$emit('submit', formValues)" > <template #input(path)="{ id, value, validation, input, blur }"> @@ -117,9 +176,11 @@ export default { </gl-form-fields> <div class="gl-display-flex gl-gap-3"> <gl-button type="submit" variant="confirm" class="js-no-auto-disable" :loading="loading">{{ - $options.i18n.createOrganization + submitButtonText + }}</gl-button> + <gl-button v-if="showCancelButton" :href="organizationsPath">{{ + $options.i18n.cancel }}</gl-button> - <gl-button :href="organizationsPath">{{ $options.i18n.cancel }}</gl-button> </div> </gl-form> </template> diff --git a/app/assets/javascripts/organizations/shared/constants.js b/app/assets/javascripts/organizations/shared/constants.js new file mode 100644 index 00000000000..010613bc9fd --- /dev/null +++ b/app/assets/javascripts/organizations/shared/constants.js @@ -0,0 +1,3 @@ +export const FORM_FIELD_NAME = 'name'; +export const FORM_FIELD_ID = 'id'; +export const FORM_FIELD_PATH = 'path'; diff --git a/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql new file mode 100644 index 00000000000..1d95786fcb0 --- /dev/null +++ b/app/assets/javascripts/organizations/shared/graphql/queries/organization.query.graphql @@ -0,0 +1,9 @@ +query getOrganization($id: ID!) { + organization(id: $id) @client { + id + name + descriptionHtml + avatarUrl + webUrl + } +} diff --git a/app/assets/javascripts/organizations/shared/graphql/resolvers.js b/app/assets/javascripts/organizations/shared/graphql/resolvers.js index 9f7e9b22e1d..9ed1be62352 100644 --- a/app/assets/javascripts/organizations/shared/graphql/resolvers.js +++ b/app/assets/javascripts/organizations/shared/graphql/resolvers.js @@ -2,7 +2,7 @@ import { organizations, organizationProjects, organizationGroups, - createOrganizationResponse, + updateOrganizationResponse, } from '../../mock_data'; const simulateLoading = () => { @@ -34,11 +34,11 @@ export default { }, }, Mutation: { - createOrganization: async () => { + updateOrganization: async () => { // Simulate API loading await simulateLoading(); - return createOrganizationResponse; + return updateOrganizationResponse; }, }, }; diff --git a/app/assets/javascripts/organizations/users/components/app.vue b/app/assets/javascripts/organizations/users/components/app.vue new file mode 100644 index 00000000000..ae22bedd69a --- /dev/null +++ b/app/assets/javascripts/organizations/users/components/app.vue @@ -0,0 +1,51 @@ +<script> +import { __, s__ } from '~/locale'; +import { createAlert } from '~/alert'; +import organizationUsersQuery from '../graphql/organization_users.query.graphql'; + +export default { + name: 'OrganizationsUsersApp', + i18n: { + users: __('Users'), + loadingPlaceholder: __('Loading'), + errorMessage: s__( + 'Organization|An error occurred loading the organization users. Please refresh the page to try again.', + ), + }, + inject: ['organizationGid'], + data() { + return { + users: [], + }; + }, + apollo: { + users: { + query: organizationUsersQuery, + variables() { + return { id: this.organizationGid }; + }, + update(data) { + return data.organization.organizationUsers.nodes; + }, + error(error) { + createAlert({ message: this.$options.i18n.errorMessage, error, captureError: true }); + }, + }, + }, + computed: { + loading() { + return this.$apollo.queries.users.loading; + }, + }, +}; +</script> + +<template> + <section> + <h1 class="gl-my-4 gl-font-size-h-display">{{ $options.i18n.users }}</h1> + <template v-if="loading"> + {{ $options.i18n.loadingPlaceholder }} + </template> + <div data-testid="organization-users">{{ users }}</div> + </section> +</template> diff --git a/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql new file mode 100644 index 00000000000..a0b2a639401 --- /dev/null +++ b/app/assets/javascripts/organizations/users/graphql/organization_users.query.graphql @@ -0,0 +1,17 @@ +query getOrganizationUsers($id: OrganizationsOrganizationID!) { + organization(id: $id) { + id + organizationUsers { + nodes { + badges { + text + variant + } + id + user { + id + } + } + } + } +} diff --git a/app/assets/javascripts/organizations/users/index.js b/app/assets/javascripts/organizations/users/index.js new file mode 100644 index 00000000000..76656243075 --- /dev/null +++ b/app/assets/javascripts/organizations/users/index.js @@ -0,0 +1,29 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createDefaultClient from '~/lib/graphql'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import OrganizationsUsersApp from './components/app.vue'; + +export const initOrganizationsUsers = () => { + const el = document.getElementById('js-organizations-users'); + + if (!el) return false; + + const apolloProvider = new VueApollo({ + defaultClient: createDefaultClient(), + }); + + const { organizationGid } = convertObjectPropsToCamelCase(el.dataset); + + return new Vue({ + el, + name: 'OrganizationsUsersRoot', + apolloProvider, + provide: { + organizationGid, + }, + render(createElement) { + return createElement(OrganizationsUsersApp); + }, + }); +}; diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue index 7c594a6c091..934bb206cc4 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue @@ -219,7 +219,6 @@ export default { <template> <div> <persisted-search - class="gl-mb-5" :sortable-fields="$options.sortableFields" :default-order="$options.sortableFields[0].orderBy" default-sort="asc" diff --git a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue index a1c4d7ea1f2..89a8c4c2a2f 100644 --- a/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/container_registry/explorer/pages/list.vue @@ -293,7 +293,6 @@ export default { </template> </registry-header> <persisted-search - class="gl-mb-5" :sortable-fields="$options.searchConfig" :default-order="$options.searchConfig[0].orderBy" default-sort="desc" diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue index bfe0c250dd9..cf1ee44b82e 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/components/list/harbor_list_row.vue @@ -44,12 +44,7 @@ export default { <template> <list-item v-bind="$attrs"> <template #left-primary> - <router-link - class="gl-text-body gl-font-weight-bold" - data-testid="details-link" - data-qa-selector="registry_image_content" - :to="linkTo" - > + <router-link class="gl-text-body gl-font-weight-bold" data-testid="details-link" :to="linkTo"> {{ item.name }} </router-link> <clipboard-button diff --git a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue index bff32a124bc..bf0cdd5db10 100644 --- a/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue +++ b/app/assets/javascripts/packages_and_registries/harbor_registry/pages/details.vue @@ -135,7 +135,6 @@ export default { <div class="gl-my-3"> <details-header :images-detail="imagesDetail" /> <persisted-search - class="gl-mb-5" :sortable-fields="$options.searchConfig.nameSortFields" :default-order="$options.searchConfig.nameSortFields[0].orderBy" default-sort="asc" diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue index b49c448c478..a821a2483cd 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/app.vue @@ -167,7 +167,7 @@ export default { <gl-tabs> <gl-tab :title="__('Detail')"> - <div data-qa-selector="package_information_content"> + <div> <package-history :package-entity="packageEntity" :project-name="projectName" /> <terraform-installation /> </div> diff --git a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue index cd5f9f5a676..9d70391a8dd 100644 --- a/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue +++ b/app/assets/javascripts/packages_and_registries/infrastructure_registry/details/components/details_title.vue @@ -34,7 +34,7 @@ export default { </script> <template> - <title-area :title="packageEntity.name" data-qa-selector="package_title"> + <title-area :title="packageEntity.name"> <template #sub-header> <gl-icon name="eye" class="gl-mr-3" /> <gl-sprintf :message="$options.i18n.packageInfo"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue index a3bbd569f41..937553e25cc 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/additional_metadata.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { s__ } from '~/locale'; import Composer from '~/packages_and_registries/package_registry/components/details/metadata/composer.vue'; import Conan from '~/packages_and_registries/package_registry/components/details/metadata/conan.vue'; @@ -41,9 +41,6 @@ export default { apollo: { packageMetadata: { query: getPackageMetadataQuery, - context: { - isSingleRequest: true, - }, variables() { return { id: this.packageId, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue index c8924e6548b..7b3acaf2ab6 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_files.vue @@ -12,7 +12,7 @@ import { GlSprintf, GlKeysetPagination, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert, VARIANT_SUCCESS, VARIANT_WARNING } from '~/alert'; import { NEXT, PREV } from '~/vue_shared/components/pagination/constants'; import { numberToHumanSize } from '~/lib/utils/number_utils'; @@ -92,9 +92,6 @@ export default { apollo: { packageFiles: { query: getPackageFilesQuery, - context: { - isSingleRequest: true, - }, variables() { return this.queryVariables; }, diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue index 663c361819e..32f94b82fa3 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_history.vue @@ -1,7 +1,7 @@ <script> import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { first } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { truncateSha } from '~/lib/utils/text_utility'; import { s__, n__ } from '~/locale'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue index cdf03d64b27..db5e007b81f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_title.vue @@ -77,7 +77,6 @@ export default { v-gl-resize-observer="checkBreakpoints" :title="packageEntity.name" :avatar="packageIcon" - data-qa-selector="package_title" > <template #sub-header> <div data-testid="sub-header" class="gl-display-flex gl-flex-wrap gl-gap-3"> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue index 482249bc252..0c0001ba6d6 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/details/package_versions_list.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { n__ } from '~/locale'; import VersionRow from '~/packages_and_registries/package_registry/components/details/version_row.vue'; import PackagesListLoader from '~/packages_and_registries/shared/components/packages_list_loader.vue'; diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue index a545ad1d09c..674683aa02f 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/package_list_row.vue @@ -183,7 +183,12 @@ export default { <span data-testid="right-secondary"> <gl-sprintf :message="publishedMessage"> <template v-if="isGroupPage" #projectName> - <gl-link data-testid="root-link" :href="projectLink">{{ projectName }}</gl-link> + <gl-link + data-testid="root-link" + class="gl-text-decoration-underline" + :href="projectLink" + >{{ projectName }}</gl-link + > </template> <template #date> <timeago-tooltip :time="packageEntity.createdAt" /> diff --git a/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue index 8ecf433f3ab..2f74de9a615 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/components/list/publish_method.vue @@ -39,9 +39,12 @@ export default { <span data-testid="pipeline-ref" class="gl-mr-2">{{ pipeline.ref }}</span> <gl-icon name="commit" class="gl-mr-2" /> - <gl-link data-testid="pipeline-sha" :href="pipeline.commitPath" class="gl-mr-2">{{ - packageShaShort - }}</gl-link> + <gl-link + data-testid="pipeline-sha" + :href="pipeline.commitPath" + class="gl-mr-2 gl-text-decoration-underline" + >{{ packageShaShort }}</gl-link + > <clipboard-button :text="pipeline.sha" diff --git a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue index a187c7a70d2..294c6baad1b 100644 --- a/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue +++ b/app/assets/javascripts/packages_and_registries/package_registry/pages/list.vue @@ -171,7 +171,7 @@ export default { /> </template> </package-title> - <package-search class="gl-mb-5" @update="handleSearchUpdate" /> + <package-search @update="handleSearchUpdate" /> <delete-packages :refetch-queries="refetchQueriesData" diff --git a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue index de087a8fcc5..e15f204dc6e 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue +++ b/app/assets/javascripts/packages_and_registries/settings/group/components/packages_settings.vue @@ -3,6 +3,7 @@ import { GlTableLite, GlToggle } from '@gitlab/ui'; import { GENERIC_PACKAGE_FORMAT, MAVEN_PACKAGE_FORMAT, + NUGET_PACKAGE_FORMAT, PACKAGE_FORMATS_TABLE_HEADER, PACKAGE_SETTINGS_HEADER, PACKAGE_SETTINGS_DESCRIPTION, @@ -91,6 +92,18 @@ export default { }, testid: 'generic-settings', }, + { + id: 'nuget-duplicated-settings-regex-input', + format: NUGET_PACKAGE_FORMAT, + duplicatesAllowed: this.packageSettings.nugetDuplicatesAllowed, + duplicateExceptionRegex: this.packageSettings.nugetDuplicateExceptionRegex, + duplicateExceptionRegexError: this.errors.nugetDuplicateExceptionRegex, + modelNames: { + allowed: 'nugetDuplicatesAllowed', + exception: 'nugetDuplicateExceptionRegex', + }, + testid: 'nuget-settings', + }, ]; }, }, diff --git a/app/assets/javascripts/packages_and_registries/settings/group/constants.js b/app/assets/javascripts/packages_and_registries/settings/group/constants.js index bfb57e3ac1c..54b337a4296 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/constants.js +++ b/app/assets/javascripts/packages_and_registries/settings/group/constants.js @@ -10,6 +10,7 @@ export const MAVEN_PACKAGE_FORMAT = s__('PackageRegistry|Maven'); export const NPM_PACKAGE_FORMAT = s__('PackageRegistry|npm'); export const PYPI_PACKAGE_FORMAT = s__('PackageRegistry|PyPI'); export const GENERIC_PACKAGE_FORMAT = s__('PackageRegistry|Generic'); +export const NUGET_PACKAGE_FORMAT = s__('PackageRegistry|NuGet'); export const DUPLICATES_TOGGLE_LABEL = s__('PackageRegistry|Allow duplicates'); export const DUPLICATES_SETTING_EXCEPTION_TITLE = __('Exceptions'); diff --git a/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql index 267e40263f2..0e36f48e9a6 100644 --- a/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql +++ b/app/assets/javascripts/packages_and_registries/settings/group/graphql/fragments/package_settings_fields.fragment.graphql @@ -3,6 +3,8 @@ fragment PackageSettingsFields on PackageSettings { mavenDuplicateExceptionRegex genericDuplicatesAllowed genericDuplicateExceptionRegex + nugetDuplicatesAllowed + nugetDuplicateExceptionRegex mavenPackageRequestsForwarding lockMavenPackageRequestsForwarding mavenPackageRequestsForwardingLocked diff --git a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue index 0a94f67ea5e..18e95ee313e 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/package_path.vue @@ -47,7 +47,7 @@ export default { </script> <template> - <div data-qa-selector="package_path" class="gl-display-flex gl-align-items-center"> + <div class="gl-display-flex gl-align-items-center"> <gl-icon data-testid="base-icon" name="project" class="gl-mx-3 gl-min-w-0" /> <gl-link diff --git a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue index f67bee77eb6..ac83f5fc1ad 100644 --- a/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue +++ b/app/assets/javascripts/packages_and_registries/shared/components/registry_list.vue @@ -92,7 +92,7 @@ export default { <div> <div v-if="!hiddenDelete" - class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-align-items-center" + class="gl-display-flex gl-justify-content-space-between gl-mb-3 gl-mt-5 gl-align-items-center" > <div class="gl-display-flex gl-align-items-center"> <gl-form-checkbox diff --git a/app/assets/javascripts/pages/admin/application_settings/appearances/preview_sign_in/index.js b/app/assets/javascripts/pages/admin/application_settings/appearances/preview_sign_in/index.js new file mode 100644 index 00000000000..e3524bfbee3 --- /dev/null +++ b/app/assets/javascripts/pages/admin/application_settings/appearances/preview_sign_in/index.js @@ -0,0 +1,3 @@ +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +renderGFM(document.body); diff --git a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js index a5305777dd5..6ca9f39842a 100644 --- a/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js +++ b/app/assets/javascripts/pages/admin/application_settings/metrics_and_profiling/index.js @@ -1,3 +1,5 @@ import setup from '~/admin/application_settings/setup_metrics_and_profiling'; +import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data'; setup(); +initServiceUsageData(); diff --git a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js b/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js deleted file mode 100644 index 8a12e753847..00000000000 --- a/app/assets/javascripts/pages/admin/application_settings/service_usage_data/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initServiceUsageData from '~/admin/application_settings/setup_service_usage_data'; - -initServiceUsageData(); diff --git a/app/assets/javascripts/pages/explore/catalog/index.js b/app/assets/javascripts/pages/explore/catalog/index.js new file mode 100644 index 00000000000..fec738a93a6 --- /dev/null +++ b/app/assets/javascripts/pages/explore/catalog/index.js @@ -0,0 +1,3 @@ +import { initCatalog } from '~/ci/catalog/'; + +initCatalog(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/details/index.js b/app/assets/javascripts/pages/import/bulk_imports/details/index.js new file mode 100644 index 00000000000..5c2571af60f --- /dev/null +++ b/app/assets/javascripts/pages/import/bulk_imports/details/index.js @@ -0,0 +1,20 @@ +import Vue from 'vue'; +import BulkImportDetailsApp from '~/import/details/components/bulk_import_details_app.vue'; + +export const initBulkImportDetails = () => { + const el = document.querySelector('.js-bulk-import-details'); + + if (!el) { + return null; + } + + return new Vue({ + el, + name: 'BulkImportDetailsRoot', + render(createElement) { + return createElement(BulkImportDetailsApp); + }, + }); +}; + +initBulkImportDetails(); diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue index 459546a5562..e912bfa4f92 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue +++ b/app/assets/javascripts/pages/import/bulk_imports/history/components/bulk_imports_history_app.vue @@ -14,13 +14,14 @@ import { createAlert } from '~/alert'; import { parseIntPagination, normalizeHeaders } from '~/lib/utils/common_utils'; import { joinPaths } from '~/lib/utils/url_utility'; import { getBulkImportsHistory } from '~/rest_api'; -import ImportStatus from '~/import_entities/components/import_status.vue'; +import ImportStatus from '~/import_entities/import_groups/components/import_status.vue'; import { StatusPoller } from '~/import_entities/import_groups/services/status_poller'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { isImporting } from '../utils'; import { DEFAULT_ERROR } from '../utils/error_messages'; @@ -57,6 +58,8 @@ export default { GlTooltip, }, + mixins: [glFeatureFlagMixin()], + inject: ['realtimeChangesPath'], data() { @@ -103,6 +106,10 @@ export default { .filter((item) => isImporting(item.status)) .map((item) => item.bulk_import_id); }, + + showDetailsLink() { + return this.glFeatures.bulkImportDetailsPage; + }, }, watch: { @@ -225,12 +232,7 @@ export default { :description="s__('BulkImport|Your imported groups and projects will appear here.')" /> <template v-else> - <gl-table-lite - :fields="$options.fields" - :items="historyItems" - data-qa-selector="import_history_table" - class="gl-w-full" - > + <gl-table-lite :fields="$options.fields" :items="historyItems" class="gl-w-full"> <template #cell(destination_name)="{ item }"> <gl-icon v-gl-tooltip @@ -252,14 +254,23 @@ export default { <time-ago :time="value" /> </template> <template #cell(status)="{ value, item, toggleDetails, detailsShowing }"> - <import-status :status="value" class="gl-display-inline-block gl-w-13" /> - <gl-button - v-if="item.failures.length" - class="gl-ml-3" - :selected="detailsShowing" - @click="toggleDetails" - >{{ __('Details') }}</gl-button + <div + class="gl-display-flex gl-flex-direction-column gl-lg-flex-direction-row gl-align-items-flex-start gl-justify-content-space-between gl-gap-3" > + <import-status + :id="item.bulk_import_id" + :entity-id="item.id" + :has-failures="item.has_failures" + :show-details-link="showDetailsLink" + :status="value" + /> + <gl-button + v-if="!showDetailsLink && item.failures.length" + :selected="detailsShowing" + @click="toggleDetails" + >{{ __('Details') }}</gl-button + > + </div> </template> <template #row-details="{ item }"> <pre><code>{{ item.failures }}</code></pre> diff --git a/app/assets/javascripts/pages/import/bulk_imports/history/index.js b/app/assets/javascripts/pages/import/bulk_imports/history/index.js index cc12723572d..ac975db3667 100644 --- a/app/assets/javascripts/pages/import/bulk_imports/history/index.js +++ b/app/assets/javascripts/pages/import/bulk_imports/history/index.js @@ -4,13 +4,14 @@ import BulkImportHistoryApp from './components/bulk_imports_history_app.vue'; function mountImportHistoryApp(mountElement) { if (!mountElement) return undefined; - const { realtimeChangesPath } = mountElement.dataset; + const { realtimeChangesPath, detailsPath } = mountElement.dataset; return new Vue({ el: mountElement, name: 'BulkImportHistoryRoot', provide: { realtimeChangesPath, + detailsPath, }, render(createElement) { return createElement(BulkImportHistoryApp); diff --git a/app/assets/javascripts/pages/import/history/components/import_history_app.vue b/app/assets/javascripts/pages/import/history/components/import_history_app.vue index 938c2be89c5..9c0f937fe0e 100644 --- a/app/assets/javascripts/pages/import/history/components/import_history_app.vue +++ b/app/assets/javascripts/pages/import/history/components/import_history_app.vue @@ -11,11 +11,8 @@ import { DEFAULT_ERROR } from '../utils/error_messages'; import ImportErrorDetails from './import_error_details.vue'; const DEFAULT_PER_PAGE = 20; -const DEFAULT_TH_CLASSES = - 'gl-bg-transparent! gl-border-b-solid! gl-border-b-gray-200! gl-border-b-1! gl-p-5!'; const tableCell = (config) => ({ - thClass: DEFAULT_TH_CLASSES, tdClass: (value, key, item) => { return { // eslint-disable-next-line no-underscore-dangle @@ -57,12 +54,12 @@ export default { tableCell({ key: 'source', label: s__('BulkImport|Source'), - thClass: `${DEFAULT_TH_CLASSES} gl-w-30p`, + thClass: 'gl-w-30p', }), tableCell({ key: 'destination', label: s__('BulkImport|Destination'), - thClass: `${DEFAULT_TH_CLASSES} gl-w-40p`, + thClass: 'gl-w-40p', }), tableCell({ key: 'created_at', @@ -144,12 +141,7 @@ export default { :description="s__('BulkImport|Your imported projects will appear here.')" /> <template v-else> - <gl-table - :fields="$options.fields" - :items="historyItems" - data-qa-selector="import_history_table" - class="gl-w-full" - > + <gl-table :fields="$options.fields" :items="historyItems" class="gl-w-full"> <template #cell(source)="{ item }"> <template v-if="item.import_url"> <gl-link diff --git a/app/assets/javascripts/pages/organizations/organizations/users/index.js b/app/assets/javascripts/pages/organizations/organizations/users/index.js new file mode 100644 index 00000000000..12d53207b22 --- /dev/null +++ b/app/assets/javascripts/pages/organizations/organizations/users/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsUsers } from '~/organizations/users'; + +initOrganizationsUsers(); diff --git a/app/assets/javascripts/pages/organizations/settings/general/index.js b/app/assets/javascripts/pages/organizations/settings/general/index.js new file mode 100644 index 00000000000..5b74af6206e --- /dev/null +++ b/app/assets/javascripts/pages/organizations/settings/general/index.js @@ -0,0 +1,3 @@ +import { initOrganizationsSettingsGeneral } from '~/organizations/settings/general'; + +initOrganizationsSettingsGeneral(); diff --git a/app/assets/javascripts/pages/passwords/new/index.js b/app/assets/javascripts/pages/passwords/new/index.js new file mode 100644 index 00000000000..e3524bfbee3 --- /dev/null +++ b/app/assets/javascripts/pages/passwords/new/index.js @@ -0,0 +1,3 @@ +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +renderGFM(document.body); diff --git a/app/assets/javascripts/pages/profiles/preferences/show/index.js b/app/assets/javascripts/pages/profiles/preferences/show/index.js index 76939434680..3668811bec7 100644 --- a/app/assets/javascripts/pages/profiles/preferences/show/index.js +++ b/app/assets/javascripts/pages/profiles/preferences/show/index.js @@ -1,5 +1,7 @@ import initProfilePreferences from '~/profile/preferences/profile_preferences_bundle'; import initProfilePreferencesDiffsColors from '~/profile/preferences/profile_preferences_diffs_colors'; +import { initHomeOrganizationSetting } from '~/organizations/profile/preferences'; initProfilePreferences(); initProfilePreferencesDiffsColors(); +initHomeOrganizationSetting(); diff --git a/app/assets/javascripts/pages/projects/blob/show/index.js b/app/assets/javascripts/pages/projects/blob/show/index.js index 07662e4411e..d42fb10063e 100644 --- a/app/assets/javascripts/pages/projects/blob/show/index.js +++ b/app/assets/javascripts/pages/projects/blob/show/index.js @@ -153,9 +153,10 @@ const initForkInfo = () => { initForkInfo(); const CommitPipelineStatusEl = document.querySelector('.js-commit-pipeline-status'); -const statusLink = document.querySelector('.commit-actions .ci-status-link'); -if (statusLink) { - statusLink.remove(); +const legacyStatusBadge = document.querySelector('.js-ci-status-badge-legacy'); + +if (legacyStatusBadge) { + legacyStatusBadge.remove(); // eslint-disable-next-line no-new new Vue({ el: CommitPipelineStatusEl, diff --git a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue index 9659c927fbf..e3d50e900ca 100644 --- a/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue +++ b/app/assets/javascripts/pages/projects/forks/new/components/fork_form.vue @@ -7,6 +7,7 @@ import { GlFormGroup, GlFormTextarea, GlButton, + GlSprintf, GlFormRadio, GlFormRadioGroup, } from '@gitlab/ui'; @@ -56,6 +57,7 @@ export default { GlIcon, GlLink, GlButton, + GlSprintf, GlFormInput, GlFormTextarea, GlFormGroup, @@ -91,6 +93,9 @@ export default { projectDescription: { default: '', }, + projectDefaultBranch: { + default: '', + }, projectVisibility: { default: '', }, @@ -116,6 +121,7 @@ export default { required: false, skipValidation: true, }), + branches: initFormField({ value: '', required: true, skipValidation: true }), visibility: initFormField({ value: null }), }, }; @@ -168,6 +174,18 @@ export default { return allowedLevels; }, + branchesOptions() { + return [ + { + text: s__('ForkProject|All branches'), + value: '', + }, + { + text: s__(`ForkProject|Only the default branch %{defaultBranch}`), + value: this.projectDefaultBranch, + }, + ]; + }, visibilityLevels() { return [ { @@ -245,7 +263,7 @@ export default { this.form.showValidation = false; const { projectId } = this; - const { name, slug, description, visibility, namespace } = this.form.fields; + const { name, slug, description, branches, visibility, namespace } = this.form.fields; const postParams = { id: projectId, @@ -253,6 +271,7 @@ export default { namespace_id: namespace.value.id, path: slug.value, description: description.value, + branches: branches.value, visibility: visibility.value, }; @@ -263,6 +282,7 @@ export default { const { data } = await axios.post(url, postParams); redirectTo(data.web_url); // eslint-disable-line import/no-deprecated } catch (error) { + this.isSaving = false; createAlert({ message: s__( 'ForkProject|An error occurred while forking the project. Please try again.', @@ -348,6 +368,34 @@ export default { /> </gl-form-group> + <gl-form-group> + <label> + {{ s__('ForkProject|Branches to include') }} + </label> + <gl-form-radio-group + v-model="form.fields.branches.value" + data-testid="fork-branches-radio-group" + name="branches" + :aria-label="__('branches')" + required + > + <gl-form-radio + v-for="{ text, value } in branchesOptions" + :key="value" + :value="value" + :data-testid="`radio-${value}`" + > + <div> + <gl-sprintf :message="text"> + <template #defaultBranch> + <code class="gl-ml-2">{{ projectDefaultBranch }}</code> + </template> + </gl-sprintf> + </div> + </gl-form-radio> + </gl-form-radio-group> + </gl-form-group> + <gl-form-group v-validation:[form.showValidation] :invalid-feedback="s__('ForkProject|Please select a visibility level')" diff --git a/app/assets/javascripts/pages/projects/forks/new/index.js b/app/assets/javascripts/pages/projects/forks/new/index.js index a31b8b1a1f4..694914e9154 100644 --- a/app/assets/javascripts/pages/projects/forks/new/index.js +++ b/app/assets/javascripts/pages/projects/forks/new/index.js @@ -15,6 +15,7 @@ const { projectId, projectName, projectPath, + projectDefaultBranch, projectDescription, projectVisibility, restrictedVisibilityLevels, @@ -38,6 +39,7 @@ new Vue({ projectName, projectPath, projectDescription, + projectDefaultBranch, projectVisibility, restrictedVisibilityLevels: JSON.parse(restrictedVisibilityLevels), }, diff --git a/app/assets/javascripts/pages/projects/merge_requests/page.js b/app/assets/javascripts/pages/projects/merge_requests/page.js index fb243d01dc6..a9d281fc899 100644 --- a/app/assets/javascripts/pages/projects/merge_requests/page.js +++ b/app/assets/javascripts/pages/projects/merge_requests/page.js @@ -28,7 +28,7 @@ requestIdleCallback(() => { if (el) { const { data } = el.dataset; - const { iid, projectPath, title, tabs, isFluidLayout } = JSON.parse(data); + const { iid, projectPath, title, tabs, isFluidLayout, sourceProjectPath } = JSON.parse(data); // eslint-disable-next-line no-new new Vue({ @@ -42,6 +42,7 @@ requestIdleCallback(() => { title, tabs, isFluidLayout: parseBoolean(isFluidLayout), + sourceProjectPath, }, render(h) { return h(StickyHeader); diff --git a/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js b/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js new file mode 100644 index 00000000000..1a2b85d7e16 --- /dev/null +++ b/app/assets/javascripts/pages/projects/ml/model_versions/show/index.js @@ -0,0 +1,4 @@ +import { initSimpleApp } from '~/helpers/init_simple_app_helper'; +import { ShowMlModelVersion } from '~/ml/model_registry/apps'; + +initSimpleApp('#js-mount-show-ml-model-version', ShowMlModelVersion); diff --git a/app/assets/javascripts/pages/projects/ml/models/index/index.js b/app/assets/javascripts/pages/projects/ml/models/index/index.js index 62d326f43a5..3f8ef4910a7 100644 --- a/app/assets/javascripts/pages/projects/ml/models/index/index.js +++ b/app/assets/javascripts/pages/projects/ml/models/index/index.js @@ -1,4 +1,4 @@ import { initSimpleApp } from '~/helpers/init_simple_app_helper'; -import MlModelsIndex from '~/ml/model_registry/routes/models/index'; +import { IndexMlModels } from '~/ml/model_registry/apps'; -initSimpleApp('#js-index-ml-models', MlModelsIndex); +initSimpleApp('#js-index-ml-models', IndexMlModels); diff --git a/app/assets/javascripts/pages/projects/ml/models/show/index.js b/app/assets/javascripts/pages/projects/ml/models/show/index.js index 87ee5c851f6..c8e25e0f0e8 100644 --- a/app/assets/javascripts/pages/projects/ml/models/show/index.js +++ b/app/assets/javascripts/pages/projects/ml/models/show/index.js @@ -1,4 +1,4 @@ import { initSimpleApp } from '~/helpers/init_simple_app_helper'; import { ShowMlModel } from '~/ml/model_registry/apps'; -initSimpleApp('#js-mount-show-ml-model', ShowMlModel); +initSimpleApp('#js-mount-show-ml-model', ShowMlModel, { withApolloProvider: true }); diff --git a/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js b/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js deleted file mode 100644 index ba03fccdb03..00000000000 --- a/app/assets/javascripts/pages/projects/product_analytics/graphs/index.js +++ /dev/null @@ -1,3 +0,0 @@ -import initActivityCharts from '~/analytics/product_analytics/activity_charts_bundle'; - -initActivityCharts(); diff --git a/app/assets/javascripts/pages/registrations/new/index.js b/app/assets/javascripts/pages/registrations/new/index.js index 90a9c9e7279..54974e878c3 100644 --- a/app/assets/javascripts/pages/registrations/new/index.js +++ b/app/assets/javascripts/pages/registrations/new/index.js @@ -5,6 +5,7 @@ import EmailFormatValidator from '~/pages/sessions/new/email_format_validator'; import { initLanguageSwitcher } from '~/language_switcher'; import { initPasswordInput } from '~/authentication/password'; import Tracking from '~/tracking'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; new UsernameValidator(); // eslint-disable-line no-new new LengthValidator(); // eslint-disable-line no-new @@ -17,3 +18,4 @@ Tracking.enableFormTracking({ initLanguageSwitcher(); initPasswordInput(); +renderGFM(document.body); diff --git a/app/assets/javascripts/pages/sessions/new/index.js b/app/assets/javascripts/pages/sessions/new/index.js index 1d5d885753c..32df2911a48 100644 --- a/app/assets/javascripts/pages/sessions/new/index.js +++ b/app/assets/javascripts/pages/sessions/new/index.js @@ -4,6 +4,7 @@ import NoEmojiValidator from '~/emoji/no_emoji_validator'; import { initLanguageSwitcher } from '~/language_switcher'; import LengthValidator from '~/validators/length_validator'; import mountEmailVerificationApplication from '~/sessions/new'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import OAuthRememberMe from './oauth_remember_me'; import preserveUrlFragment from './preserve_url_fragment'; import SigninTabsMemoizer from './signin_tabs_memoizer'; @@ -24,3 +25,4 @@ preserveUrlFragment(window.location.hash); initVueAlerts(); initLanguageSwitcher(); mountEmailVerificationApplication(); +renderGFM(document.body); diff --git a/app/assets/javascripts/pages/users/index.js b/app/assets/javascripts/pages/users/index.js index af55a5dc01a..d2c31314bba 100644 --- a/app/assets/javascripts/pages/users/index.js +++ b/app/assets/javascripts/pages/users/index.js @@ -1,11 +1,15 @@ import $ from 'jquery'; import { setCookie } from '~/lib/utils/common_utils'; import UserCallout from '~/user_callout'; -import { initReportAbuse } from '~/users/profile'; -import { initProfileTabs } from '~/profile'; +import { initProfileTabs, initUserAchievements } from '~/profile'; +import { initUserActionsApp } from '~/users/profile/actions'; import UserTabs from './user_tabs'; function initUserProfile(action) { + // TODO: Remove both Vue and legacy JS tabs code/feature flag uses with the + // removal of the old navigation. + // See https://gitlab.com/groups/gitlab-org/-/epics/11875. + if (gon.features?.profileTabsVue) { initProfileTabs(); } else { @@ -24,5 +28,6 @@ function initUserProfile(action) { const page = $('body').attr('data-page'); const action = page.split(':')[1]; initUserProfile(action); +initUserAchievements(); +initUserActionsApp(); new UserCallout(); // eslint-disable-line no-new -initReportAbuse(); diff --git a/app/assets/javascripts/pages/users/show/index.js b/app/assets/javascripts/pages/users/show/index.js deleted file mode 100644 index 7d612d6cc4e..00000000000 --- a/app/assets/javascripts/pages/users/show/index.js +++ /dev/null @@ -1,5 +0,0 @@ -import { initUserAchievements } from '~/profile'; -import { initUserActionsApp } from '~/users/profile/actions'; - -initUserAchievements(); -initUserActionsApp(); diff --git a/app/assets/javascripts/pages/users/user_tabs.js b/app/assets/javascripts/pages/users/user_tabs.js index 430022f9a9b..79eb3902116 100644 --- a/app/assets/javascripts/pages/users/user_tabs.js +++ b/app/assets/javascripts/pages/users/user_tabs.js @@ -1,3 +1,6 @@ +// TODO: Remove this with the removal of the old navigation. +// See https://gitlab.com/groups/gitlab-org/-/epics/11875. + import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils'; import $ from 'jquery'; import Activities from '~/activities'; @@ -194,7 +197,7 @@ export default class UserTabs { this.loadActivityCalendar(); UserTabs.renderMostRecentBlocks('#js-overview .activities-block', { - requestParams: { limit: 10 }, + requestParams: { limit: 15 }, }); UserTabs.renderMostRecentBlocks('#js-overview .projects-block', { requestParams: { limit: 10, skip_pagination: true, skip_namespace: true, compact_mode: true }, diff --git a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue index 720c1e0d7f2..c5f8fd1904f 100644 --- a/app/assets/javascripts/performance_bar/components/performance_bar_app.vue +++ b/app/assets/javascripts/performance_bar/components/performance_bar_app.vue @@ -61,11 +61,6 @@ export default { keys: ['feature', 'request'], }, { - metric: 'rugged', - header: s__('PerformanceBar|Rugged calls'), - keys: ['feature', 'args'], - }, - { metric: 'redis', header: s__('PerformanceBar|Redis calls'), keys: ['cmd', 'instance'], diff --git a/app/assets/javascripts/persistent_user_callouts.js b/app/assets/javascripts/persistent_user_callouts.js index cea01852630..bba8e1f7ba5 100644 --- a/app/assets/javascripts/persistent_user_callouts.js +++ b/app/assets/javascripts/persistent_user_callouts.js @@ -23,7 +23,7 @@ const PERSISTENT_USER_CALLOUTS = [ '.js-geo-migrate-hashed-storage-callout', '.js-unlimited-members-during-trial-alert', '.js-branch-rules-info-callout', - '.js-new-navigation-callout', + '.js-new-nav-for-everyone-callout', '.js-namespace-over-storage-users-combined-alert', ]; diff --git a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue index ab837d04d9a..43e70046cfb 100644 --- a/app/assets/javascripts/pipeline_wizard/components/wrapper.vue +++ b/app/assets/javascripts/pipeline_wizard/components/wrapper.vue @@ -14,8 +14,7 @@ import CommitStep from './commit.vue'; export const i18n = { stepNofN: __('Step %{currentStep} of %{stepCount}'), draft: __('Draft: %{filename}'), - overlayMessage: __(`Start inputting changes and we will generate a - YAML-file for you to add to your repository`), + overlayMessage: __(`Enter values to populate the .gitlab-ci.yml configuration file.`), }; const trackingMixin = Tracking.mixin(); diff --git a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue index 5a93de3b1be..3676ba96254 100644 --- a/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue +++ b/app/assets/javascripts/pipeline_wizard/pipeline_wizard.vue @@ -1,5 +1,6 @@ <script> import { parseDocument } from 'yaml'; +import { DEFAULT_CI_CONFIG_PATH } from '~/lib/utils/constants'; import WizardWrapper from './components/wrapper.vue'; export default { @@ -23,7 +24,7 @@ export default { defaultFilename: { type: String, required: false, - default: '.gitlab-ci.yml', + default: DEFAULT_CI_CONFIG_PATH, }, }, computed: { diff --git a/app/assets/javascripts/pipeline_wizard/templates/pages.yml b/app/assets/javascripts/pipeline_wizard/templates/pages.yml index 9d7936f2f5a..8eecd51fe27 100644 --- a/app/assets/javascripts/pipeline_wizard/templates/pages.yml +++ b/app/assets/javascripts/pipeline_wizard/templates/pages.yml @@ -1,12 +1,10 @@ id: gitlab/pages -title: Get started with Pages -description: "GitLab Pages lets you deploy static websites in minutes. All you - need is a .gitlab-ci.yml file. Follow the below steps to - create one for your app now." +title: Get started with GitLab Pages +description: "Use GitLab Pages to deploy your static website. Follow these steps to create the configuration file, .gitlab-ci.yml, and start a pipeline to deploy the site." steps: - inputs: - label: Select your build image - description: A Docker image that we can use to build your image + description: A Docker image, used to create an instance where your job runs. placeholder: node:lts widget: text target: $BUILD_IMAGE @@ -14,18 +12,15 @@ steps: pattern: "(?:[a-z]+/)?([a-z]+)(?::[0-9]+)?" invalid-feedback: Please enter a valid docker image - widget: checklist - title: "Before we begin, please check:" items: - - text: The app's built output files are in a folder named "public" - help: GitLab Pages will only publish files in that folder. - You may need to adjust your build engine's config. + - text: The application files are in the `public` folder + help: GitLab Pages publishes files in the public folder only. If needed, change your jobs to send output to this folder. template: # The Docker image that will be used to build your app image: $BUILD_IMAGE - inputs: - label: Installation Steps - description: "Enter the steps that need to run to set up a local build - environment, for example installing dependencies." + description: "Enter steps to set up a local build environment, like installing dependencies." placeholder: npm ci widget: list target: $INSTALLATION_STEPS @@ -34,8 +29,7 @@ steps: before_script: $INSTALLATION_STEPS - inputs: - label: Build Steps - description: "Enter the steps necessary to build a production version of - your application." + description: "Enter steps to build a production version of your application." widget: list target: $BUILD_STEPS template: diff --git a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue index 6b39f137880..815b8742500 100644 --- a/app/assets/javascripts/profile/edit/components/profile_edit_app.vue +++ b/app/assets/javascripts/profile/edit/components/profile_edit_app.vue @@ -109,8 +109,11 @@ export default { async syncHeaderAvatars() { const dataURL = await readFileAsDataURL(this.avatarBlob); - // TODO: implement sync for super sidebar - ['.header-user-avatar', '.js-sidebar-user-avatar'].forEach((selector) => { + const elements = gon?.use_new_navigation + ? ['[data-testid="user-dropdown"] .gl-avatar'] + : ['.header-user-avatar', '.js-sidebar-user-avatar']; + + elements.forEach((selector) => { const node = document.querySelector(selector); if (!node) return; diff --git a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue index aa30192b74b..2fc1f99c183 100644 --- a/app/assets/javascripts/profile/preferences/components/profile_preferences.vue +++ b/app/assets/javascripts/profile/preferences/components/profile_preferences.vue @@ -5,9 +5,9 @@ import { INTEGRATION_VIEW_CONFIGS, i18n } from '../constants'; import IntegrationView from './integration_view.vue'; function updateClasses(bodyClasses = '', applicationTheme, layout) { - // Remove body class for any previous theme, re-add current one - document.body.classList.remove(...bodyClasses.split(' ')); - document.body.classList.add(applicationTheme); + // Remove documentElement class for any previous theme, re-add current one + document.documentElement.classList.remove(...bodyClasses.split(' ')); + document.documentElement.classList.add(applicationTheme); // Toggle container-fluid class if (layout === 'fluid') { diff --git a/app/assets/javascripts/profile/profile.js b/app/assets/javascripts/profile/profile.js index 947bf7acd5c..2ccb360c7c1 100644 --- a/app/assets/javascripts/profile/profile.js +++ b/app/assets/javascripts/profile/profile.js @@ -89,8 +89,12 @@ export default class Profile { } updateHeaderAvatar() { - $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); - $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL); + if (gon?.use_new_navigation) { + $('[data-testid="user-dropdown"] .gl-avatar').attr('src', this.avatarGlCrop.dataURL); + } else { + $('.header-user-avatar').attr('src', this.avatarGlCrop.dataURL); + $('.js-sidebar-user-avatar').attr('src', this.avatarGlCrop.dataURL); + } } setRepoRadio() { diff --git a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue index 7c00ce45b3a..377310b087e 100644 --- a/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue +++ b/app/assets/javascripts/projects/commit/components/commit_options_dropdown.vue @@ -51,7 +51,6 @@ export default { text: s__('ChangeTypeAction|Cherry-pick'), extraAttrs: { 'data-testid': 'cherry-pick-link', - 'data-qa-selector': 'cherry_pick_button', }, action: () => this.showModal(OPEN_CHERRY_PICK_MODAL), }; @@ -62,7 +61,6 @@ export default { text: s__('ChangeTypeAction|Revert'), extraAttrs: { 'data-testid': 'revert-link', - 'data-qa-selector': 'revert_button', }, action: () => this.showModal(OPEN_REVERT_MODAL), }; @@ -85,7 +83,6 @@ export default { download: '', rel: 'nofollow', 'data-testid': 'plain-diff-link', - 'data-qa-selector': 'plain_diff', }, }; }, @@ -97,7 +94,6 @@ export default { download: '', rel: 'nofollow', 'data-testid': 'email-patches-link', - 'data-qa-selector': 'email_patches', }, }; }, @@ -148,8 +144,7 @@ export default { :toggle-text="__('Options')" right data-testid="commit-options-dropdown" - data-qa-selector="options_button" - class="gl-xs-w-full gl-line-height-20" + class="gl-line-height-20" > <gl-disclosure-dropdown-group :group="optionsGroup" @action="closeDropdown" /> diff --git a/app/assets/javascripts/projects/commit/components/form_modal.vue b/app/assets/javascripts/projects/commit/components/form_modal.vue index 44b8ccb57ca..d1e78084b9f 100644 --- a/app/assets/javascripts/projects/commit/components/form_modal.vue +++ b/app/assets/javascripts/projects/commit/components/form_modal.vue @@ -57,7 +57,6 @@ export default { variant: 'confirm', category: 'primary', 'data-testid': 'submit-commit', - 'data-qa-selector': 'submit_commit_button', }, }, actionCancel: { @@ -74,7 +73,6 @@ export default { 'branchCollaboration', 'modalTitle', 'existingBranch', - 'prependedText', 'targetProjectId', 'targetProjectName', 'branchesEndpoint', diff --git a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue index 0feaf8db82b..a4851b4fe4b 100644 --- a/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue +++ b/app/assets/javascripts/projects/commit_box/info/components/commit_box_pipeline_status.vue @@ -1,6 +1,6 @@ <script> import { GlLoadingIcon } from '@gitlab/ui'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { createAlert } from '~/alert'; import { getQueryHeaders, toggleQueryPollingByVisibility } from '~/ci/pipeline_details/graph/utils'; import getLatestPipelineStatusQuery from '../graphql/queries/get_latest_pipeline_status.query.graphql'; @@ -9,7 +9,7 @@ import { COMMIT_BOX_POLL_INTERVAL, PIPELINE_STATUS_FETCH_ERROR } from '../consta export default { PIPELINE_STATUS_FETCH_ERROR, components: { - CiBadgeLink, + CiIcon, GlLoadingIcon, }, inject: { @@ -63,12 +63,6 @@ export default { <template> <div class="gl-display-inline-block gl-vertical-align-middle gl-mr-2"> <gl-loading-icon v-if="loading" /> - <ci-badge-link - v-else - :status="pipelineStatus" - :details-path="pipelineStatus.detailsPath" - size="md" - :show-text="false" - /> + <ci-icon v-else :status="pipelineStatus" /> </div> </template> diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js index c206e648561..24b7130e765 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_mini_graph.js @@ -5,7 +5,7 @@ import createDefaultClient from '~/lib/graphql'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { useGet: true }), + defaultClient: createDefaultClient(), }); export const initCommitPipelineMiniGraph = async (selector = '.js-commit-pipeline-mini-graph') => { diff --git a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js index d5e62531283..079f74dc8a2 100644 --- a/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js +++ b/app/assets/javascripts/projects/commit_box/info/init_commit_pipeline_status.js @@ -6,7 +6,7 @@ import CommitBoxPipelineStatus from './components/commit_box_pipeline_status.vue Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient({}, { useGet: true }), + defaultClient: createDefaultClient(), }); export default (selector = '.js-commit-pipeline-status') => { diff --git a/app/assets/javascripts/projects/commits/store/actions.js b/app/assets/javascripts/projects/commits/store/actions.js index 5175f7f9151..7d04e9a15a3 100644 --- a/app/assets/javascripts/projects/commits/store/actions.js +++ b/app/assets/javascripts/projects/commits/store/actions.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; diff --git a/app/assets/javascripts/projects/default_project_templates.js b/app/assets/javascripts/projects/default_project_templates.js index 5bbc881952f..e3599c87616 100644 --- a/app/assets/javascripts/projects/default_project_templates.js +++ b/app/assets/javascripts/projects/default_project_templates.js @@ -121,4 +121,8 @@ export default { text: s__('ProjectTemplates|Laravel Framework'), icon: '.template-option .icon-laravel', }, + astro_tailwind: { + text: s__('ProjectTemplates|Astro Tailwind'), + icon: '.template-option .icon-gitlab_logo', + }, }; diff --git a/app/assets/javascripts/projects/new/components/new_project_url_select.vue b/app/assets/javascripts/projects/new/components/new_project_url_select.vue index ef2a2aa5526..84a2ddfce07 100644 --- a/app/assets/javascripts/projects/new/components/new_project_url_select.vue +++ b/app/assets/javascripts/projects/new/components/new_project_url_select.vue @@ -67,7 +67,7 @@ export default { } : this.$options.emptyNameSpace, shouldSkipQuery: true, - userNamespaceId: this.userNamespaceId, + userNamespaceUniqueId: this.userNamespaceId, }; }, computed: { @@ -186,7 +186,7 @@ export default { {{ group.fullPath }} </gl-dropdown-item> </template> - <template v-if="hasNamespaceMatches && userNamespaceId"> + <template v-if="hasNamespaceMatches && userNamespaceUniqueId"> <gl-dropdown-section-header>{{ __('Users') }}</gl-dropdown-section-header> <gl-dropdown-item @click="handleDropdownItemClick(userNamespace)"> {{ userNamespace.fullPath }} @@ -202,7 +202,7 @@ export default { :id="inputId" type="hidden" :name="inputName" - :value="selectedNamespace.id || userNamespaceId" + :value="selectedNamespace.id || userNamespaceUniqueId" /> </gl-button-group> </template> diff --git a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue index 5383a6cdddf..f921b2dfdd6 100644 --- a/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue +++ b/app/assets/javascripts/projects/pipelines/charts/components/statistics_list.vue @@ -1,7 +1,7 @@ <script> import { GlLink } from '@gitlab/ui'; import { SUPPORTED_FORMATS, getFormatter } from '~/lib/utils/unit_format'; -import { s__, n__ } from '~/locale'; +import { s__, n__, formatNumber } from '~/locale'; const defaultPrecision = 2; @@ -22,25 +22,25 @@ export default { }, computed: { statistics() { - const formatter = getFormatter(SUPPORTED_FORMATS.percentHundred); + const formatPercent = getFormatter(SUPPORTED_FORMATS.percentHundred); return [ { title: s__('PipelineCharts|Total:'), - value: n__('1 pipeline', '%d pipelines', this.counts.total), + value: n__('1 pipeline', '%d pipelines', formatNumber(this.counts.total)), }, { title: s__('PipelineCharts|Successful:'), - value: n__('1 pipeline', '%d pipelines', this.counts.success), + value: n__('1 pipeline', '%d pipelines', formatNumber(this.counts.success)), }, { title: s__('PipelineCharts|Failed:'), - value: n__('1 pipeline', '%d pipelines', this.counts.failed), + value: n__('1 pipeline', '%d pipelines', formatNumber(this.counts.failed)), link: this.failedPipelinesLink, }, { title: s__('PipelineCharts|Success ratio:'), - value: formatter(this.counts.successRatio, defaultPrecision), + value: formatPercent(this.counts.successRatio, defaultPrecision), }, ]; }, diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue index dbcb77b67f3..becd373c5f1 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/index.vue @@ -187,7 +187,7 @@ export default { :roles="pushAccessLevels.roles" :users="pushAccessLevels.users" :groups="pushAccessLevels.groups" - data-qa-selector="allowed_to_push_content" + data-testid="allowed-to-push-content" /> <!-- Allowed to merge --> @@ -198,7 +198,7 @@ export default { :roles="mergeAccessLevels.roles" :users="mergeAccessLevels.users" :groups="mergeAccessLevels.groups" - data-qa-selector="allowed_to_merge_content" + data-testid="allowed-to-merge-content" /> <!-- Force push --> diff --git a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue index 3a5b3409596..366c69556f2 100644 --- a/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue +++ b/app/assets/javascripts/projects/settings/branch_rules/components/view/protection_row.vue @@ -105,7 +105,6 @@ export default { v-for="(item, index) in accessLevels" :key="index" data-testid="access-level" - data-qa-selector="access_level_content" :data-qa-role="item.accessLevelDescription" > <span v-if="commaSeparateList && index > 0" data-testid="comma-separator">,</span> diff --git a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue index fee2f591216..f5fb72e84bc 100644 --- a/app/assets/javascripts/projects/settings/components/default_branch_selector.vue +++ b/app/assets/javascripts/projects/settings/components/default_branch_selector.vue @@ -33,6 +33,5 @@ export default { :translations="$options.i18n" name="project[default_branch]" data-testid="default-branch-dropdown" - data-qa-selector="default_branch_dropdown" /> </template> diff --git a/app/assets/javascripts/projects/settings/init_access_dropdown.js b/app/assets/javascripts/projects/settings/init_access_dropdown.js index 67afbee3854..b02a33675ee 100644 --- a/app/assets/javascripts/projects/settings/init_access_dropdown.js +++ b/app/assets/javascripts/projects/settings/init_access_dropdown.js @@ -1,5 +1,5 @@ -import * as Sentry from '@sentry/browser'; import Vue from 'vue'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import AccessDropdown from './components/access_dropdown.vue'; export const initAccessDropdown = (el, options) => { @@ -22,6 +22,7 @@ export const initAccessDropdown = (el, options) => { data() { return { preselected }; }, + disabled, methods: { setPreselectedItems(items) { this.preselected = items; diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue index 7753b850744..7d9ad83a1c6 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/app.vue @@ -76,7 +76,7 @@ export default { v-gl-modal="$options.modalId" size="small" class="gl-ml-3" - data-qa-selector="add_branch_rule_button" + data-testid="add-branch-rule-button" >{{ $options.i18n.addBranchRule }}</gl-button > </template> diff --git a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue index f45a5b12db6..0a5fa288828 100644 --- a/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue +++ b/app/assets/javascripts/projects/settings/repository/branch_rules/components/branch_rule.vue @@ -156,7 +156,7 @@ export default { <li> <div class="gl-display-flex gl-justify-content-space-between" - data-qa-selector="branch_content" + data-testid="branch-content" :data-qa-branch-name="name" > <div> @@ -178,7 +178,7 @@ export default { class="gl-align-self-start" category="tertiary" size="small" - data-qa-selector="details_button" + data-testid="details-button" :href="detailsPath" > {{ $options.i18n.detailsButtonLabel }}</gl-button diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue index 09bc275cbd4..6f22af4bd26 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_form.vue @@ -6,10 +6,16 @@ import { GlFormInputGroup, GlFormInput, GlLink, + GlFormSelect, GlSprintf, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { isEmptyValue, hasMinimumLength, isIntegerGreaterThan, isEmail } from '~/lib/utils/forms'; +import { + isEmptyValue, + hasMinimumLength, + isIntegerGreaterThan, + isServiceDeskSettingEmail, +} from '~/lib/utils/forms'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import { I18N_FORM_INTRODUCTION_PARAGRAPH, @@ -23,6 +29,11 @@ import { I18N_FORM_SMTP_USERNAME_LABEL, I18N_FORM_SMTP_PASSWORD_LABEL, I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SMTP_AUTHENTICATION_LABEL, + I18N_FORM_SMTP_AUTHENTICATION_NONE, + I18N_FORM_SMTP_AUTHENTICATION_PLAIN, + I18N_FORM_SMTP_AUTHENTICATION_LOGIN, + I18N_FORM_SMTP_AUTHENTICATION_CRAM_MD5, I18N_FORM_SUBMIT_LABEL, I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, @@ -42,6 +53,7 @@ export default { GlFormGroup, GlFormInputGroup, GlFormInput, + GlFormSelect, GlLink, GlSprintf, }, @@ -56,6 +68,11 @@ export default { I18N_FORM_SMTP_USERNAME_LABEL, I18N_FORM_SMTP_PASSWORD_LABEL, I18N_FORM_SMTP_PASSWORD_DESCRIPTION, + I18N_FORM_SMTP_AUTHENTICATION_LABEL, + I18N_FORM_SMTP_AUTHENTICATION_NONE, + I18N_FORM_SMTP_AUTHENTICATION_PLAIN, + I18N_FORM_SMTP_AUTHENTICATION_LOGIN, + I18N_FORM_SMTP_AUTHENTICATION_CRAM_MD5, I18N_FORM_SUBMIT_LABEL, I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL, I18N_FORM_INVALID_FEEDBACK_SMTP_ADDRESS, @@ -82,6 +99,7 @@ export default { smtpPort: '587', smtpUsername: '', smtpPassword: '', + smtpAuthentication: null, validationState: { customEmail: null, smtpAddress: null, @@ -113,6 +131,7 @@ export default { smtp_port: this.smtpPort, smtp_username: this.smtpUsername, smtp_password: this.smtpPassword, + smtp_authentication: this.smtpAuthentication, }; }, onCustomEmailChange() { @@ -124,7 +143,7 @@ export default { } }, validateCustomEmail() { - this.validationState.customEmail = isEmail(this.customEmail); + this.validationState.customEmail = isServiceDeskSettingEmail(this.customEmail); }, validateSmtpAddress() { this.validationState.smtpAddress = !isEmptyValue(this.smtpAddress); @@ -145,6 +164,26 @@ export default { this.validateSmtpUsername(); this.validateSmtpPassword(); }, + getSmtpAuthenticationOptions() { + return [ + { + text: this.$options.I18N_FORM_SMTP_AUTHENTICATION_NONE, + value: null, + }, + { + text: this.$options.I18N_FORM_SMTP_AUTHENTICATION_PLAIN, + value: 'plain', + }, + { + text: this.$options.I18N_FORM_SMTP_AUTHENTICATION_LOGIN, + value: 'login', + }, + { + text: this.$options.I18N_FORM_SMTP_AUTHENTICATION_CRAM_MD5, + value: 'cram_md5', + }, + ]; + }, }, }; </script> @@ -298,6 +337,20 @@ export default { /> </gl-form-group> + <gl-form-group + :label="$options.I18N_FORM_SMTP_AUTHENTICATION_LABEL" + label-for="custom-email-form-smtp-password" + class="gl-mt-3" + > + <gl-form-select + id="custom-email-form-smtp-authentication" + v-model.trim="smtpAuthentication" + :options="getSmtpAuthenticationOptions()" + :aria-label="$options.I18N_FORM_SMTP_AUTHENTICATION_LABEL" + :disabled="isSubmitting" + /> + </gl-form-group> + <gl-button type="submit" variant="confirm" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue index 03ba99bcf71..f72aa19bdf2 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/custom_email_wrapper.vue @@ -234,7 +234,6 @@ export default { :href="$options.FEEDBACK_ISSUE_URL" target="_blank" data-testid="feedback-link" - class="gl-text-blue-600 font-size-inherit" >{{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue index 2b2722ab329..6674937be67 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_root.vue @@ -55,6 +55,9 @@ export default { projectKey: { default: '', }, + addExternalParticipantsFromCc: { + default: false, + }, templates: { default: [], }, @@ -109,13 +112,20 @@ export default { }); }, - onSaveTemplate({ selectedTemplate, fileTemplateProjectId, outgoingName, projectKey }) { + onSaveTemplate({ + selectedTemplate, + fileTemplateProjectId, + outgoingName, + projectKey, + addExternalParticipantsFromCc, + }) { this.isTemplateSaving = true; const body = { issue_template_key: selectedTemplate, outgoing_name: outgoingName, project_key: projectKey, + add_external_participants_from_cc: addExternalParticipantsFromCc, service_desk_enabled: this.isEnabled, file_template_project_id: fileTemplateProjectId, }; @@ -187,6 +197,7 @@ export default { :initial-selected-file-template-project-id="selectedFileTemplateProjectId" :initial-outgoing-name="outgoingName" :initial-project-key="projectKey" + :initial-add-external-participants-from-cc="addExternalParticipantsFromCc" :templates="templates" :is-template-saving="isTemplateSaving" @save="onSaveTemplate" diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue index 5078cbbdf59..5febb6ff0aa 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_setting.vue @@ -4,6 +4,7 @@ import { GlToggle, GlLoadingIcon, GlSprintf, + GlFormCheckbox, GlFormInputGroup, GlFormGroup, GlFormInput, @@ -11,7 +12,8 @@ import { GlAlert, } from '@gitlab/ui'; import { helpPagePath } from '~/helpers/help_page_helper'; -import { __ } from '~/locale'; +import { __, s__ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ServiceDeskTemplateDropdown from './service_desk_template_dropdown.vue'; @@ -21,12 +23,22 @@ export default { issueTrackerEnableMessage: __( 'To use Service Desk in this project, you must %{linkStart}activate the issue tracker%{linkEnd}.', ), + addExternalParticipantsFromCc: { + label: s__('ServiceDesk|Add external participants from the %{codeStart}Cc%{codeEnd} header'), + help: s__( + 'ServiceDesk|Add email addresses in the %{codeStart}Cc%{codeEnd} header of Service Desk emails to the issue.', + ), + helpNotificationExtra: s__( + 'ServiceDesk|Like the author, external participants receive Service Desk emails and can participate in the discussion.', + ), + }, }, components: { ClipboardButton, GlButton, GlToggle, GlLoadingIcon, + GlFormCheckbox, GlSprintf, GlFormInput, GlFormGroup, @@ -35,6 +47,7 @@ export default { GlAlert, ServiceDeskTemplateDropdown, }, + mixins: [glFeatureFlagsMixin()], props: { isEnabled: { type: Boolean, @@ -78,6 +91,11 @@ export default { required: false, default: '', }, + initialAddExternalParticipantsFromCc: { + type: Boolean, + required: false, + default: false, + }, templates: { type: Array, required: false, @@ -95,11 +113,15 @@ export default { selectedFileTemplateProjectId: this.initialSelectedFileTemplateProjectId, outgoingName: this.initialOutgoingName || __('GitLab Support Bot'), projectKey: this.initialProjectKey, + addExternalParticipantsFromCc: this.initialAddExternalParticipantsFromCc, searchTerm: '', projectKeyError: null, }; }, computed: { + showAddExternalParticipantsFromCC() { + return this.glFeatures.issueEmailParticipants; + }, hasProjectKeySupport() { return Boolean(this.serviceDeskEmailEnabled); }, @@ -134,6 +156,7 @@ export default { selectedTemplate: this.selectedTemplate, outgoingName: this.outgoingName, projectKey: this.projectKey, + addExternalParticipantsFromCc: this.addExternalParticipantsFromCc, fileTemplateProjectId: this.selectedFileTemplateProjectId, }); }, @@ -240,12 +263,7 @@ export default { " > <template #link="{ content }"> - <gl-link - :href="emailSuffixHelpUrl" - target="_blank" - class="gl-text-blue-600 font-size-inherit" - >{{ content }} - </gl-link> + <gl-link :href="emailSuffixHelpUrl" target="_blank">{{ content }} </gl-link> </template> </gl-sprintf> </template> @@ -259,10 +277,7 @@ export default { " > <template #link="{ content }"> - <gl-link - :href="serviceDeskEmailAddressHelpUrl" - target="_blank" - class="gl-text-blue-600 font-size-inherit" + <gl-link :href="serviceDeskEmailAddressHelpUrl" target="_blank" >{{ content }} </gl-link> </template> @@ -307,11 +322,31 @@ export default { </template> </gl-form-group> + <gl-form-checkbox + v-if="showAddExternalParticipantsFromCC" + v-model="addExternalParticipantsFromCc" + :disabled="!isIssueTrackerEnabled" + > + <gl-sprintf :message="$options.i18n.addExternalParticipantsFromCc.label"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + + <template #help> + <gl-sprintf :message="$options.i18n.addExternalParticipantsFromCc.help"> + <template #code="{ content }"> + <code>{{ content }}</code> + </template> + </gl-sprintf> + {{ $options.i18n.addExternalParticipantsFromCc.helpNotificationExtra }} + </template> + </gl-form-checkbox> + <gl-button variant="confirm" class="gl-mt-5" data-testid="save_service_desk_settings_button" - data-qa-selector="save_service_desk_settings_button" :disabled="isTemplateSaving || !isIssueTrackerEnabled" @click="onSaveTemplate" > diff --git a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue index 315f0743b53..86c4fdcc30a 100644 --- a/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue +++ b/app/assets/javascripts/projects/settings_service_desk/components/service_desk_template_dropdown.vue @@ -84,7 +84,6 @@ export default { id="service-desk-template-select" :text="selectedTemplate || $options.i18n.defaultDropdownText" :header-text="$options.i18n.defaultDropdownText" - data-qa-selector="service_desk_template_dropdown" :block="true" class="service-desk-template-select" toggle-class="gl-m-0" diff --git a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js index aafd77bd25e..8ac186e292c 100644 --- a/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js +++ b/app/assets/javascripts/projects/settings_service_desk/custom_email_constants.js @@ -37,6 +37,13 @@ export const I18N_FORM_SMTP_PORT_DESCRIPTION = s__( export const I18N_FORM_SMTP_USERNAME_LABEL = s__('ServiceDesk|SMTP username'); export const I18N_FORM_SMTP_PASSWORD_LABEL = s__('ServiceDesk|SMTP password'); export const I18N_FORM_SMTP_PASSWORD_DESCRIPTION = s__('ServiceDesk|Minimum 8 characters long.'); +export const I18N_FORM_SMTP_AUTHENTICATION_LABEL = s__('ServiceDesk|SMTP authentication method'); +export const I18N_FORM_SMTP_AUTHENTICATION_NONE = s__( + 'ServiceDesk|Let GitLab select a server-supported method (recommended)', +); +export const I18N_FORM_SMTP_AUTHENTICATION_PLAIN = s__('ServiceDesk|Plain'); +export const I18N_FORM_SMTP_AUTHENTICATION_LOGIN = s__('ServiceDesk|Login'); +export const I18N_FORM_SMTP_AUTHENTICATION_CRAM_MD5 = s__('ServiceDesk|CRAM-MD5'); export const I18N_FORM_SUBMIT_LABEL = s__('ServiceDesk|Save and test connection'); export const I18N_FORM_INVALID_FEEDBACK_CUSTOM_EMAIL = s__( diff --git a/app/assets/javascripts/projects/settings_service_desk/index.js b/app/assets/javascripts/projects/settings_service_desk/index.js index c4d4f42576f..ce223b349bf 100644 --- a/app/assets/javascripts/projects/settings_service_desk/index.js +++ b/app/assets/javascripts/projects/settings_service_desk/index.js @@ -21,6 +21,7 @@ export default () => { incomingEmail, outgoingName, projectKey, + addExternalParticipantsFromCc, selectedTemplate, selectedFileTemplateProjectId, templates, @@ -39,6 +40,7 @@ export default () => { isIssueTrackerEnabled: parseBoolean(issueTrackerEnabled), outgoingName, projectKey, + addExternalParticipantsFromCc: parseBoolean(addExternalParticipantsFromCc), selectedTemplate, selectedFileTemplateProjectId: parseInt(selectedFileTemplateProjectId, 10) || null, templates: JSON.parse(templates), diff --git a/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue index 5b620aa2300..074cddac422 100644 --- a/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue +++ b/app/assets/javascripts/projects/tree/components/commit_pipeline_status.vue @@ -91,17 +91,10 @@ export default { }; </script> <template> - <div class="ci-status-link"> + <div class="gl-ml-5"> <gl-loading-icon v-if="isLoading" size="lg" label="Loading pipeline status" /> <a v-else :href="ciStatus.details_path"> - <ci-icon - v-gl-tooltip - :title="statusTitle" - :aria-label="statusTitle" - :status="ciStatus" - :size="24" - data-container="body" - /> + <ci-icon :status="ciStatus" :title="statusTitle" :aria-label="statusTitle" /> </a> </div> </template> diff --git a/app/assets/javascripts/protected_branches/protected_branch_edit.js b/app/assets/javascripts/protected_branches/protected_branch_edit.js index 29034b3bc0e..66da3de516a 100644 --- a/app/assets/javascripts/protected_branches/protected_branch_edit.js +++ b/app/assets/javascripts/protected_branches/protected_branch_edit.js @@ -6,6 +6,10 @@ import { initToggle } from '~/toggles'; import { initAccessDropdown } from '~/projects/settings/init_access_dropdown'; import { ACCESS_LEVELS, LEVEL_TYPES } from './constants'; +const isDropdownDisabled = (dropdown) => { + return dropdown?.$options.disabled === ''; +}; + export default class ProtectedBranchEdit { constructor(options) { this.hasLicense = options.hasLicense; @@ -104,6 +108,9 @@ export default class ProtectedBranchEdit { } initSelectedItems(dropdown, accessLevel) { + if (isDropdownDisabled(dropdown)) { + return; + } this.selectedItems[accessLevel] = dropdown.preselected.map((item) => { if (item.type === LEVEL_TYPES.USER) return { id: item.id, user_id: item.user_id }; if (item.type === LEVEL_TYPES.ROLE) return { id: item.id, access_level: item.access_level }; @@ -183,7 +190,10 @@ export default class ProtectedBranchEdit { }; }); - this.selectedItems[accessLevel] = itemsToAdd; - this[`${accessLevel}_dropdown`]?.setPreselectedItems(itemsToAdd); + const dropdown = this[`${accessLevel}_dropdown`]; + if (!isDropdownDisabled(dropdown)) { + this.selectedItems[accessLevel] = itemsToAdd; + dropdown?.setPreselectedItems(itemsToAdd); + } } } diff --git a/app/assets/javascripts/protected_tags/protected_tag_create.js b/app/assets/javascripts/protected_tags/protected_tag_create.js index b5661af352c..b3754cecce4 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_create.js +++ b/app/assets/javascripts/protected_tags/protected_tag_create.js @@ -41,7 +41,7 @@ export default class ProtectedTagCreate { accessLevel: ACCESS_LEVELS.CREATE, accessLevelsData: gon.create_access_levels, searchEnabled: dropdownEl.dataset.filter !== undefined, - testId: 'allowed_to_create_dropdown', + testId: 'allowed-to-create-dropdown', }); this.protectedTagAccessDropdown.$on('select', (selected) => { diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit.vue b/app/assets/javascripts/protected_tags/protected_tag_edit.vue index 82b2ecc5f5c..7fe1dc9c01a 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit.vue +++ b/app/assets/javascripts/protected_tags/protected_tag_edit.vue @@ -101,7 +101,7 @@ export default { <template> <access-dropdown toggle-class="js-allowed-to-create gl-max-w-34" - test-id="allowed_to_create_dropdown" + test-id="allowed-to-create-dropdown" :has-license="hasLicense" :access-level="$options.ACCESS_LEVELS.CREATE" :access-levels-data="accessLevelsData" diff --git a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js index 444d6e9cf76..fad15a5d89e 100644 --- a/app/assets/javascripts/protected_tags/protected_tag_edit_list.js +++ b/app/assets/javascripts/protected_tags/protected_tag_edit_list.js @@ -1,5 +1,5 @@ import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import ProtectedTagEdit from './protected_tag_edit.vue'; export default class ProtectedTagEditList { diff --git a/app/assets/javascripts/releases/components/app_edit_new.vue b/app/assets/javascripts/releases/components/app_edit_new.vue index c68fbceb4f6..df9f333afe5 100644 --- a/app/assets/javascripts/releases/components/app_edit_new.vue +++ b/app/assets/javascripts/releases/components/app_edit_new.vue @@ -176,8 +176,7 @@ export default { </p> <form v-if="showForm" class="js-quick-submit" @submit.prevent="submitForm"> <tag-field /> - <gl-form-group> - <label for="release-title">{{ __('Release title') }}</label> + <gl-form-group :label="__('Release title')"> <gl-form-input id="release-title" ref="releaseTitleInput" @@ -186,17 +185,14 @@ export default { class="form-control" /> </gl-form-group> - <gl-form-group class="w-50" data-testid="milestones-field"> - <label>{{ __('Milestones') }}</label> - <div class="d-flex flex-column col-md-6 col-sm-10 pl-0"> - <milestone-combobox - v-model="releaseMilestones" - :project-id="projectId" - :group-id="groupId" - :group-milestones-available="groupMilestonesAvailable" - :extra-links="milestoneComboboxExtraLinks" - /> - </div> + <gl-form-group :label="__('Milestones')" class="gl-w-30" data-testid="milestones-field"> + <milestone-combobox + v-model="releaseMilestones" + :project-id="projectId" + :group-id="groupId" + :group-milestones-available="groupMilestonesAvailable" + :extra-links="milestoneComboboxExtraLinks" + /> </gl-form-group> <gl-form-group :label="__('Release date')" label-for="release-released-at"> <template #label-description> @@ -214,8 +210,7 @@ export default { </template> <gl-datepicker id="release-released-at" v-model="releasedAt" :default-date="releasedAt" /> </gl-form-group> - <gl-form-group data-testid="release-notes"> - <label for="release-notes">{{ __('Release notes') }}</label> + <gl-form-group :label="__('Release notes')" data-testid="release-notes"> <div class="common-note-form"> <markdown-field :can-attach-file="true" diff --git a/app/assets/javascripts/releases/components/release_block_header.vue b/app/assets/javascripts/releases/components/release_block_header.vue index 070865cf84b..b4c897a8236 100644 --- a/app/assets/javascripts/releases/components/release_block_header.vue +++ b/app/assets/javascripts/releases/components/release_block_header.vue @@ -50,7 +50,7 @@ export default { <template> <div class="card-header d-flex align-items-center bg-white pr-0"> <h2 class="card-title my-2 mr-auto"> - <gl-link v-if="selfLink" :href="selfLink" class="font-size-inherit"> + <gl-link v-if="selfLink" :href="selfLink"> {{ release.name }} </gl-link> <template v-else> diff --git a/app/assets/javascripts/repository/components/blob_button_group.vue b/app/assets/javascripts/repository/components/blob_button_group.vue index 99b861ca104..ed7212eb9a6 100644 --- a/app/assets/javascripts/repository/components/blob_button_group.vue +++ b/app/assets/javascripts/repository/components/blob_button_group.vue @@ -92,8 +92,8 @@ export default { deleteModalTitle() { return sprintf(__('Delete %{name}'), { name: this.name }); }, - lockBtnQASelector() { - return this.canLock ? 'lock_button' : 'disabled_lock_button'; + lockBtnTestId() { + return this.canLock ? 'lock-button' : 'disabled-lock-button'; }, }, methods: { @@ -120,8 +120,7 @@ export default { :project-path="projectPath" :is-locked="isLocked" :can-lock="canLock" - data-testid="lock" - :data-qa-selector="lockBtnQASelector" + :data-testid="lockBtnTestId" /> <gl-button data-testid="replace" @click="showModal($options.replaceBlobModalId)"> {{ $options.i18n.replace }} diff --git a/app/assets/javascripts/repository/components/blob_content_viewer.vue b/app/assets/javascripts/repository/components/blob_content_viewer.vue index 6565c84fa11..97a1cbda5d0 100644 --- a/app/assets/javascripts/repository/components/blob_content_viewer.vue +++ b/app/assets/javascripts/repository/components/blob_content_viewer.vue @@ -122,6 +122,7 @@ export default { blobInfo: {}, isEmptyRepository: false, projectId: null, + showBlame: this.$route?.query?.blame === '1', }; }, computed: { @@ -202,6 +203,9 @@ export default { isUsingLfs() { return this.blobInfo.storedExternally && this.blobInfo.externalStorage === LFS_STORAGE; }, + isBlameEnabled() { + return this.glFeatures.blobBlameInfo && this.blobInfo.language === 'json'; // This feature is currently scoped to JSON files + }, }, watch: { // Watch the URL 'plain' query value to know if the viewer needs changing. @@ -289,6 +293,14 @@ export default { onCopy() { navigator.clipboard.writeText(this.blobInfo.rawTextBlob); }, + handleToggleBlame() { + this.switchViewer(SIMPLE_BLOB_VIEWER); + this.showBlame = !this.showBlame; + + const blame = this.showBlame === true ? '1' : '0'; + if (this.$route?.query?.blame === blame) return; + this.$router.push({ path: this.$route.path, query: { ...this.$route.query, blame } }); + }, }, }; </script> @@ -299,19 +311,21 @@ export default { <div v-if="blobInfo && !isLoading" id="fileHolder" class="file-holder"> <blob-header :blob="blobInfo" - :hide-viewer-switcher="!hasRichViewer || isBinaryFileType || isUsingLfs" + :hide-viewer-switcher="isBinaryFileType || isUsingLfs" :is-binary="isBinaryFileType" :active-viewer-type="viewer.type" :has-render-error="hasRenderError" :show-path="false" :override-copy="true" :show-fork-suggestion="showForkSuggestion" + :show-blame-toggle="isBlameEnabled" :project-path="projectPath" :project-id="projectId" @viewer-changed="handleViewerChanged" @copy="onCopy" @edit="editBlob" @error="displayError" + @blame="handleToggleBlame" > <template #actions> <blob-button-group @@ -354,6 +368,7 @@ export default { v-else :blob="blobInfo" :chunks="chunks" + :show-blame="showBlame" :project-path="projectPath" :current-ref="currentRef" class="blob-viewer" diff --git a/app/assets/javascripts/repository/components/commit_info.vue b/app/assets/javascripts/repository/components/commit_info.vue index b6e3cdbb7a3..b6674114a20 100644 --- a/app/assets/javascripts/repository/components/commit_info.vue +++ b/app/assets/javascripts/repository/components/commit_info.vue @@ -1,6 +1,6 @@ <script> import { GlTooltipDirective, GlLink, GlButton } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { __, sprintf } from '~/locale'; import SafeHtml from '~/vue_shared/directives/safe_html'; import defaultAvatarUrl from 'images/no_avatar.png'; import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; @@ -26,6 +26,11 @@ export default { type: Object, required: true, }, + prevBlameLink: { + type: String, + required: false, + default: null, + }, }, data() { return { showDescription: false }; @@ -35,6 +40,9 @@ export default { // Strip the newline at the beginning return this.commit?.descriptionHtml?.replace(/^
/, ''); }, + avatarLinkAltText() { + return sprintf(__(`%{username}'s avatar`), { username: this.commit.authorName }); + }, }, methods: { toggleShowDescription() { @@ -58,6 +66,7 @@ export default { v-if="commit.author" :link-href="commit.author.webPath" :img-src="commit.author.avatarUrl" + :img-alt="avatarLinkAltText" :img-size="32" class="gl-my-2 gl-mr-4" /> @@ -67,10 +76,8 @@ export default { :img-src="commit.authorGravatar || $options.defaultAvatarUrl" :size="32" /> - <div - class="commit-detail flex-list gl-display-flex gl-justify-content-space-between gl-align-items-center gl-flex-grow-1 gl-min-w-0" - > - <div class="commit-content" data-qa-selector="commit_content"> + <div class="commit-detail flex-list gl-display-flex gl-flex-grow-1 gl-min-w-0"> + <div class="commit-content gl-w-full gl-text-truncate" data-testid="commit-content"> <gl-link v-safe-html:[$options.safeHtmlConfig]="commit.titleHtml" :href="commit.webPath" @@ -112,5 +119,6 @@ export default { <div class="gl-flex-grow-1"></div> <slot></slot> </div> + <div v-if="prevBlameLink" v-safe-html:[$options.safeHtmlConfig]="prevBlameLink"></div> </div> </template> diff --git a/app/assets/javascripts/repository/components/delete_blob_modal.vue b/app/assets/javascripts/repository/components/delete_blob_modal.vue index 97171a3282b..079d4c522a8 100644 --- a/app/assets/javascripts/repository/components/delete_blob_modal.vue +++ b/app/assets/javascripts/repository/components/delete_blob_modal.vue @@ -273,7 +273,7 @@ export default { v-model="form.fields['commit_message'].value" v-validation:[form.showValidation] name="commit_message" - data-qa-selector="commit_message_field" + data-testid="commit-message-field" :state="form.fields['commit_message'].state" :disabled="loading" required diff --git a/app/assets/javascripts/repository/components/last_commit.vue b/app/assets/javascripts/repository/components/last_commit.vue index 05d4d9e1f81..7f7a76cd4aa 100644 --- a/app/assets/javascripts/repository/components/last_commit.vue +++ b/app/assets/javascripts/repository/components/last_commit.vue @@ -3,7 +3,7 @@ import { GlTooltipDirective, GlButton, GlButtonGroup, GlLoadingIcon } from '@git import SafeHtml from '~/vue_shared/directives/safe_html'; import pathLastCommitQuery from 'shared_queries/repository/path_last_commit.query.graphql'; import { sprintf, s__ } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import SignatureBadge from '~/commit/components/signature_badge.vue'; import getRefMixin from '../mixins/get_ref'; @@ -17,7 +17,7 @@ export default { CommitInfo, ClipboardButton, SignatureBadge, - CiBadgeLink, + CiIcon, GlButtonGroup, GlButton, GlLoadingIcon, @@ -50,9 +50,6 @@ export default { pipeline: pipelines?.length && pipelines[0].node, }; }, - context: { - isSingleRequest: true, - }, error(error) { throw error; }, @@ -115,12 +112,10 @@ export default { class="commit-actions gl-display-flex gl-flex-align gl-align-items-center gl-flex-direction-row" > <signature-badge v-if="commit.signature" :signature="commit.signature" /> - <div v-if="commit.pipeline" class="ci-status-link"> - <ci-badge-link + <div v-if="commit.pipeline" class="gl-ml-5"> + <ci-icon :status="commit.pipeline.detailedStatus" - :details-path="commit.pipeline.detailedStatus.detailsPath" :aria-label="statusTitle" - :show-text="false" class="js-commit-pipeline" /> </div> diff --git a/app/assets/javascripts/repository/components/table/row.vue b/app/assets/javascripts/repository/components/table/row.vue index 526757e6147..6a81f11eb51 100644 --- a/app/assets/javascripts/repository/components/table/row.vue +++ b/app/assets/javascripts/repository/components/table/row.vue @@ -248,19 +248,19 @@ export default { class="ml-1" /> </td> - <td class="d-none d-sm-table-cell tree-commit cursor-default gl-text-secondary"> + <td class="d-none d-sm-table-cell tree-commit cursor-default"> <gl-link v-if="commitData" v-safe-html:[$options.safeHtmlConfig]="commitData.titleHtml" :href="commitData.commitPath" :title="commitData.message" - class="str-truncated-100 tree-commit-link gl-text-secondary" + class="str-truncated-100 tree-commit-link gl-text-gray-600" /> <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared"> <gl-skeleton-loader v-if="showSkeletonLoader" :lines="1" /> </gl-intersection-observer> </td> - <td class="tree-time-ago text-right cursor-default gl-text-secondary"> + <td class="tree-time-ago text-right cursor-default gl-text-gray-600"> <gl-intersection-observer @appear="rowAppeared" @disappear="rowDisappeared"> <timeago-tooltip v-if="commitData" :time="commitData.committedDate" /> </gl-intersection-observer> diff --git a/app/assets/javascripts/search/sidebar/components/app.vue b/app/assets/javascripts/search/sidebar/components/app.vue index 2ff138cabe5..86a5f5107f8 100644 --- a/app/assets/javascripts/search/sidebar/components/app.vue +++ b/app/assets/javascripts/search/sidebar/components/app.vue @@ -1,12 +1,12 @@ <script> // eslint-disable-next-line no-restricted-imports import { mapState, mapGetters } from 'vuex'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import ScopeLegacyNavigation from '~/search/sidebar/components/scope_legacy_navigation.vue'; import ScopeSidebarNavigation from '~/search/sidebar/components/scope_sidebar_navigation.vue'; import SmallScreenDrawerNavigation from '~/search/sidebar/components/small_screen_drawer_navigation.vue'; import SidebarPortal from '~/super_sidebar/components/sidebar_portal.vue'; import { toggleSuperSidebarCollapsed } from '~/super_sidebar/super_sidebar_collapsed_state_manager'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import DomElementListener from '~/vue_shared/components/dom_element_listener.vue'; import { SCOPE_ISSUES, @@ -16,6 +16,7 @@ import { SCOPE_NOTES, SCOPE_COMMITS, SCOPE_MILESTONES, + SCOPE_WIKI_BLOBS, SEARCH_TYPE_ADVANCED, } from '../constants'; import IssuesFilters from './issues_filters.vue'; @@ -25,6 +26,7 @@ import ProjectsFilters from './projects_filters.vue'; import NotesFilters from './notes_filters.vue'; import CommitsFilters from './commits_filters.vue'; import MilestonesFilters from './milestones_filters.vue'; +import WikiBlobsFilters from './wiki_blobs_filters.vue'; export default { name: 'GlobalSearchSidebar', @@ -34,6 +36,7 @@ export default { BlobsFilters, ProjectsFilters, NotesFilters, + WikiBlobsFilters, ScopeLegacyNavigation, ScopeSidebarNavigation, SidebarPortal, @@ -60,20 +63,18 @@ export default { return this.currentScope === SCOPE_PROJECTS; }, showNotesFilters() { - // for now, the feature flag is placed here. Since we have only one filter in notes scope - return this.currentScope === SCOPE_NOTES && this.glFeatures.searchNotesHideArchivedProjects; + return this.currentScope === SCOPE_NOTES; }, showCommitsFilters() { - // for now, the feature flag is placed here. Since we have only one filter in commits scope - return ( - this.currentScope === SCOPE_COMMITS && this.glFeatures.searchCommitsHideArchivedProjects - ); + return this.currentScope === SCOPE_COMMITS; }, showMilestonesFilters() { - // for now, the feature flag is placed here. Since we have only one filter in milestones scope + return this.currentScope === SCOPE_MILESTONES; + }, + showWikiBlobsFilters() { return ( - this.currentScope === SCOPE_MILESTONES && - this.glFeatures.searchMilestonesHideArchivedProjects + this.currentScope === SCOPE_WIKI_BLOBS && + this.glFeatures?.searchProjectWikisHideArchivedProjects ); }, showScopeNavigation() { @@ -103,6 +104,7 @@ export default { <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> <milestones-filters v-if="showMilestonesFilters" /> + <wiki-blobs-filters v-if="showWikiBlobsFilters" /> </sidebar-portal> </section> @@ -119,6 +121,7 @@ export default { <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> <milestones-filters v-if="showMilestonesFilters" /> + <wiki-blobs-filters v-if="showWikiBlobsFilters" /> </div> <small-screen-drawer-navigation class="gl-lg-display-none"> <scope-legacy-navigation /> @@ -129,6 +132,7 @@ export default { <notes-filters v-if="showNotesFilters" /> <commits-filters v-if="showCommitsFilters" /> <milestones-filters v-if="showMilestonesFilters" /> + <wiki-blobs-filters v-if="showWikiBlobsFilters" /> </small-screen-drawer-navigation> </section> </template> diff --git a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js index ed90e2aaded..96a6f119da2 100644 --- a/app/assets/javascripts/search/sidebar/components/archived_filter/data.js +++ b/app/assets/javascripts/search/sidebar/components/archived_filter/data.js @@ -5,7 +5,16 @@ const checkboxLabel = s__('GlobalSearch|Include archived'); export const TRACKING_NAMESPACE = 'search:archived:select'; export const TRACKING_LABEL_CHECKBOX = 'checkbox'; -const scopes = ['projects', 'issues', 'merge_requests', 'notes', 'blobs', 'commits', 'milestones']; +const scopes = [ + 'projects', + 'issues', + 'merge_requests', + 'notes', + 'blobs', + 'commits', + 'milestones', + 'wiki_blobs', +]; const filterParam = 'include_archived'; diff --git a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue index ac36ae6b366..0ed2c24efba 100644 --- a/app/assets/javascripts/search/sidebar/components/blobs_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/blobs_filters.vue @@ -18,11 +18,8 @@ export default { computed: { ...mapGetters(['currentScope']), ...mapState(['useSidebarNavigation', 'searchType']), - showArchivedFilter() { - return this.glFeatures.searchBlobsHideArchivedProjects; - }, showDivider() { - return !this.useSidebarNavigation && this.showArchivedFilter; + return !this.useSidebarNavigation; }, hrClasses() { return [...HR_DEFAULT_CLASSES, 'gl-display-none', 'gl-md-display-block']; @@ -35,6 +32,6 @@ export default { <filters-template> <language-filter class="gl-mb-5" /> <hr v-if="showDivider" :class="hrClasses" /> - <archived-filter v-if="showArchivedFilter" class="gl-mb-5" /> + <archived-filter class="gl-mb-5" /> </filters-template> </template> diff --git a/app/assets/javascripts/search/sidebar/components/issues_filters.vue b/app/assets/javascripts/search/sidebar/components/issues_filters.vue index 4a2d3df6921..a77fb34cdba 100644 --- a/app/assets/javascripts/search/sidebar/components/issues_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/issues_filters.vue @@ -41,10 +41,7 @@ export default { ); }, showArchivedFilter() { - return ( - archivedFilterData.scopes.includes(this.currentScope) && - this.glFeatures.searchIssuesHideArchivedProjects - ); + return archivedFilterData.scopes.includes(this.currentScope); }, showDivider() { return !this.useSidebarNavigation; diff --git a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue index ebd0406bcec..97583730958 100644 --- a/app/assets/javascripts/search/sidebar/components/label_filter/index.vue +++ b/app/assets/javascripts/search/sidebar/components/label_filter/index.vue @@ -55,12 +55,15 @@ export default { }, i18n: I18N, computed: { - ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'aggregations']), + ...mapState(['useSidebarNavigation', 'searchLabelString', 'query', 'urlQuery', 'aggregations']), ...mapGetters([ 'filteredLabels', 'filteredUnselectedLabels', 'filteredAppliedSelectedLabels', 'appliedSelectedLabels', + 'unselectedLabels', + 'unappliedNewLabels', + 'labelAggregationBuckets', ]), searchInputDescribeBy() { if (this.isLoggedIn) { @@ -100,10 +103,10 @@ export default { return FIRST_DROPDOWN_INDEX; }, hasSelectedLabels() { - return this.filteredAppliedSelectedLabels.length > 0; + return this.filteredAppliedSelectedLabels?.length > 0; }, hasUnselectedLabels() { - return this.filteredUnselectedLabels.length > 0; + return this.filteredUnselectedLabels?.length > 0; }, labelSearchBox() { return this.$refs.searchLabelInputBox?.$el.querySelector('[role=searchbox]'); @@ -122,25 +125,30 @@ export default { this.setLabelFilterSearch({ value }); }, }, - selectedFilters: { + selectedLabels: { get() { return this.combinedSelectedFilters; }, set(value) { this.setQuery({ key: this.$options.labelFilterData?.filterParam, value }); - trackSelectCheckbox(value); }, }, }, async created() { - await this.fetchAllAggregation(); + if (this.urlQuery?.[labelFilterData.filterParam]?.length > 0) { + await this.fetchAllAggregation(); + } }, methods: { ...mapActions(['fetchAllAggregation', 'setQuery', 'closeLabel', 'setLabelFilterSearch']), - openDropdown() { + async openDropdown() { this.isFocused = true; + if (!this.aggregations.error && this.filteredLabels?.length === 0) { + await this.fetchAllAggregation(); + } + trackOpenDropdown(); }, closeDropdown(event) { @@ -158,16 +166,8 @@ export default { const { key } = event.target.closest('.gl-label').dataset; this.closeLabel({ key }); }, - reactiveLabelColor(label) { - const { color, key } = label; - - return this.query?.labels?.some((labelKey) => labelKey === key) - ? color - : `rgba(${rgbFromHex(color)}, 0.3)`; - }, - isLabelClosable(label) { - const { key } = label; - return this.query?.labels?.some((labelKey) => labelKey === key); + inactiveLabelColor(label) { + return `rgba(${rgbFromHex(label.color)}, 0.3)`; }, }, FIRST_DROPDOWN_INDEX, @@ -188,13 +188,34 @@ export default { </h5> <div class="gl-my-5"> <gl-label + v-for="label in unappliedNewLabels" + :key="label.key" + class="gl-mr-2 gl-mb-2 gl-bg-gray-10" + :data-key="label.key" + :background-color="inactiveLabelColor(label)" + :title="label.title" + :show-close-button="false" + data-testid="unapplied-label" + /> + <gl-label + v-for="label in unselectedLabels" + :key="label.key" + class="gl-mr-2 gl-mb-2 gl-bg-gray-10" + :data-key="label.key" + :background-color="inactiveLabelColor(label)" + :title="label.title" + :show-close-button="false" + data-testid="unselected-label" + /> + <gl-label v-for="label in appliedSelectedLabels" :key="label.key" class="gl-mr-2 gl-mb-2 gl-bg-gray-10" :data-key="label.key" - :background-color="reactiveLabelColor(label)" + :background-color="label.color" :title="label.title" - :show-close-button="isLabelClosable(label)" + :show-close-button="true" + data-testid="label" @close="onLabelClose" /> </div> @@ -245,7 +266,7 @@ export default { $options.i18n.DROPDOWN_HEADER }}</gl-dropdown-section-header> <gl-dropdown-form> - <gl-form-checkbox-group v-model="selectedFilters"> + <gl-form-checkbox-group v-model="selectedLabels"> <label-dropdown-items v-if="hasSelectedLabels" :labels="filteredAppliedSelectedLabels" diff --git a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue index 6e476ef7935..f86906ebd26 100644 --- a/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue +++ b/app/assets/javascripts/search/sidebar/components/merge_requests_filters.vue @@ -1,7 +1,6 @@ <script> // eslint-disable-next-line no-restricted-imports import { mapGetters, mapState } from 'vuex'; -import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { HR_DEFAULT_CLASSES } from '../constants'; import { statusFilterData } from './status_filter/data'; import StatusFilter from './status_filter/index.vue'; @@ -16,15 +15,11 @@ export default { FiltersTemplate, ArchivedFilter, }, - mixins: [glFeatureFlagsMixin()], computed: { ...mapGetters(['currentScope']), ...mapState(['useSidebarNavigation', 'searchType']), showArchivedFilter() { - return ( - archivedFilterData.scopes.includes(this.currentScope) && - this.glFeatures.searchMergeRequestsHideArchivedProjects - ); + return archivedFilterData.scopes.includes(this.currentScope); }, showStatusFilter() { return Object.values(statusFilterData.scopes).includes(this.currentScope); diff --git a/app/assets/javascripts/search/sidebar/components/wiki_blobs_filters.vue b/app/assets/javascripts/search/sidebar/components/wiki_blobs_filters.vue new file mode 100644 index 00000000000..b1f386d9f4f --- /dev/null +++ b/app/assets/javascripts/search/sidebar/components/wiki_blobs_filters.vue @@ -0,0 +1,18 @@ +<script> +import ArchivedFilter from './archived_filter/index.vue'; +import FiltersTemplate from './filters_template.vue'; + +export default { + name: 'WikiBlobsFilters', + components: { + ArchivedFilter, + FiltersTemplate, + }, +}; +</script> + +<template> + <filters-template> + <archived-filter class="gl-mb-5" /> + </filters-template> +</template> diff --git a/app/assets/javascripts/search/sidebar/constants/index.js b/app/assets/javascripts/search/sidebar/constants/index.js index b5446ecbb42..1559155a941 100644 --- a/app/assets/javascripts/search/sidebar/constants/index.js +++ b/app/assets/javascripts/search/sidebar/constants/index.js @@ -5,6 +5,8 @@ export const SCOPE_PROJECTS = 'projects'; export const SCOPE_NOTES = 'notes'; export const SCOPE_COMMITS = 'commits'; export const SCOPE_MILESTONES = 'milestones'; +export const SCOPE_WIKI_BLOBS = 'wiki_blobs'; + export const LABEL_DEFAULT_CLASSES = [ 'gl-display-flex', 'gl-flex-direction-row', diff --git a/app/assets/javascripts/search/store/getters.js b/app/assets/javascripts/search/store/getters.js index d01fd884bad..de05e9b80b2 100644 --- a/app/assets/javascripts/search/store/getters.js +++ b/app/assets/javascripts/search/store/getters.js @@ -1,10 +1,24 @@ -import { findKey } from 'lodash'; +import { findKey, intersection } from 'lodash'; import { languageFilterData } from '~/search/sidebar/components/language_filter/data'; import { labelFilterData } from '~/search/sidebar/components/label_filter/data'; import { formatSearchResultCount, addCountOverLimit } from '~/search/store/utils'; import { GROUPS_LOCAL_STORAGE_KEY, PROJECTS_LOCAL_STORAGE_KEY, ICON_MAP } from './constants'; +const queryLabelFilters = (state) => state?.query?.[labelFilterData.filterParam] || []; +const urlQueryLabelFilters = (state) => state?.urlQuery?.[labelFilterData.filterParam] || []; + +const appliedSelectedLabelsKeys = (state) => + intersection(urlQueryLabelFilters(state), queryLabelFilters(state)); + +const unselectedLabelsKeys = (state) => + urlQueryLabelFilters(state)?.filter((label) => !queryLabelFilters(state)?.includes(label)); + +const unappliedNewLabelKeys = (state) => + state?.query?.labels?.filter((label) => !urlQueryLabelFilters(state)?.includes(label)); + +export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || []; + export const frequentGroups = (state) => { return state.frequentItems[GROUPS_LOCAL_STORAGE_KEY]; }; @@ -39,25 +53,28 @@ export const filteredLabels = (state) => { }; export const filteredAppliedSelectedLabels = (state) => - filteredLabels(state)?.filter((label) => state?.urlQuery?.labels?.includes(label.key)); + filteredLabels(state)?.filter((label) => urlQueryLabelFilters(state)?.includes(label.key)); export const appliedSelectedLabels = (state) => { return labelAggregationBuckets(state)?.filter((label) => - state?.urlQuery?.labels?.includes(label.key), + appliedSelectedLabelsKeys(state)?.includes(label.key), ); }; -export const filteredUnselectedLabels = (state) => { - if (!state?.urlQuery?.labels) { - return filteredLabels(state); - } +export const filteredUnselectedLabels = (state) => + filteredLabels(state)?.filter((label) => !urlQueryLabelFilters(state)?.includes(label.key)); - return filteredLabels(state)?.filter((label) => !state?.urlQuery?.labels?.includes(label.key)); -}; +export const unselectedLabels = (state) => + labelAggregationBuckets(state).filter((label) => + unselectedLabelsKeys(state)?.includes(label.key), + ); -export const currentScope = (state) => findKey(state.navigation, { active: true }); +export const unappliedNewLabels = (state) => + labelAggregationBuckets(state).filter((label) => + unappliedNewLabelKeys(state)?.includes(label.key), + ); -export const queryLanguageFilters = (state) => state.query[languageFilterData.filterParam] || []; +export const currentScope = (state) => findKey(state.navigation, { active: true }); export const navigationItems = (state) => Object.values(state.navigation).map((item) => ({ diff --git a/app/assets/javascripts/security_configuration/components/training_provider_list.vue b/app/assets/javascripts/security_configuration/components/training_provider_list.vue index f5f88e12163..d424ec6dfeb 100644 --- a/app/assets/javascripts/security_configuration/components/training_provider_list.vue +++ b/app/assets/javascripts/security_configuration/components/training_provider_list.vue @@ -9,7 +9,7 @@ import { GlSkeletonLoader, GlIcon, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import { __, s__ } from '~/locale'; diff --git a/app/assets/javascripts/sentry/init_sentry.js b/app/assets/javascripts/sentry/init_sentry.js index 6f32c8c4165..722741b50e4 100644 --- a/app/assets/javascripts/sentry/init_sentry.js +++ b/app/assets/javascripts/sentry/init_sentry.js @@ -23,7 +23,7 @@ const initSentry = () => { const client = new BrowserClient({ // Sentry.init(...) options dsn: gon.sentry_dsn, - release: gon.version, + release: gon.revision, allowUrls: process.env.NODE_ENV === 'production' ? [gon.gitlab_url] @@ -56,7 +56,7 @@ const initSentry = () => { hub.bindClient(client); hub.setTags({ - revision: gon.revision, + version: gon.version, feature_category: gon.feature_category, page, }); @@ -75,7 +75,7 @@ const initSentry = () => { // The _Sentry object is globally exported so it can be used by // ./sentry_browser_wrapper.js - // This hack allows us to load a single version of `@sentry/browser` + // This hack allows us to load a single version of `~/sentry/sentry_browser_wrapper` // in the browser, see app/views/layouts/_head.html.haml to find how it is imported. // eslint-disable-next-line no-underscore-dangle diff --git a/app/assets/javascripts/sentry/legacy_index.js b/app/assets/javascripts/sentry/legacy_index.js index 604b982e128..688f8eb0a44 100644 --- a/app/assets/javascripts/sentry/legacy_index.js +++ b/app/assets/javascripts/sentry/legacy_index.js @@ -25,7 +25,7 @@ index(); // The _Sentry object is globally exported so it can be used by // ./sentry_browser_wrapper.js -// This hack allows us to load a single version of `@sentry/browser` +// This hack allows us to load a single version of `~/sentry/sentry_browser_wrapper` // in the browser, see app/views/layouts/_head.html.haml to find how it is imported. // eslint-disable-next-line no-underscore-dangle diff --git a/app/assets/javascripts/sentry/sentry_browser_wrapper.js b/app/assets/javascripts/sentry/sentry_browser_wrapper.js index 03cf53fabef..99f5adf8e89 100644 --- a/app/assets/javascripts/sentry/sentry_browser_wrapper.js +++ b/app/assets/javascripts/sentry/sentry_browser_wrapper.js @@ -1,15 +1,23 @@ +/* eslint-disable no-console */ + // The _Sentry object is globally exported so it can be used here // This hack allows us to load a single version of `@sentry/browser` -// in the browser (or none). See app/views/layouts/_head.html.haml -// to find how it is imported. +// in the browser (or none). + +// See app/views/layouts/_head.html.haml to find how it is imported. -// This module wraps methods used by our production code. -// Each export is names as we cannot export the entire namespace from *. +// This module exports Sentry methods used by our production code. /** @type {import('@sentry/core').captureException} */ export const captureException = (...args) => { // eslint-disable-next-line no-underscore-dangle const Sentry = window._Sentry; + // When Sentry is not configured during development, show console error + if (process.env.NODE_ENV === 'development' && !Sentry) { + console.error('[Sentry stub]', 'captureException(...) called with:', { ...args }); + return; + } + Sentry?.captureException(...args); }; diff --git a/app/assets/javascripts/service_ping_consent.js b/app/assets/javascripts/service_ping_consent.js deleted file mode 100644 index 7d6e7e81f3b..00000000000 --- a/app/assets/javascripts/service_ping_consent.js +++ /dev/null @@ -1,35 +0,0 @@ -import $ from 'jquery'; -import { createAlert } from '~/alert'; -import axios from './lib/utils/axios_utils'; -import { parseBoolean } from './lib/utils/common_utils'; -import { __ } from './locale'; - -export default () => { - $('body').on('click', '.js-service-ping-consent-action', (e) => { - e.preventDefault(); - e.stopImmediatePropagation(); // overwrite rails listener - - const { url, checkEnabled, servicePingEnabled } = e.target.dataset; - const data = { - application_setting: { - version_check_enabled: parseBoolean(checkEnabled), - service_ping_enabled: parseBoolean(servicePingEnabled), - }, - }; - - const hideConsentMessage = () => - document.querySelector('.service-ping-consent-message .js-close')?.click(); - - axios - .put(url, data) - .then(() => { - hideConsentMessage(); - }) - .catch(() => { - hideConsentMessage(); - createAlert({ - message: __('Something went wrong. Try again later.'), - }); - }); - }); -}; diff --git a/app/assets/javascripts/sessions/new/components/update_email.vue b/app/assets/javascripts/sessions/new/components/update_email.vue index 124cd671169..f9b9a063808 100644 --- a/app/assets/javascripts/sessions/new/components/update_email.vue +++ b/app/assets/javascripts/sessions/new/components/update_email.vue @@ -1,6 +1,7 @@ <script> import { GlForm, GlFormGroup, GlFormInput, GlButton } from '@gitlab/ui'; import { createAlert, VARIANT_SUCCESS } from '~/alert'; +import { isUserEmail } from '~/lib/utils/forms'; import axios from '~/lib/utils/axios_utils'; import { I18N_EMAIL, @@ -10,7 +11,6 @@ import { I18N_EMAIL_INVALID, I18N_UPDATE_EMAIL_SUCCESS, I18N_GENERIC_ERROR, - EMAIL_REGEXP, SUCCESS_RESPONSE, FAILURE_RESPONSE, } from '../constants'; @@ -48,7 +48,7 @@ export default { return ''; } - if (!EMAIL_REGEXP.test(this.email)) { + if (!isUserEmail(this.email)) { return I18N_EMAIL_INVALID; } diff --git a/app/assets/javascripts/sessions/new/constants.js b/app/assets/javascripts/sessions/new/constants.js index e9bd26099aa..eb2bc25d958 100644 --- a/app/assets/javascripts/sessions/new/constants.js +++ b/app/assets/javascripts/sessions/new/constants.js @@ -25,6 +25,5 @@ export const I18N_UPDATE_EMAIL_SUCCESS = s__( ); export const VERIFICATION_CODE_REGEX = /^\d{6}$/; -export const EMAIL_REGEXP = /^[^@\s]+@[^@\s]+$/; // Taken from DeviseEmailValidator export const SUCCESS_RESPONSE = 'success'; export const FAILURE_RESPONSE = 'failure'; diff --git a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue index 609a9355d20..745122afb4a 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignee_title.vue @@ -47,7 +47,6 @@ export default { class="js-sidebar-dropdown-toggle edit-link btn gl-text-gray-900! gl-ml-auto hide-collapsed btn-default btn-sm gl-button btn-default-tertiary float-right" href="#" data-test-id="edit-link" - data-qa-selector="edit_link" data-track-action="click_edit_button" data-track-label="right_sidebar" data-track-property="assignee" diff --git a/app/assets/javascripts/sidebar/components/assignees/assignees.vue b/app/assets/javascripts/sidebar/components/assignees/assignees.vue index 9d6a8bf47e0..55c5b04dbe3 100644 --- a/app/assets/javascripts/sidebar/components/assignees/assignees.vue +++ b/app/assets/javascripts/sidebar/components/assignees/assignees.vue @@ -58,7 +58,6 @@ export default { type="button" class="gl-button btn-link gl-reset-color!" data-testid="assign-yourself" - data-qa-selector="assign_yourself_button" @click="assignSelf" > {{ __('assign yourself') }} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue index 5ca18969f0b..06ac2cb715d 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_vue/dropdown_value.vue @@ -58,7 +58,6 @@ export default { <template v-for="label in sortedSelectedLabels" v-else> <gl-label :key="label.id" - data-qa-selector="selected_label_content" :data-qa-label-name="label.title" :title="label.title" :description="label.description" diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue index 377200ab804..3d9a5893c67 100644 --- a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue @@ -81,7 +81,6 @@ export default { :value="searchKey" :placeholder="__('Search labels')" :disabled="labelsFetchInProgress" - data-qa-selector="dropdown_input_field" data-testid="dropdown-input-field" @input="$emit('input', $event)" @keydown.enter="$emit('searchEnter', $event)" diff --git a/app/assets/javascripts/sidebar/components/lock/edit_form.vue b/app/assets/javascripts/sidebar/components/lock/edit_form.vue index c9e651370f9..1497b229a59 100644 --- a/app/assets/javascripts/sidebar/components/lock/edit_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/edit_form.vue @@ -27,11 +27,10 @@ export default { <gl-sprintf :message=" __( - 'Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment.', + 'Unlock this discussion? %{strongStart}Everyone%{strongEnd} will be able to comment.', ) " > - <template #issuableDisplayName>{{ issuableDisplayName }}</template> <template #strong="{ content }" ><strong>{{ content }}</strong></template > @@ -42,11 +41,10 @@ export default { <gl-sprintf :message=" __( - 'Lock this %{issuableDisplayName}? Only %{strongStart}project members%{strongEnd} will be able to comment.', + 'Lock this discussion? Only %{strongStart}project members%{strongEnd} will be able to comment.', ) " > - <template #issuableDisplayName>{{ issuableDisplayName }}</template> <template #strong="{ content }" ><strong>{{ content }}</strong></template > diff --git a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue index 165499696de..977d1d6f668 100644 --- a/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue +++ b/app/assets/javascripts/sidebar/components/lock/issuable_lock_form.vue @@ -50,12 +50,12 @@ export default { issueCapitalized: __('Issue'), mergeRequest: __('merge request'), mergeRequestCapitalized: __('Merge request'), - lockingMergeRequest: __('Locking %{issuableDisplayName}'), - unlockingMergeRequest: __('Unlocking %{issuableDisplayName}'), - lockMergeRequest: __('Lock %{issuableDisplayName}'), - unlockMergeRequest: __('Unlock %{issuableDisplayName}'), - lockedMessage: __('%{issuableDisplayName} locked.'), - unlockedMessage: __('%{issuableDisplayName} unlocked.'), + lockingMergeRequest: __('Locking discussion'), + unlockingMergeRequest: __('Unlocking discussion'), + lockMergeRequest: __('Lock discussion'), + unlockMergeRequest: __('Unlock discussion'), + lockedMessage: __('Discussion locked.'), + unlockedMessage: __('Discussion unlocked.'), }, data() { return { @@ -152,7 +152,7 @@ export default { }) .catch(() => { const alertMessage = __( - 'Something went wrong trying to change the locked state of this %{issuableDisplayName}', + 'Something went wrong trying to change the locked state of the discussion', ); createAlert({ message: sprintf(alertMessage, { issuableDisplayName: this.issuableDisplayName }), @@ -170,9 +170,14 @@ export default { </script> <template> - <li v-if="isMovedMrSidebar && isIssuable" class="gl-dropdown-item"> - <button type="button" class="dropdown-item" data-testid="issuable-lock" @click="toggleLocked"> - <span class="gl-dropdown-item-text-wrapper"> + <li v-if="isMovedMrSidebar && isIssuable" class="gl-new-dropdown-item"> + <button + type="button" + class="gl-new-dropdown-item-content" + data-testid="issuable-lock" + @click="toggleLocked" + > + <span class="gl-new-dropdown-item-text-wrapper"> <template v-if="isLoading"> <gl-loading-icon inline size="sm" /> {{ lockToggleInProgressText }} </template> diff --git a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue index 34a4da946d6..ea8e0c4b950 100644 --- a/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue +++ b/app/assets/javascripts/sidebar/components/move/issuable_move_dropdown.vue @@ -1,26 +1,20 @@ <script> import { GlIcon, - GlLoadingIcon, - GlDropdown, - GlDropdownForm, - GlDropdownItem, - GlSearchBoxByType, GlButton, + GlCollapsibleListbox, GlTooltipDirective as GlTooltip, } from '@gitlab/ui'; - +import { debounce } from 'lodash'; +import { __ } from '~/locale'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import axios from '~/lib/utils/axios_utils'; export default { components: { GlIcon, - GlLoadingIcon, - GlDropdown, - GlDropdownForm, - GlDropdownItem, - GlSearchBoxByType, GlButton, + GlCollapsibleListbox, }, directives: { GlTooltip, @@ -51,82 +45,58 @@ export default { }, data() { return { - projectsListLoading: false, - projectsListLoadFailed: false, - searchKey: '', projects: [], - selectedProject: null, - projectItemClick: false, + projectsList: [], + selectedProjects: [], + noResultsText: '', + isSearching: false, }; }, - computed: { - hasNoSearchResults() { - return Boolean( - !this.projectsListLoading && - !this.projectsListLoadFailed && - this.searchKey && - !this.projects.length, - ); - }, - failedToLoadResults() { - return !this.projectsListLoading && this.projectsListLoadFailed; - }, - }, - watch: { - searchKey(value = '') { - this.fetchProjects(value); - }, + mounted() { + this.fetchProjects = debounce(this.fetchProjects, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); }, methods: { - fetchProjects(search = '') { - this.projectsListLoading = true; - this.projectsListLoadFailed = false; - return axios - .get(this.projectsFetchPath, { + triggerSearch() { + this.$refs.dropdown.search(); + }, + async fetchProjects(search = '') { + this.isSearching = true; + + try { + const { data } = await axios.get(this.projectsFetchPath, { params: { search, }, - }) - .then(({ data }) => { - this.projects = data; - this.$refs.searchInput.focusInput(); - }) - .catch(() => { - this.projectsListLoadFailed = true; - }) - .finally(() => { - this.projectsListLoading = false; }); - }, - isSelectedProject(project) { - if (this.selectedProject) { - return this.selectedProject.id === project.id; - } - return false; - }, - /** - * This handler is to prevent dropdown - * from closing when an item is selected - * and emit an event only when dropdown closes. - */ - handleDropdownHide(e) { - if (this.projectItemClick) { - e.preventDefault(); - this.projectItemClick = false; - } else { - this.$emit('dropdown-close'); + this.projects = data; + this.projectsList = data.map((item) => ({ + value: item.id, + text: item.name_with_namespace, + })); + + if (!this.projectsList.length) { + this.noResultsText = __('No matching results'); + } + } catch (e) { + this.noResultsText = __('Failed to load projects'); + } finally { + this.isSearching = false; } }, - handleDropdownCloseClick() { - this.$refs.dropdown.hide(); - }, - handleProjectSelect(project) { - this.selectedProject = project.id === this.selectedProject?.id ? null : project; - this.projectItemClick = true; + handleProjectSelect(items) { + // hack: simulate a single select to prevent the dropdown from closing + // todo: switch back to single select when https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2363 is fixed + this.selectedProjects = [items[items.length - 1]]; }, handleMoveClick() { - this.$refs.dropdown.hide(); - this.$emit('move-issuable', this.selectedProject); + this.$refs.dropdown.close(); + this.$emit( + 'move-issuable', + this.projects.find((item) => item.id === this.selectedProjects[0]), + ); + }, + handleDropdownHide() { + this.$emit('dropdown-close'); }, }, }; @@ -143,79 +113,45 @@ export default { > <gl-icon name="arrow-right" /> </div> - <gl-dropdown + <gl-collapsible-listbox ref="dropdown" + v-model="selectedProjects" + :items="projectsList" :block="true" - :disabled="moveInProgress || disabled" - class="hide-collapsed" - toggle-class="js-sidebar-dropdown-toggle" - @shown="fetchProjects" - @hide="handleDropdownHide" + :multiple="true" + :searchable="true" + :searching="isSearching" + :search-placeholder="__('Search project')" + :no-results-text="noResultsText" + :header-text="dropdownButtonTitle" + @hidden="handleDropdownHide" + @shown="triggerSearch" + @search="fetchProjects" + @select="handleProjectSelect" > - <template #button-content - ><gl-loading-icon v-if="moveInProgress" size="sm" class="gl-mr-3" />{{ - dropdownButtonTitle - }}</template - > - <gl-dropdown-form class="gl-pt-0"> - <div - data-testid="header" - class="gl-display-flex gl-pb-3 gl-border-1 gl-border-b-solid gl-border-gray-100" - > - <span class="gl-flex-grow-1 gl-text-center gl-font-weight-bold gl-py-1">{{ - dropdownHeaderTitle - }}</span> - <gl-button - variant="link" - icon="close" - class="gl-mr-2 gl-w-auto! gl-p-2!" - :aria-label="__('Close')" - @click.prevent="handleDropdownCloseClick" - /> - </div> - <gl-search-box-by-type - ref="searchInput" - v-model.trim="searchKey" - :placeholder="__('Search project')" - :debounce="300" - /> - <div data-testid="content" class="dropdown-content"> - <gl-loading-icon v-if="projectsListLoading" size="lg" class="gl-p-5" /> - <ul v-else> - <gl-dropdown-item - v-for="project in projects" - :key="project.id" - is-check-item - :is-checked="isSelectedProject(project)" - @click.stop.prevent="handleProjectSelect(project)" - >{{ project.name_with_namespace }}</gl-dropdown-item - > - </ul> - <div v-if="hasNoSearchResults" class="gl-text-center gl-p-3"> - {{ __('No matching results') }} - </div> - <div - v-if="failedToLoadResults" - data-testid="failed-load-results" - class="gl-text-center gl-p-3" - > - {{ __('Failed to load projects') }} - </div> - </div> - <div - data-testid="footer" - class="gl-pt-3 gl-px-3 gl-border-1 gl-border-t-solid gl-border-gray-100" + <template #toggle> + <gl-button + :loading="moveInProgress" + size="medium" + class="gl-w-full js-sidebar-dropdown-toggle hide-collapsed" + data-testid="dropdown-button" + :disabled="moveInProgress || disabled" + >{{ dropdownButtonTitle }}</gl-button > + </template> + <template #footer> + <div data-testid="footer" class="gl-p-3"> <gl-button category="primary" variant="confirm" - :disabled="!Boolean(selectedProject)" - class="gl-w-full issuable-move-button" + :disabled="!Boolean(selectedProjects.length)" + class="gl-w-full" + data-testid="dropdown-move-button" @click="handleMoveClick" >{{ __('Move') }}</gl-button > </div> - </gl-dropdown-form> - </gl-dropdown> + </template> + </gl-collapsible-listbox> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue index b764d660d63..40893f10109 100644 --- a/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue +++ b/app/assets/javascripts/sidebar/components/participants/sidebar_participants_widget.vue @@ -49,9 +49,6 @@ export default { error, }); }, - context: { - isSingleRequest: true, - }, }, }, computed: { diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue index a7db3b3d09f..d8e61c135e7 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewer_avatar.vue @@ -40,7 +40,6 @@ export default { :width="imgSize" :class="`s${imgSize}`" class="avatar avatar-inline m-0" - data-qa-selector="avatar_image" /> <gl-icon v-if="hasMergeIcon" name="warning-solid" aria-hidden="true" class="merge-icon" /> </span> diff --git a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue index ee9edd6a022..1bcbf2167e9 100644 --- a/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue +++ b/app/assets/javascripts/sidebar/components/reviewers/reviewers.vue @@ -62,7 +62,11 @@ export default { <collapsed-reviewer-list :users="sortedReviewers" :issuable-type="issuableType" /> <div class="value hide-collapsed"> - <span v-if="hasNoUsers" class="no-value" data-testid="no-value"> + <span + v-if="hasNoUsers" + class="no-value gl-display-flex gl-font-base gl-line-height-normal" + data-testid="no-value" + > {{ __('None') }} <template v-if="editable"> - @@ -71,7 +75,6 @@ export default { variant="link" class="gl-ml-2" data-testid="assign-yourself" - data-qa-selector="assign_yourself_button" @click="assignSelf" > <span class="gl-text-gray-500 gl-hover-text-blue-800">{{ __('assign yourself') }}</span> diff --git a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue index e2a3efa096f..e14fee5bfb8 100644 --- a/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue +++ b/app/assets/javascripts/sidebar/components/severity/sidebar_severity_widget.vue @@ -112,7 +112,7 @@ export default { </script> <template> - <div ref="sidebarSeverity" class="block"> + <div ref="sidebarSeverity" class="block" data-testid="severity-block-container"> <sidebar-editable-item ref="toggle" :loading="isUpdating" @@ -131,7 +131,7 @@ export default { </gl-sprintf> </gl-tooltip> </div> - <div class="hide-collapsed"> + <div class="hide-collapsed" data-testid="incident-severity"> <severity-token :severity="selectedItem" /> </div> </template> diff --git a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue index ba0bf783315..7ce1ceb4bb8 100644 --- a/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue +++ b/app/assets/javascripts/sidebar/components/toggle/toggle_sidebar.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { __ } from '~/locale'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; export default { name: 'ToggleSidebar', @@ -10,6 +11,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, + mixins: [glFeatureFlagsMixin()], props: { collapsed: { type: Boolean, @@ -29,7 +31,13 @@ export default { return this.collapsed ? 'chevron-double-lg-left' : 'chevron-double-lg-right'; }, allCssClasses() { - return [this.cssClasses, { 'js-sidebar-collapsed': this.collapsed }]; + return [ + this.cssClasses, + { + 'js-sidebar-collapsed': this.collapsed, + 'gl-mt-2': this.glFeatures.notificationsTodosButtons, + }, + ]; }, }, watch: { diff --git a/app/assets/javascripts/sidebar/mount_sidebar.js b/app/assets/javascripts/sidebar/mount_sidebar.js index 4b6dbdcc2c9..12e60a9ed4e 100644 --- a/app/assets/javascripts/sidebar/mount_sidebar.js +++ b/app/assets/javascripts/sidebar/mount_sidebar.js @@ -799,8 +799,7 @@ export function mountAssigneesDropdown() { }); } -const isAssigneesWidgetShown = - (isInIssuePage() || isInDesignPage() || isInMRPage()) && gon.features.issueAssigneesWidget; +const isAssigneesWidgetShown = isInIssuePage() || isInDesignPage() || isInMRPage(); export function mountSidebar(mediator, store) { mountSidebarTodoWidget(); diff --git a/app/assets/javascripts/sidebar/queries/constants.js b/app/assets/javascripts/sidebar/queries/constants.js index 0844abc4599..6bcdc01a003 100644 --- a/app/assets/javascripts/sidebar/queries/constants.js +++ b/app/assets/javascripts/sidebar/queries/constants.js @@ -12,8 +12,8 @@ import { WORKSPACE_PROJECT, } from '~/issues/constants'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; -import abuseReportLabelsQuery from '~/admin/abuse_report/components/graphql/abuse_report_labels.query.graphql'; -import createAbuseReportLabelMutation from '~/admin/abuse_report/components/graphql/create_abuse_report_label.mutation.graphql'; +import abuseReportLabelsQuery from '~/admin/abuse_report/graphql/abuse_report_labels.query.graphql'; +import createAbuseReportLabelMutation from '~/admin/abuse_report/graphql/create_abuse_report_label.mutation.graphql'; import createGroupOrProjectLabelMutation from '../components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; import updateTestCaseLabelsMutation from '../components/labels/labels_select_widget/graphql/update_test_case_labels.mutation.graphql'; import epicLabelsQuery from '../components/labels/labels_select_widget/graphql/epic_labels.query.graphql'; diff --git a/app/assets/javascripts/silent_mode_settings/components/app.vue b/app/assets/javascripts/silent_mode_settings/components/app.vue index 2dd0449448c..a151492c75c 100644 --- a/app/assets/javascripts/silent_mode_settings/components/app.vue +++ b/app/assets/javascripts/silent_mode_settings/components/app.vue @@ -1,5 +1,5 @@ <script> -import { GlToggle, GlBadge } from '@gitlab/ui'; +import { GlToggle } from '@gitlab/ui'; import { updateApplicationSettings } from '~/rest_api'; import { createAlert } from '~/alert'; import toast from '~/vue_shared/plugins/global_toast'; @@ -13,11 +13,9 @@ export default { saveError: s__('SilentMode|There was an error updating the Silent Mode Settings.'), enabled: __('enabled'), disabled: __('disabled'), - experiment: __('Experiment'), }, components: { GlToggle, - GlBadge, }, props: { isSilentModeEnabled: { @@ -62,9 +60,5 @@ export default { :label="$options.i18n.toggleLabel" :is-loading="isLoading" @change="updateSilentModeSettings" - > - <template #label - >{{ $options.i18n.toggleLabel }} <gl-badge>{{ $options.i18n.experiment }}</gl-badge></template - > - </gl-toggle> + /> </template> diff --git a/app/assets/javascripts/single_file_diff.js b/app/assets/javascripts/single_file_diff.js index 11896a75798..1e01da795e8 100644 --- a/app/assets/javascripts/single_file_diff.js +++ b/app/assets/javascripts/single_file_diff.js @@ -40,9 +40,12 @@ export default class SingleFileDiff { this.$chevronDownIcon.removeClass('gl-display-none'); } - $('.js-file-title, .click-to-expand', this.file).on('click', (e) => { + $('.js-file-title', this.file).on('click', (e) => { this.toggleDiff($(e.target)); }); + $('.click-to-expand', this.file).on('click', (e) => { + this.toggleDiff($(e.currentTarget)); + }); } toggleDiff($target, cb) { diff --git a/app/assets/javascripts/snippets/components/snippet_header.vue b/app/assets/javascripts/snippets/components/snippet_header.vue index 56ea931fc8c..573b8777ade 100644 --- a/app/assets/javascripts/snippets/components/snippet_header.vue +++ b/app/assets/javascripts/snippets/components/snippet_header.vue @@ -213,7 +213,7 @@ export default { </script> <template> <div class="detail-page-header"> - <div class="detail-page-header-body"> + <div class="detail-page-header-body gl-align-items-baseline"> <div class="snippet-box has-tooltip d-flex align-items-center gl-mr-2 mb-1" data-testid="snippet-container" @@ -235,12 +235,20 @@ export default { <template #author> <a :href="snippet.author.webUrl" class="d-inline"> <gl-avatar :size="24" :src="snippet.author.avatarUrl" /> - <span class="bold">{{ snippet.author.name }}</span> + <span class="bold gl-display-none gl-sm-display-inline">{{ + snippet.author.name + }}</span> + <strong + v-if="snippet.author.username" + data-testid="authored-username" + class="gl-display-inline gl-sm-display-none!" + >@{{ snippet.author.username }}</strong + > </a> <gl-emoji v-if="snippet.author.status" v-gl-tooltip - class="gl-vertical-align-baseline font-size-inherit gl-mr-1" + class="gl-vertical-align-baseline gl-reset-font-size gl-mr-1" :title="snippet.author.status.message" :data-name="snippet.author.status.emoji" /> @@ -249,7 +257,7 @@ export default { </div> </div> - <div v-if="hasPersonalSnippetActions" class="detail-page-header-actions"> + <div v-if="hasPersonalSnippetActions" class="detail-page-header-actions gl-align-self-start"> <div class="d-none d-sm-flex"> <template v-for="(action, index) in personalSnippetActions"> <div diff --git a/app/assets/javascripts/super_sidebar/components/create_menu.vue b/app/assets/javascripts/super_sidebar/components/create_menu.vue index 279e689bd8d..e8410a51905 100644 --- a/app/assets/javascripts/super_sidebar/components/create_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/create_menu.vue @@ -70,7 +70,6 @@ export default { :toggle-text="$options.i18n.createNew" :toggle-id="$options.toggleId" :dropdown-offset="dropdownOffset" - data-qa-selector="new_menu_toggle" data-testid="new-menu-toggle" @shown="dropdownOpen = true" @hidden="dropdownOpen = false" diff --git a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue index e73b9b275ee..414e4a54a8e 100644 --- a/app/assets/javascripts/super_sidebar/components/flyout_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/flyout_menu.vue @@ -139,8 +139,8 @@ export default { :key="item.id" :item="item" :is-flyout="true" - @pin-add="(itemId) => $emit('pin-add', itemId)" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-add="(itemId, itemTitle) => $emit('pin-add', itemId, itemTitle)" + @pin-remove="(itemId, itemTitle) => $emit('pin-remove', itemId, itemTitle)" /> </ul> <svg diff --git a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue index b85b163cea9..1a681d6e9bd 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/command_palette/command_palette_items.vue @@ -2,7 +2,7 @@ import { debounce } from 'lodash'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { GlDisclosureDropdownGroup, GlLoadingIcon } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import Tracking from '~/tracking'; diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue index 61fa360c41f..e6137bda401 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search.vue @@ -15,7 +15,14 @@ import { truncate } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { sprintf } from '~/locale'; -import { ARROW_DOWN_KEY, ARROW_UP_KEY, END_KEY, HOME_KEY, ESC_KEY } from '~/lib/utils/keys'; +import { + ARROW_DOWN_KEY, + ARROW_UP_KEY, + END_KEY, + HOME_KEY, + ESC_KEY, + NUMPAD_ENTER_KEY, +} from '~/lib/utils/keys'; import { COMMAND_PALETTE, MIN_SEARCH_TERM, @@ -215,6 +222,8 @@ export default { this.focusNextItem(event, elements, 1); } else if (code === ESC_KEY) { this.$refs.searchModal.close(); + } else if (code === NUMPAD_ENTER_KEY) { + event.target?.firstChild.click(); } else { stop = false; } diff --git a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue index 9167be5c1cc..914d3c393f5 100644 --- a/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue +++ b/app/assets/javascripts/super_sidebar/components/global_search/components/global_search_default_places.vue @@ -1,5 +1,6 @@ <script> import { GlDisclosureDropdownGroup } from '@gitlab/ui'; +import { kebabCase } from 'lodash'; import { PLACES } from '~/vue_shared/global_search/constants'; import { TRACKING_UNKNOWN_ID, TRACKING_UNKNOWN_PANEL } from '~/super_sidebar/constants'; import { TRACKING_CLICK_COMMAND_PALETTE_ITEM } from '../command_palette/constants'; @@ -20,7 +21,7 @@ export default { group() { return { name: this.$options.i18n.PLACES, - items: this.contextSwitcherLinks.map(({ title, link }) => ({ + items: this.contextSwitcherLinks.map(({ title, link, ...rest }) => ({ text: title, href: link, extraAttrs: { @@ -35,6 +36,12 @@ export default { // QA attributes 'data-testid': 'places-item-link', 'data-qa-places-item': title, + + // Any other data- attributes (e.g., for @rails/ujs) + ...Object.entries(rest).reduce((acc, [name, value]) => { + if (name.startsWith('data')) acc[kebabCase(name)] = value; + return acc; + }, {}), }, })), }; diff --git a/app/assets/javascripts/super_sidebar/components/menu_section.vue b/app/assets/javascripts/super_sidebar/components/menu_section.vue index 91b781b8235..a672e254004 100644 --- a/app/assets/javascripts/super_sidebar/components/menu_section.vue +++ b/app/assets/javascripts/super_sidebar/components/menu_section.vue @@ -145,8 +145,8 @@ export default { :items="item.items" @mouseover="isMouseOverFlyout = true" @mouseleave="isMouseOverFlyout = false" - @pin-add="(itemId) => $emit('pin-add', itemId)" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-add="(itemId, itemTitle) => $emit('pin-add', itemId, itemTitle)" + @pin-remove="(itemId, itemTitle) => $emit('pin-remove', itemId, itemTitle)" /> <gl-collapse @@ -162,8 +162,8 @@ export default { v-for="subItem of item.items" :key="`${item.title}-${subItem.title}`" :item="subItem" - @pin-add="(itemId) => $emit('pin-add', itemId)" - @pin-remove="(itemId) => $emit('pin-remove', itemId)" + @pin-add="(itemId, itemTitle) => $emit('pin-add', itemId, itemTitle)" + @pin-remove="(itemId, itemTitle) => $emit('pin-remove', itemId, itemTitle)" /> </ul> </slot> diff --git a/app/assets/javascripts/super_sidebar/components/nav_item.vue b/app/assets/javascripts/super_sidebar/components/nav_item.vue index 5416f86abeb..3ae33bf8b37 100644 --- a/app/assets/javascripts/super_sidebar/components/nav_item.vue +++ b/app/assets/javascripts/super_sidebar/components/nav_item.vue @@ -70,14 +70,16 @@ export default { return { isMouseIn: false, canClickPinButton: false, - pillCount: this.item.pill_count, }; }, computed: { + pillData() { + return this.item.pill_count; + }, hasPill() { return ( - Number.isFinite(this.pillCount) || - (typeof this.pillCount === 'string' && this.pillCount !== '') + Number.isFinite(this.pillData) || + (typeof this.pillData === 'string' && this.pillData !== '') ); }, isPinnable() { @@ -188,12 +190,22 @@ export default { eventHub.$off('updatePillValue', this.updatePillValue); }, methods: { + pinAdd() { + this.$emit('pin-add', this.item.id, this.item.title); + }, + pinRemove() { + this.$emit('pin-remove', this.item.id, this.item.title); + }, togglePointerEvents() { this.canClickPinButton = this.isMouseIn; }, updatePillValue({ value, itemId }) { if (this.item.id === itemId) { - this.pillCount = value; + // https://gitlab.com/gitlab-org/gitlab/-/issues/428246 + // fixing this linting issue is causing the pills not to async update + // + // eslint-disable-next-line vue/no-mutating-props + this.item.pill_count = value; } }, }, @@ -214,7 +226,6 @@ export default { class="gl-relative gl-display-flex gl-align-items-center gl-min-h-7 gl-gap-3 gl-mb-1 gl-py-2 gl-text-black-normal! gl-hover-bg-t-gray-a-08 gl-focus-bg-t-gray-a-08 gl-text-decoration-none! gl-focus--focus show-on-focus-or-hover--control hide-on-focus-or-hover--control" :class="computedLinkClasses" data-testid="nav-item-link" - data-qa-selector="nav_item_link" > <div :class="[isActive ? 'gl-opacity-10' : 'gl-opacity-0']" @@ -258,7 +269,7 @@ export default { 'hide-on-focus-or-hover--target transition-opacity-on-hover--target': isPinnable, }" > - {{ pillCount }} + {{ pillData }} </gl-badge> </span> </component> @@ -273,7 +284,7 @@ export default { data-testid="nav-item-unpin" icon="thumbtack-solid" size="small" - @click="$emit('pin-remove', item.id)" + @click="pinRemove" @transitionend="togglePointerEvents" /> <gl-button @@ -286,7 +297,7 @@ export default { data-testid="nav-item-pin" icon="thumbtack" size="small" - @click="$emit('pin-add', item.id)" + @click="pinAdd" @transitionend="togglePointerEvents" /> </template> diff --git a/app/assets/javascripts/super_sidebar/components/pinned_section.vue b/app/assets/javascripts/super_sidebar/components/pinned_section.vue index ea3e9e9df1f..05040218164 100644 --- a/app/assets/javascripts/super_sidebar/components/pinned_section.vue +++ b/app/assets/javascripts/super_sidebar/components/pinned_section.vue @@ -84,8 +84,8 @@ export default { return { ...i, title }; }); }, - onPinRemove(itemId) { - this.$emit('pin-remove', itemId); + onPinRemove(itemId, itemTitle) { + this.$emit('pin-remove', itemId, itemTitle); }, }, }; @@ -113,7 +113,7 @@ export default { :key="item.id" :item="item" is-in-pinned-section - @pin-remove="onPinRemove" + @pin-remove="onPinRemove(item.id, item.title)" /> </draggable> <li v-else class="gl-text-secondary gl-font-sm gl-py-3" style="margin-left: 2.5rem"> diff --git a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue index 772072c0996..c04addf5262 100644 --- a/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/sidebar_menu.vue @@ -1,6 +1,7 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlBreakpointInstance, breakpoints } from '@gitlab/ui/dist/utils'; +import { s__, sprintf } from '~/locale'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { PANELS_WITH_PINS } from '../constants'; @@ -16,7 +17,10 @@ export default { PinnedSection, }, mixins: [glFeatureFlagsMixin()], - + i18n: { + pinAdded: s__('Navigation|%{title} added to pinned items'), + pinRemoved: s__('Navigation|%{title} removed from pinned items'), + }, provide() { return { pinnedItemIds: this.changedPinnedItemIds, @@ -111,12 +115,22 @@ export default { window.removeEventListener('resize', this.decideFlyoutState); }, methods: { - createPin(itemId) { + createPin(itemId, itemTitle) { this.changedPinnedItemIds.ids.push(itemId); + this.$toast.show( + sprintf(this.$options.i18n.pinAdded, { + title: itemTitle, + }), + ); this.updatePins(); }, - destroyPin(itemId) { + destroyPin(itemId, itemTitle) { this.changedPinnedItemIds.ids = this.changedPinnedItemIds.ids.filter((id) => id !== itemId); + this.$toast.show( + sprintf(this.$options.i18n.pinRemoved, { + title: itemTitle, + }), + ); this.updatePins(); }, movePin(fromId, toId, isDownwards) { diff --git a/app/assets/javascripts/super_sidebar/components/user_bar.vue b/app/assets/javascripts/super_sidebar/components/user_bar.vue index 88ea4d828b7..3c47245a1a6 100644 --- a/app/assets/javascripts/super_sidebar/components/user_bar.vue +++ b/app/assets/javascripts/super_sidebar/components/user_bar.vue @@ -115,6 +115,7 @@ export default { <gl-badge v-if="sidebarData.gitlab_com_and_canary" variant="success" + data-testid="canary-badge-link" :href="sidebarData.canary_toggle_com_url" size="sm" > diff --git a/app/assets/javascripts/super_sidebar/components/user_menu.vue b/app/assets/javascripts/super_sidebar/components/user_menu.vue index 891e883b6c0..5712b716f48 100644 --- a/app/assets/javascripts/super_sidebar/components/user_menu.vue +++ b/app/assets/javascripts/super_sidebar/components/user_menu.vue @@ -8,7 +8,6 @@ import { } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, __, sprintf } from '~/locale'; -import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; import Tracking from '~/tracking'; import PersistentUserCallout from '~/persistent_user_callout'; import { USER_MENU_TRACKING_DEFAULTS, DROPDOWN_Y_OFFSET, IMPERSONATING_OFFSET } from '../constants'; @@ -39,14 +38,13 @@ export default { GlDisclosureDropdownGroup, GlDisclosureDropdownItem, GlButton, - NewNavToggle, UserMenuProfileItem, }, directives: { SafeHtml, }, mixins: [Tracking.mixin()], - inject: ['toggleNewNavEndpoint', 'isImpersonating'], + inject: ['isImpersonating'], props: { data: { required: true, @@ -301,13 +299,6 @@ export default { /> </gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-group bordered> - <template #group-label> - <span class="gl-font-sm">{{ $options.i18n.newNavigation.sectionTitle }}</span> - </template> - <new-nav-toggle :endpoint="toggleNewNavEndpoint" enabled new-navigation /> - </gl-disclosure-dropdown-group> - <gl-disclosure-dropdown-group v-if="data.can_sign_out" bordered diff --git a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js index f9e488ea5ee..9e540175b48 100644 --- a/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js +++ b/app/assets/javascripts/super_sidebar/super_sidebar_bundle.js @@ -1,4 +1,5 @@ import Vue from 'vue'; +import { GlToast } from '@gitlab/ui'; import { convertObjectPropsToCamelCase, parseBoolean } from '~/lib/utils/common_utils'; import { initStatusTriggers } from '../header'; import { JS_TOGGLE_EXPAND_CLASS } from './constants'; @@ -10,6 +11,8 @@ import { import SuperSidebar from './components/super_sidebar.vue'; import SuperSidebarToggle from './components/super_sidebar_toggle.vue'; +Vue.use(GlToast); + const getTrialStatusWidgetData = (sidebarData) => { if (sidebarData.trial_status_widget_data_attrs && sidebarData.trial_status_popover_data_attrs) { const { @@ -63,13 +66,7 @@ export const initSuperSidebar = () => { if (!el) return false; - const { - rootPath, - sidebar, - toggleNewNavEndpoint, - forceDesktopExpandedSidebar, - commandPalette, - } = el.dataset; + const { rootPath, sidebar, forceDesktopExpandedSidebar, commandPalette } = el.dataset; bindSuperSidebarCollapsedEvents(forceDesktopExpandedSidebar); initSuperSidebarCollapsedState(parseBoolean(forceDesktopExpandedSidebar)); @@ -95,7 +92,6 @@ export const initSuperSidebar = () => { name: 'SuperSidebarRoot', provide: { rootPath, - toggleNewNavEndpoint, isImpersonating, ...getTrialStatusWidgetData(sidebarData), commandPaletteCommands, diff --git a/app/assets/javascripts/super_sidebar/utils.js b/app/assets/javascripts/super_sidebar/utils.js index d2fb72adb85..3d6eef62ad2 100644 --- a/app/assets/javascripts/super_sidebar/utils.js +++ b/app/assets/javascripts/super_sidebar/utils.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import AccessorUtilities from '~/lib/utils/accessor'; import { FREQUENT_ITEMS, FIFTEEN_MINUTES_IN_MS } from '~/frequent_items/constants'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/tags/components/delete_tag_modal.vue b/app/assets/javascripts/tags/components/delete_tag_modal.vue index c4f9db70d2a..9a0cc026223 100644 --- a/app/assets/javascripts/tags/components/delete_tag_modal.vue +++ b/app/assets/javascripts/tags/components/delete_tag_modal.vue @@ -151,7 +151,6 @@ export default { ref="deleteTagButton" :disabled="deleteButtonDisabled" variant="danger" - data-qa-selector="delete_tag_confirmation_button" data-testid="delete-tag-confirmation-button" @click="submitForm" >{{ buttonText }}</gl-button diff --git a/app/assets/javascripts/terraform/components/init_command_modal.vue b/app/assets/javascripts/terraform/components/init_command_modal.vue index 74c41700f43..7962c8573df 100644 --- a/app/assets/javascripts/terraform/components/init_command_modal.vue +++ b/app/assets/javascripts/terraform/components/init_command_modal.vue @@ -40,15 +40,14 @@ export default { }, methods: { getModalInfoCopyStr() { - const stateNameEncoded = this.stateName - ? encodeURIComponent(this.stateName) - : '<YOUR-STATE-NAME>'; + const stateNameEncoded = this.stateName ? encodeURIComponent(this.stateName) : 'default'; return `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> +export TF_STATE_NAME=${stateNameEncoded} terraform init \\ - -backend-config="address=${this.terraformApiUrl}/${stateNameEncoded}" \\ - -backend-config="lock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ - -backend-config="unlock_address=${this.terraformApiUrl}/${stateNameEncoded}/lock" \\ + -backend-config="address=${this.terraformApiUrl}/$TF_STATE_NAME" \\ + -backend-config="lock_address=${this.terraformApiUrl}/$TF_STATE_NAME/lock" \\ + -backend-config="unlock_address=${this.terraformApiUrl}/$TF_STATE_NAME/lock" \\ -backend-config="username=${this.username}" \\ -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ -backend-config="lock_method=POST" \\ diff --git a/app/assets/javascripts/terraform/components/states_table.vue b/app/assets/javascripts/terraform/components/states_table.vue index c88c528a632..273cd599308 100644 --- a/app/assets/javascripts/terraform/components/states_table.vue +++ b/app/assets/javascripts/terraform/components/states_table.vue @@ -11,14 +11,14 @@ import { } from '@gitlab/ui'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { s__, sprintf } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import StateActions from './states_table_actions.vue'; export default { components: { - CiBadgeLink, + CiIcon, GlAlert, GlBadge, GlLink, @@ -198,10 +198,10 @@ export default { :id="`terraformJobStatusContainer${item.name}`" class="gl-my-2" > - <ci-badge-link + <ci-icon :id="`terraformJobStatus${item.name}`" :status="pipelineDetailedStatus(item)" - class="gl-py-1" + show-status-text /> <gl-tooltip diff --git a/app/assets/javascripts/time_tracking/components/timelogs_app.vue b/app/assets/javascripts/time_tracking/components/timelogs_app.vue index 7bb9b6c52a5..8464384ac7c 100644 --- a/app/assets/javascripts/time_tracking/components/timelogs_app.vue +++ b/app/assets/javascripts/time_tracking/components/timelogs_app.vue @@ -1,5 +1,4 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlButton, GlFormGroup, @@ -8,6 +7,7 @@ import { GlKeysetPagination, GlDatepicker, } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { createAlert } from '~/alert'; import { formatTimeSpent } from '~/lib/utils/datetime_utility'; import { s__ } from '~/locale'; diff --git a/app/assets/javascripts/token_access/components/inbound_token_access.vue b/app/assets/javascripts/token_access/components/inbound_token_access.vue index 7e55f56279e..345db1752f6 100644 --- a/app/assets/javascripts/token_access/components/inbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/inbound_token_access.vue @@ -46,12 +46,6 @@ export default { columnClass: 'gl-w-40p', }, { - key: 'namespace', - label: __('Namespace'), - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', - }, - { key: 'actions', label: '', tdClass: 'gl-text-right', diff --git a/app/assets/javascripts/token_access/components/outbound_token_access.vue b/app/assets/javascripts/token_access/components/outbound_token_access.vue index 43aa9b94b3a..846b0d1791f 100644 --- a/app/assets/javascripts/token_access/components/outbound_token_access.vue +++ b/app/assets/javascripts/token_access/components/outbound_token_access.vue @@ -54,12 +54,6 @@ export default { columnClass: 'gl-w-40p', }, { - key: 'namespace', - label: __('Namespace'), - thClass: 'gl-border-t-none!', - columnClass: 'gl-w-40p', - }, - { key: 'actions', label: '', tdClass: 'gl-text-right', diff --git a/app/assets/javascripts/token_access/components/token_projects_table.vue b/app/assets/javascripts/token_access/components/token_projects_table.vue index ee88b4ec339..4245b39dec1 100644 --- a/app/assets/javascripts/token_access/components/token_projects_table.vue +++ b/app/assets/javascripts/token_access/components/token_projects_table.vue @@ -29,9 +29,6 @@ export default { removeProject(project) { this.$emit('removeProject', project); }, - namespaceFallback(namespace) { - return namespace?.fullPath || ''; - }, }, }; </script> @@ -50,13 +47,7 @@ export default { </template> <template #cell(project)="{ item }"> - <span data-testid="token-access-project-name">{{ item.name }}</span> - </template> - - <template #cell(namespace)="{ item }"> - <span data-testid="token-access-project-namespace"> - {{ namespaceFallback(item.namespace) }} - </span> + <span data-testid="token-access-project-name">{{ item.fullPath }}</span> </template> <template #cell(actions)="{ item }"> diff --git a/app/assets/javascripts/token_access/graphql/cache_config.js b/app/assets/javascripts/token_access/graphql/cache_config.js new file mode 100644 index 00000000000..2db534b7eb5 --- /dev/null +++ b/app/assets/javascripts/token_access/graphql/cache_config.js @@ -0,0 +1,14 @@ +export default { + typePolicies: { + Project: { + fields: { + ciCdSettings: { + merge: true, + }, + ciJobTokenScope: { + merge: true, + }, + }, + }, + }, +}; diff --git a/app/assets/javascripts/token_access/index.js b/app/assets/javascripts/token_access/index.js index 9258d5eba45..45bd1921dbd 100644 --- a/app/assets/javascripts/token_access/index.js +++ b/app/assets/javascripts/token_access/index.js @@ -2,11 +2,12 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createDefaultClient from '~/lib/graphql'; import TokenAccessApp from './components/token_access_app.vue'; +import cacheConfig from './graphql/cache_config'; Vue.use(VueApollo); const apolloProvider = new VueApollo({ - defaultClient: createDefaultClient(), + defaultClient: createDefaultClient({}, { cacheConfig }), }); export const initTokenAccess = (containerId = 'js-ci-token-access-app') => { diff --git a/app/assets/javascripts/tracking/constants.js b/app/assets/javascripts/tracking/constants.js index 46278152879..bc416b20e80 100644 --- a/app/assets/javascripts/tracking/constants.js +++ b/app/assets/javascripts/tracking/constants.js @@ -1,5 +1,7 @@ export const SNOWPLOW_JS_SOURCE = 'gitlab-javascript'; +export const MAX_LOCAL_STORAGE_QUEUE_SIZE = 100; + export const DEFAULT_SNOWPLOW_OPTIONS = { namespace: 'gl', hostname: window.location.hostname, @@ -15,6 +17,7 @@ export const DEFAULT_SNOWPLOW_OPTIONS = { forms: { allow: [] }, fields: { allow: [] }, }, + maxLocalStorageQueueSize: MAX_LOCAL_STORAGE_QUEUE_SIZE, }; export const ACTION_ATTR_SELECTOR = '[data-track-action]'; diff --git a/app/assets/javascripts/tracking/dispatch_snowplow_event.js b/app/assets/javascripts/tracking/dispatch_snowplow_event.js index 99e4a6aa3c7..91512292eb6 100644 --- a/app/assets/javascripts/tracking/dispatch_snowplow_event.js +++ b/app/assets/javascripts/tracking/dispatch_snowplow_event.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import getStandardContext from './get_standard_context'; export function dispatchSnowplowEvent( diff --git a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue index 5dfa9c67852..f994cad6881 100644 --- a/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue +++ b/app/assets/javascripts/users/profile/actions/components/user_actions_app.vue @@ -83,7 +83,13 @@ export default { <template> <span> - <gl-disclosure-dropdown icon="ellipsis_v" category="tertiary" no-caret :items="dropdownItems" /> + <gl-disclosure-dropdown + data-testid="user-profile-actions" + icon="ellipsis_v" + category="tertiary" + no-caret + :items="dropdownItems" + /> <abuse-category-selector v-if="reportedUserId" :reported-user-id="reportedUserId" diff --git a/app/assets/javascripts/users/profile/components/report_abuse_button.vue b/app/assets/javascripts/users/profile/components/report_abuse_button.vue deleted file mode 100644 index 0e41a214888..00000000000 --- a/app/assets/javascripts/users/profile/components/report_abuse_button.vue +++ /dev/null @@ -1,58 +0,0 @@ -<script> -import { GlButton, GlTooltipDirective } from '@gitlab/ui'; -import { s__ } from '~/locale'; -import { BV_HIDE_TOOLTIP } from '~/lib/utils/constants'; - -import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; - -export default { - name: 'ReportAbuseButton', - components: { - GlButton, - AbuseCategorySelector, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - inject: ['reportedUserId', 'reportedFromUrl'], - i18n: { - reportAbuse: s__('ReportAbuse|Report abuse to administrator'), - }, - data() { - return { - open: false, - }; - }, - computed: { - buttonTooltipText() { - return this.$options.i18n.reportAbuse; - }, - }, - methods: { - toggleDrawer(open) { - this.open = open; - }, - hideTooltips() { - this.$root.$emit(BV_HIDE_TOOLTIP); - }, - }, -}; -</script> -<template> - <span> - <gl-button - v-gl-tooltip="buttonTooltipText" - category="primary" - :aria-label="buttonTooltipText" - icon="error" - @click="toggleDrawer(true)" - @mouseout="hideTooltips" - /> - <abuse-category-selector - :reported-user-id="reportedUserId" - :reported-from-url="reportedFromUrl" - :show-drawer="open" - @close-drawer="toggleDrawer(false)" - /> - </span> -</template> diff --git a/app/assets/javascripts/users/profile/index.js b/app/assets/javascripts/users/profile/index.js deleted file mode 100644 index 3ae3cc2de98..00000000000 --- a/app/assets/javascripts/users/profile/index.js +++ /dev/null @@ -1,23 +0,0 @@ -import Vue from 'vue'; -import ReportAbuseButton from './components/report_abuse_button.vue'; - -export const initReportAbuse = () => { - const el = document.getElementById('js-report-abuse'); - - if (!el) return false; - - const { reportAbusePath, reportedUserId, reportedFromUrl } = el.dataset; - - return new Vue({ - el, - name: 'ReportAbuseButtonRoot', - provide: { - reportAbusePath, - reportedUserId: reportedUserId ? parseInt(reportedUserId, 10) : null, - reportedFromUrl, - }, - render(createElement) { - return createElement(ReportAbuseButton); - }, - }); -}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue index 974b53caa15..524f2c045e6 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals.vue @@ -1,6 +1,7 @@ <script> import { GlButton, GlSprintf } from '@gitlab/ui'; import { createAlert } from '~/alert'; +import { visitUrl } from '~/lib/utils/url_utility'; import { STATUS_MERGED } from '~/issues/constants'; import { BV_SHOW_MODAL } from '~/lib/utils/constants'; import { HTTP_STATUS_UNAUTHORIZED } from '~/lib/utils/http_status'; @@ -114,6 +115,13 @@ export default { return this.userHasApproved && !this.userCanApprove && this.mr.state !== STATUS_MERGED; }, approvalText() { + // Repeating a text of this to keep i18n easier to do (vs, construcing a compound string) + if (this.requireSamlAuthToApprove) { + return this.isApproved && this.approvedBy.length > 0 + ? s__('mrWidget|Approve additionally with SAML') + : s__('mrWidget|Approve with SAML'); + } + return this.isApproved && this.approvedBy.length > 0 ? s__('mrWidget|Approve additionally') : s__('mrWidget|Approve'); @@ -161,14 +169,20 @@ export default { .join(', ') .concat('.'); }, + requireSamlAuthToApprove() { + return this.mr.requireSamlAuthToApprove; + }, }, methods: { approve() { + if (this.requireSamlAuthToApprove) { + this.approveWithSamlAuth(); + return; + } if (this.requirePasswordToApprove) { this.$root.$emit(BV_SHOW_MODAL, this.modalId); return; } - this.updateApproval( () => this.service.approveMergeRequest(), () => @@ -179,6 +193,10 @@ export default { ), ); }, + approveWithSamlAuth() { + // Intentionally direct to SAML Identity Provider for renewed authorization even if SSO session exists + visitUrl(this.mr.samlApprovalPath); + }, approveWithAuth(data) { this.updateApproval( () => this.service.approveMergeRequestWithAuth(data), @@ -236,7 +254,7 @@ export default { }; </script> <template> - <div class="js-mr-approvals mr-section-container mr-widget-workflow"> + <div v-if="approvals" class="js-mr-approvals mr-section-container mr-widget-workflow"> <state-container :is-loading="$apollo.queries.approvals.loading" :mr="mr" @@ -258,7 +276,7 @@ export default { :category="action.category" :loading="isApproving" class="gl-mr-3" - data-qa-selector="approve_button" + data-testid="approve-button" @click="action.action" > {{ action.text }} diff --git a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue index 367395f4446..b2c44dee230 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/approvals/approvals_summary.vue @@ -137,7 +137,7 @@ export default { </script> <template> - <div data-qa-selector="approvals_summary_content"> + <div data-testid="approvals-summary-content"> <span class="gl-font-weight-bold">{{ approvalLeftMessage }}</span> <template v-if="hasApprovers"> <span v-if="approvalLeftMessage">{{ message }}</span> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue index 303952c787e..32c3f19014b 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/conflicts.vue @@ -72,6 +72,8 @@ export default { <template> <merge-checks-message :check="check"> - <action-buttons v-if="!isLoading" :tertiary-buttons="tertiaryActionsButtons" /> + <template #failed> + <action-buttons v-if="!isLoading" :tertiary-buttons="tertiaryActionsButtons" /> + </template> </merge-checks-message> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js new file mode 100644 index 00000000000..431348e1d57 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/constants.js @@ -0,0 +1,6 @@ +export const COMPONENTS = { + conflict: () => import('./conflicts.vue'), + unresolved_discussions: () => import('./unresolved_discussions.vue'), + need_rebase: () => import('./rebase.vue'), + default: () => import('./message.vue'), +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue index d0d749aa441..058b9e1fe99 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/message.vue @@ -1,10 +1,25 @@ <script> +import { __ } from '~/locale'; import StatusIcon from '../widget/status_icon.vue'; const ICON_NAMES = { failed: 'failed', - allowed_to_fail: 'neutral', - passed: 'success', + inactive: 'neutral', + success: 'success', +}; + +const FAILURE_REASONS = { + broken_status: __('Cannot merge the source into the target branch, due to a conflict.'), + ci_must_pass: __('Pipeline must succeed.'), + conflict: __('Merge conflicts must be resolved.'), + discussions_not_resolved: __('Unresolved discussions must be resolved.'), + draft_status: __('Merge request must not be draft.'), + not_open: __('Merge request must be open.'), + need_rebase: __('Merge request must be rebased, because a fast-forward merge is not possible.'), + not_approved: __('All required approvals must be given.'), + policies_denied: __('Denied licenses must be removed or approved.'), + merge_request_blocked: __('Merge request is blocked by another merge request.'), + status_checks_must_pass: __('Status checks must pass.'), }; export default { @@ -25,7 +40,10 @@ export default { }, computed: { iconName() { - return ICON_NAMES[this.check.result]; + return ICON_NAMES[this.check.status.toLowerCase()]; + }, + failureReason() { + return FAILURE_REASONS[this.check.identifier.toLowerCase()]; }, }, }; @@ -36,9 +54,10 @@ export default { <div class="gl-display-flex"> <status-icon :icon-name="iconName" :level="2" /> <div class="gl-w-full gl-min-w-0"> - <div class="gl-display-flex">{{ check.failureReason }}</div> + <div class="gl-display-flex">{{ failureReason }}</div> </div> <slot></slot> + <slot v-if="check.status === 'FAILED'" name="failed"></slot> </div> </div> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js new file mode 100644 index 00000000000..c0ac1818ffa --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.stories.js @@ -0,0 +1,85 @@ +import createMockApollo from 'helpers/mock_apollo_helper'; +import rebaseStateQuery from '../../queries/states/rebase.query.graphql'; +import Rebase from './rebase.vue'; + +const service = { + rebase: () => new Promise(() => {}), +}; + +const defaultRender = ({ apolloProvider, check, mr, canCreatePipelineInTargetProject }) => ({ + components: { Rebase }, + apolloProvider, + provide: { + canCreatePipelineInTargetProject, + }, + data() { + return { service, mr: { ...mr, targetProjectFullPath: 'gitlab-org/gitlab' }, check }; + }, + template: '<rebase :mr="mr" :service="service" :check="check" />', +}); + +const Template = ({ + failed, + pushToSourceBranch, + rebaseInProgress, + onlyAllowMergeIfPipelineSucceeds, + canCreatePipelineInTargetProject, +}) => { + const requestHandlers = [ + [ + rebaseStateQuery, + () => + Promise.resolve({ + data: { + project: { + id: '1', + mergeRequest: { + id: '2', + rebaseInProgress, + targetBranch: 'main', + userPermissions: { + pushToSourceBranch, + }, + pipelines: { + nodes: [ + { + id: '1', + project: { + id: '2', + fullPath: 'gitlab/gitlab', + }, + }, + ], + }, + }, + }, + }, + }), + ], + ]; + const apolloProvider = createMockApollo(requestHandlers); + + return defaultRender({ + apolloProvider, + check: { + identifier: 'need_rebase', + status: failed ? 'failed' : 'passed', + }, + mr: { onlyAllowMergeIfPipelineSucceeds }, + canCreatePipelineInTargetProject, + }); +}; + +export const Default = Template.bind({}); +Default.args = { + failed: true, + pushToSourceBranch: true, + rebaseInProgress: false, + onlyAllowMergeIfPipelineSucceeds: false, + canCreatePipelineInTargetProject: false, +}; + +export default { + title: 'vue_merge_request_widget/merge_checks/rebase', + component: Rebase, +}; diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue new file mode 100644 index 00000000000..72140c22a89 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/rebase.vue @@ -0,0 +1,220 @@ +<script> +import { GlModal, GlLink } from '@gitlab/ui'; +import { s__, __ } from '~/locale'; +import { helpPagePath } from '~/helpers/help_page_helper'; +import { createAlert } from '~/alert'; +import toast from '~/vue_shared/plugins/global_toast'; +import simplePoll from '~/lib/utils/simple_poll'; +import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; +import rebaseQuery from '../../queries/states/rebase.query.graphql'; +import eventHub from '../../event_hub'; +import ActionButtons from '../action_buttons.vue'; +import MergeChecksMessage from './message.vue'; + +export default { + name: 'MergeChecksRebase', + components: { + GlModal, + GlLink, + MergeChecksMessage, + ActionButtons, + }, + mixins: [mergeRequestQueryVariablesMixin], + apollo: { + state: { + query: rebaseQuery, + variables() { + return this.mergeRequestQueryVariables; + }, + update: (data) => data.project.mergeRequest, + }, + }, + inject: { + canCreatePipelineInTargetProject: { + default: false, + }, + }, + props: { + check: { + type: Object, + required: true, + }, + mr: { + type: Object, + required: false, + default: () => ({}), + }, + service: { + type: Object, + required: false, + default: () => ({}), + }, + }, + data() { + return { + state: {}, + isMakingRequest: false, + }; + }, + computed: { + isLoading() { + return this.$apollo.queries.state.loading; + }, + rebaseInProgress() { + return this.state.rebaseInProgress; + }, + showRebaseWithoutPipeline() { + return ( + !this.mr.onlyAllowMergeIfPipelineSucceeds || + (this.mr.onlyAllowMergeIfPipelineSucceeds && this.mr.allowMergeOnSkippedPipeline) + ); + }, + isForkMergeRequest() { + return this.mr.sourceProjectFullPath !== this.mr.targetProjectFullPath; + }, + isLatestPipelineCreatedInTargetProject() { + const latestPipeline = this.state.pipelines.nodes[0]; + + return latestPipeline?.project?.fullPath === this.mr.targetProjectFullPath; + }, + shouldShowSecurityWarning() { + return ( + this.canCreatePipelineInTargetProject && + this.isForkMergeRequest && + !this.isLatestPipelineCreatedInTargetProject + ); + }, + tertiaryActionsButtons() { + if (this.check.result === 'success') return []; + + return [ + { + text: s__('mrWidget|Rebase'), + loading: this.isMakingRequest || this.rebaseInProgress, + testId: 'standard-rebase-button', + onClick: () => this.tryRebase(), + }, + this.showRebaseWithoutPipeline && { + text: s__('mrWidget|Rebase without pipeline'), + loading: this.isMakingRequest || this.rebaseInProgress, + testId: 'rebase-without-ci-button', + onClick: () => this.rebaseWithoutCi(), + }, + ].filter((b) => b); + }, + }, + methods: { + rebase({ skipCi = false } = {}) { + this.isMakingRequest = true; + + this.service + .rebase({ skipCi }) + .then(() => simplePoll(this.checkRebaseStatus)) + .catch((error) => { + this.isMakingRequest = false; + + if (!error.response?.data?.merge_error) { + createAlert({ + message: __('Something went wrong. Please try again.'), + }); + } + }); + }, + rebaseWithoutCi() { + return this.rebase({ skipCi: true }); + }, + tryRebase() { + if (this.shouldShowSecurityWarning) { + this.$refs.modal.show(); + } else { + this.rebase(); + } + }, + checkRebaseStatus(continuePolling, stopPolling) { + this.service + .poll() + .then((res) => res.data) + .then((res) => { + if (res.rebase_in_progress || res.should_be_rebased) { + continuePolling(); + } else { + this.isMakingRequest = false; + + if (!res.merge_error?.length) { + toast(__('Rebase completed')); + } + + eventHub.$emit('MRWidgetRebaseSuccess'); + stopPolling(); + } + }) + .catch(() => { + this.isMakingRequest = false; + createAlert({ + message: __('Something went wrong. Please try again.'), + }); + stopPolling(); + }); + }, + }, + modal: { + id: 'rebase-security-risk-modal', + title: s__('mrWidget|Are you sure you want to rebase?'), + actionPrimary: { + text: s__('mrWidget|Rebase'), + attributes: { + variant: 'danger', + }, + }, + actionCancel: { + text: __('Cancel'), + attributes: { + variant: 'default', + }, + }, + }, + runPipelinesInTheParentProjectHelpPath: helpPagePath( + '/ci/pipelines/merge_request_pipelines.html', + { + anchor: 'run-pipelines-in-the-parent-project', + }, + ), +}; +</script> + +<template> + <merge-checks-message :check="check"> + <template #failed> + <action-buttons v-if="!isLoading" :tertiary-buttons="tertiaryActionsButtons" /> + </template> + <gl-modal + ref="modal" + :modal-id="$options.modal.id" + :title="$options.modal.title" + :action-primary="$options.modal.actionPrimary" + :action-cancel="$options.modal.actionCancel" + @primary="rebase" + > + <p> + {{ + s__( + 'Pipelines|Rebasing creates a pipeline that runs code originating from a forked project merge request. Consequently there are potential security implications, such as the exposure of CI variables.', + ) + }} + </p> + <p> + {{ + s__( + "Pipelines|You should review the code thoroughly before running this pipeline with the parent project's CI/CD resources.", + ) + }} + </p> + <p> + {{ s__('Pipelines|If you are unsure, ask a project maintainer to review it for you.') }} + </p> + <gl-link :href="$options.runPipelinesInTheParentProjectHelpPath" target="_blank"> + {{ s__('Pipelines|More Information') }} + </gl-link> + </gl-modal> + </merge-checks-message> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue b/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue new file mode 100644 index 00000000000..a6970d9c795 --- /dev/null +++ b/app/assets/javascripts/vue_merge_request_widget/components/checks/unresolved_discussions.vue @@ -0,0 +1,39 @@ +<script> +import { s__ } from '~/locale'; +import notesEventHub from '~/notes/event_hub'; +import ActionButtons from '../action_buttons.vue'; +import MergeChecksMessage from './message.vue'; + +export default { + name: 'MergeChecksUnresolvedDiscussions', + components: { + MergeChecksMessage, + ActionButtons, + }, + props: { + check: { + type: Object, + required: true, + }, + }, + computed: { + tertiaryActionsButtons() { + return [ + { + text: s__('mrWidget|Go to first unresolved thread'), + category: 'default', + onClick: () => notesEventHub.$emit('jumpToFirstUnresolvedDiscussion'), + }, + ]; + }, + }, +}; +</script> + +<template> + <merge-checks-message :check="check"> + <template #failed> + <action-buttons :tertiary-buttons="tertiaryActionsButtons" /> + </template> + </merge-checks-message> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue index 3e2f3ab4103..0f692f23142 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/extensions/base.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLoadingIcon, GlTooltipDirective, GlIntersectionObserver } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { DynamicScroller, DynamicScrollerItem } from 'vendor/vue-virtual-scroller'; import { sprintf, s__, __ } from '~/locale'; @@ -102,7 +102,7 @@ export default { return this.statusIcon(this.collapsedData); }, tertiaryActionsButtons() { - return this.tertiaryButtons ? this.tertiaryButtons() : undefined; + return 'tertiaryButtons' in this ? this.tertiaryButtons() : undefined; }, hydratedSummary() { const structuredOutput = this.summary(this.collapsedData); diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js index 1c57226f887..77dc5b1d0da 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.stories.js @@ -15,9 +15,9 @@ const defaultRender = (apolloProvider) => ({ components: { MergeChecks }, apolloProvider, data() { - return { mr: { conflictResolutionPath: 'https://gitlab.com' } }; + return { service: {}, mr: { conflictResolutionPath: 'https://gitlab.com' } }; }, - template: '<merge-checks :mr="mr" />', + template: '<merge-checks :mr="mr" :service="service" />', }); const Template = ({ canMerge, failed, pushToSourceBranch }) => { @@ -32,16 +32,14 @@ const Template = ({ canMerge, failed, pushToSourceBranch }) => { mergeRequest: { id: 1, userPermissions: { canMerge }, - mergeChecks: [ + mergeabilityChecks: [ { - failureReason: 'Unresolved discussions', - identifier: 'unresolved_discussions', - result: failed ? 'failed' : 'passed', + identifier: 'DISCUSSIONS_NOT_RESOLVED', + status: failed ? 'FAILED' : 'SUCCESS', }, { - failureReason: 'Resolve conflicts', - identifier: 'conflicts', - result: failed ? 'failed' : 'passed', + identifier: 'CONFLICT', + status: failed ? 'FAILED' : 'SUCCESS', }, ], }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue index fa84c0a4a6f..ac403c2c6f2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/merge_checks.vue @@ -1,16 +1,12 @@ <script> import { GlSkeletonLoader } from '@gitlab/ui'; -import { n__, __, sprintf } from '~/locale'; +import { __, n__, sprintf } from '~/locale'; +import { COMPONENTS } from '~/vue_merge_request_widget/components/checks/constants'; import mergeRequestQueryVariablesMixin from '../mixins/merge_request_query_variables'; import mergeChecksQuery from '../queries/merge_checks.query.graphql'; import StateContainer from './state_container.vue'; import BoldText from './bold_text.vue'; -const COMPONENTS = { - conflicts: () => import('./checks/conflicts.vue'), - default: () => import('./checks/message.vue'), -}; - export default { apollo: { state: { @@ -35,6 +31,10 @@ export default { type: Object, required: true, }, + service: { + type: Object, + required: true, + }, }, data() { return { @@ -68,10 +68,10 @@ export default { ); }, checks() { - return this.state.mergeChecks || []; + return this.state.mergeabilityChecks || []; }, failedChecks() { - return this.checks.filter((c) => c.result === 'failed'); + return this.checks.filter((c) => c.status.toLowerCase() === 'failed'); }, }, methods: { @@ -79,7 +79,7 @@ export default { this.collapsed = !this.collapsed; }, checkComponent(check) { - return COMPONENTS[check.identifier] || COMPONENTS.default; + return COMPONENTS[check.identifier.toLowerCase()] || COMPONENTS.default; }, }, }; @@ -122,6 +122,7 @@ export default { }" :check="check" :mr="mr" + :service="service" /> </div> </div> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue index 2e104f2b93b..efc74241941 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_pipeline.vue @@ -10,7 +10,7 @@ import { } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { s__, n__ } from '~/locale'; -import CiBadgeLink from '~/vue_shared/components/ci_badge_link.vue'; +import CiIcon from '~/vue_shared/components/ci_icon.vue'; import { keepLatestDownstreamPipelines } from '~/ci/pipeline_details/utils/parsing_utils'; import PipelineArtifacts from '~/ci/pipelines_page/components/pipelines_artifacts.vue'; import LegacyPipelineMiniGraph from '~/ci/pipeline_mini_graph/legacy_pipeline_mini_graph.vue'; @@ -21,7 +21,7 @@ import { MT_MERGE_STRATEGY } from '../constants'; export default { name: 'MRWidgetPipeline', components: { - CiBadgeLink, + CiIcon, GlLink, GlLoadingIcon, GlIcon, @@ -194,13 +194,7 @@ export default { </p> </template> <template v-else-if="hasPipeline"> - <ci-badge-link - :status="status" - :href="status.details_path" - size="md" - :show-text="false" - class="gl-align-self-start gl-mt-2 gl-mr-3" - /> + <ci-icon :status="status" class="gl-align-self-start gl-mt-2 gl-mr-3" /> <div class="ci-widget-container d-flex"> <div class="ci-widget-content"> <div class="media-body"> @@ -208,7 +202,9 @@ export default { data-testid="pipeline-info-container" class="gl-display-flex gl-flex-wrap gl-align-items-center gl-justify-content-space-between" > - <p class="mr-pipeline-title gl-m-0! gl-mr-3! gl-font-weight-bold gl-text-gray-900"> + <p + class="mr-pipeline-title gl-align-self-start gl-m-0! gl-mr-3! gl-font-weight-bold gl-text-gray-900" + > {{ pipeline.details.event_type_name }} <gl-link :href="pipeline.path" class="pipeline-id" data-testid="pipeline-id" >#{{ pipeline.id }}</gl-link diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue index ea3f324b8f2..370e07b397c 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_status_icon.vue @@ -28,7 +28,7 @@ export default { }; </script> <template> - <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-center gl-mr-3"> + <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3"> <div class="gl-display-flex gl-m-auto"> <gl-icon v-if="isMerged" name="merge" :size="16" class="gl-text-blue-500" /> <gl-icon v-else-if="isClosed" name="merge-request-close" :size="16" class="gl-text-red-500" /> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue index 45958d7fb8d..c70213ad8a2 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue @@ -99,11 +99,7 @@ export default { <p class="gl-mt-2"> <gl-sprintf :message="$options.SP_HELP_CONTENT"> <template #link="{ content }"> - <gl-link - data-testid="help" - :href="$options.SP_HELP_URL" - target="_blank" - class="font-size-inherit" + <gl-link data-testid="help" :href="$options.SP_HELP_URL" target="_blank" >{{ content }} </gl-link> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue index ac434c5be4e..3c2d8efaffc 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/ready_to_merge.vue @@ -1,10 +1,9 @@ <script> import { - GlIcon, GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormCheckbox, GlSprintf, GlLink, @@ -15,6 +14,7 @@ import { isEmpty, isNil } from 'lodash'; import readyToMergeMixin from 'ee_else_ce/vue_merge_request_widget/mixins/ready_to_merge'; import readyToMergeQuery from 'ee_else_ce/vue_merge_request_widget/queries/states/ready_to_merge.query.graphql'; import { createAlert } from '~/alert'; +import { fetchPolicies } from '~/lib/graphql'; import { TYPENAME_MERGE_REQUEST } from '~/graphql_shared/constants'; import { STATUS_CLOSED, STATUS_MERGED } from '~/issues/constants'; import { secondsToMilliseconds } from '~/lib/utils/datetime_utility'; @@ -25,11 +25,14 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import { convertToGraphQLId } from '~/graphql_shared/utils'; import readyToMergeSubscription from '~/vue_merge_request_widget/queries/states/ready_to_merge.subscription.graphql'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { AUTO_MERGE_STRATEGIES, MT_MERGE_STRATEGY, PIPELINE_FAILED_STATE, STATE_MACHINE, + MT_SKIP_TRAIN, + MT_RESTART_TRAIN, } from '../../constants'; import eventHub from '../../event_hub'; import mergeRequestQueryVariablesMixin from '../../mixins/merge_request_query_variables'; @@ -61,6 +64,10 @@ export default { }, manual: true, result({ data }) { + if (!data.project) { + return; + } + if (Object.keys(this.state).length === 0) { this.removeSourceBranch = data.project.mergeRequest.shouldRemoveSourceBranch || @@ -121,13 +128,12 @@ export default { SquashBeforeMerge, CommitEdit, CommitMessageDropdown, - GlIcon, GlSprintf, GlLink, GlButton, GlButtonGroup, - GlDropdown, - GlDropdownItem, + GlDisclosureDropdown, + GlDisclosureDropdownItem, GlFormCheckbox, GlSkeletonLoader, MergeFailedPipelineConfirmationDialog, @@ -139,6 +145,10 @@ export default { import( 'ee_component/vue_merge_request_widget/components/merge_train_failed_pipeline_confirmation_dialog.vue' ), + MergeTrainRestartTrainConfirmationDialog: () => + import( + 'ee_component/vue_merge_request_widget/components/merge_train_restart_train_confirmation_dialog.vue' + ), AddedCommitMessage, RelatedLinks, HelpPopover, @@ -148,7 +158,7 @@ export default { directives: { GlTooltip: GlTooltipDirective, }, - mixins: [readyToMergeMixin, mergeRequestQueryVariablesMixin], + mixins: [readyToMergeMixin, mergeRequestQueryVariablesMixin, glFeatureFlagsMixin()], props: { mr: { type: Object, required: true }, service: { type: Object, required: true }, @@ -168,6 +178,10 @@ export default { squashCommitMessageIsTouched: false, isPipelineFailedModalVisibleMergeTrain: false, isPipelineFailedModalVisibleNormalMerge: false, + isMergeTrainBeingForceMerged: false, + mergeTrainMergeType: MT_RESTART_TRAIN, + skipMergeTrain: false, + mergeTrainsSkipAllowed: this.mr.mergeTrainsSkipAllowed, editCommitMessage: false, }; }, @@ -319,6 +333,12 @@ export default { title: this.autoMergePopoverSettings.title, }; }, + isSkipMergeTrainAvailable() { + return this.mergeTrainsSkipAllowed && this.glFeatures.mergeTrainsSkipTrain; + }, + displaySkipMergeTrainOptions() { + return this.shouldDisplayMergeImmediatelyDropdownOptions && this.isSkipMergeTrainAvailable; + }, }, watch: { 'mr.state': function mrStateWatcher() { @@ -329,6 +349,12 @@ export default { eventHub.$on('ApprovalUpdated', this.updateGraphqlState); eventHub.$on('MRWidgetUpdateRequested', this.updateGraphqlState); eventHub.$on('mr.discussion.updated', this.updateGraphqlState); + + if (this.glFeatures.widgetPipelinePassSubscriptionUpdate) { + this.$apollo.queries.state.setOptions({ + fetchPolicy: fetchPolicies.NO_CACHE, + }); + } }, beforeDestroy() { eventHub.$off('ApprovalUpdated', this.updateGraphqlState); @@ -377,6 +403,7 @@ export default { auto_merge_strategy: useAutoMerge ? this.preferredAutoMergeStrategy : undefined, should_remove_source_branch: this.removeSourceBranch === true, squash: this.squashBeforeMerge, + skip_merge_train: this.skipMergeTrain, }; // If users can't alter the squash message (e.g. for 1-commit merge requests), @@ -428,6 +455,17 @@ export default { this.handleMergeButtonClick(false, true); } }, + handleMergeTrainMergeImmediatelyButtonClick(type) { + this.mergeTrainMergeType = type; + this.isMergeTrainBeingForceMerged = true; + }, + processMergeTrain() { + if (this.mergeTrainMergeType === MT_SKIP_TRAIN) { + this.skipMergeTrain = true; + } + + this.handleMergeButtonClick(false, true, true); + }, onMergeImmediatelyConfirmation() { this.handleMergeButtonClick(false, true, true); }, @@ -491,6 +529,8 @@ export default { sourceDivergedFromTargetText: s__('mrWidget|The source branch is %{link} the target branch.'), divergedCommits: (count) => n__('%d commit behind', '%d commits behind', count), }, + MT_SKIP_TRAIN, + MT_RESTART_TRAIN, }; </script> @@ -520,7 +560,7 @@ export default { <div class="mr-widget-body-controls gl-display-flex gl-align-items-center gl-flex-wrap"> <template v-if="shouldShowMergeControls"> <div - class="gl-display-flex gl-sm-flex-direction-column gl-md-align-items-center gl-flex-wrap gl-w-full" + class="gl-display-flex gl-flex-direction-column gl-md-flex-direction-row gl-md-align-items-center gl-flex-wrap gl-w-full" > <gl-form-checkbox v-if="canRemoveSourceBranch" @@ -637,32 +677,57 @@ export default { @click="handleMergeButtonClick(isAutoMergeAvailable)" >{{ mergeButtonText }}</gl-button > - <gl-dropdown + <gl-disclosure-dropdown v-if="shouldShowMergeImmediatelyDropdown" v-gl-tooltip.hover.focus="__('Select merge moment')" :disabled="isMergeButtonDisabled" variant="confirm" + class="gl-mr-0" data-testid="merge-immediately-dropdown" + icon="chevron-down" toggle-class="btn-icon js-merge-moment" + :toggle-text="__('Select a merge moment')" + text-sr-only + no-caret > - <template #button-content> - <gl-icon name="chevron-down" class="mr-0" /> - <span class="sr-only">{{ __('Select merge moment') }}</span> - </template> - <gl-dropdown-item - icon-name="warning" - button-class="accept-merge-request" + <gl-disclosure-dropdown-item + v-if=" + !shouldDisplayMergeImmediatelyDropdownOptions || !isSkipMergeTrainAvailable + " data-testid="merge-immediately-button" - @click="handleMergeImmediatelyButtonClick" + @action="handleMergeImmediatelyButtonClick" + > + <template #list-item> {{ __('Merge immediately') }} </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item + v-if="displaySkipMergeTrainOptions" + data-testid="mt-merge-now-restart-button" + @action="handleMergeTrainMergeImmediatelyButtonClick($options.MT_RESTART_TRAIN)" > - {{ __('Merge immediately') }} - </gl-dropdown-item> - </gl-dropdown> + <template #list-item> + <strong>{{ __(`Merge now and restart train`) }}</strong> + <p class="gl-text-gray-400 gl-font-sm gl-mb-0"> + {{ __('Restart merge train pipelines with the merged changes.') }} + </p> + </template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item + v-if="displaySkipMergeTrainOptions" + data-testid="mt-merge-now-skip-restart-button" + @action="handleMergeTrainMergeImmediatelyButtonClick($options.MT_SKIP_TRAIN)" + > + <template #list-item> + <strong>{{ __(`Merge now and don't restart train`) }}</strong> + <p class="gl-text-gray-400 gl-font-sm gl-mb-0"> + {{ __('Merge train pipelines continue without the merged changes.') }} + </p> + </template> + </gl-disclosure-dropdown-item> + </gl-disclosure-dropdown> </gl-button-group> <template v-if="showAutoMergeHelperText"> <div class="gl-ml-4 gl-text-gray-500 gl-font-sm" - data-qa-selector="auto_merge_helper_text" data-testid="auto-merge-helper-text" > {{ autoMergeHelperText }} @@ -730,12 +795,16 @@ export default { class="mr-ready-merge-related-links gl-display-inline" /> </li> + <li v-if="state.autoMergeEnabled" class="gl-line-height-normal"> + {{ s__('mrWidget|Auto-merge enabled') }} + </li> </ul> </div> </div> </div> </div> <merge-immediately-confirmation-dialog + v-if="mr.mergeImmediatelyDocsPath" ref="confirmationDialog" :docs-url="mr.mergeImmediatelyDocsPath" @mergeImmediately="onMergeImmediatelyConfirmation" @@ -745,6 +814,13 @@ export default { @startMergeTrain="onStartMergeTrainConfirmation" @cancel="isPipelineFailedModalVisibleMergeTrain = false" /> + <merge-train-restart-train-confirmation-dialog + v-if="isSkipMergeTrainAvailable" + :visible="isMergeTrainBeingForceMerged" + :merge-train-type="mergeTrainMergeType" + @processMergeTrainMerge="processMergeTrain" + @cancel="isMergeTrainBeingForceMerged = false" + /> <merge-failed-pipeline-confirmation-dialog :visible="isPipelineFailedModalVisibleNormalMerge" @mergeWithFailedPipeline="onMergeWithFailedPipelineConfirmation" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue index 7fc4a06cbae..267facb0a50 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/states/work_in_progress.vue @@ -143,7 +143,9 @@ export default { :collapsed="mr.mergeDetailsCollapsed" @toggle="() => mr.toggleMergeDetails()" > - <span class="gl-ml-0! gl-text-body! gl-flex-grow-1"> + <span + class="gl-display-inline-flex gl-align-self-start gl-pt-2 gl-ml-0! gl-text-body! gl-flex-grow-1" + > <bold-text :message="$options.i18n.removeDraftStatus" /> </span> <template #actions> diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue index 8249dffcc27..08e803bffc9 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/app.vue @@ -9,6 +9,8 @@ export default { MrTerraformWidget: () => import('~/vue_merge_request_widget/extensions/terraform/index.vue'), MrCodeQualityWidget: () => import('~/vue_merge_request_widget/extensions/code_quality/index.vue'), + MrAccessibilityWidget: () => + import('~/vue_merge_request_widget/extensions/accessibility/index.vue'), }, props: { @@ -31,12 +33,17 @@ export default { return this.mr.codequalityReportsPath ? 'MrCodeQualityWidget' : undefined; }, + accessibilityWidget() { + return this.mr.accessibilityReportPath ? 'MrAccessibilityWidget' : undefined; + }, + widgets() { return [ this.codeQualityWidget, this.testReportWidget, this.terraformPlansWidget, 'MrSecurityWidget', + this.accessibilityWidget, ].filter((w) => w); }, }, diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue index 72c041759d9..d4375690ad1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/dynamic_content.vue @@ -1,5 +1,5 @@ <script> -import { GlBadge, GlLink } from '@gitlab/ui'; +import { GlBadge, GlLink, GlTooltipDirective } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { generateText } from '../extensions/utils'; import ContentRow from './widget_content_row.vue'; @@ -15,6 +15,7 @@ export default { }, directives: { SafeHtml, + GlTooltip: GlTooltipDirective, }, props: { data: { @@ -78,7 +79,11 @@ export default { <div class="gl-display-flex gl-flex-grow-1"> <div class="gl-display-flex gl-flex-grow-1 gl-align-items-baseline"> <div> - <p v-safe-html="generatedText" class="gl-mb-0 gl-mr-1"></p> + <p + v-gl-tooltip="{ title: data.tooltipText, boundary: 'viewport' }" + v-safe-html="generatedText" + class="gl-mb-0 gl-mr-1" + ></p> <gl-link v-if="data.link" :href="data.link.href">{{ data.link.text }}</gl-link> <p v-if="data.supportingText" diff --git a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue index d17be3e4037..0eb50b9ff4f 100644 --- a/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue +++ b/app/assets/javascripts/vue_merge_request_widget/components/widget/widget.vue @@ -1,7 +1,7 @@ <!-- eslint-disable vue/multi-word-component-names --> <script> import { GlButton, GlLink, GlTooltipDirective, GlLoadingIcon } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { normalizeHeaders } from '~/lib/utils/common_utils'; import { logError } from '~/lib/logger'; import SafeHtml from '~/vue_shared/directives/safe_html'; diff --git a/app/assets/javascripts/vue_merge_request_widget/constants.js b/app/assets/javascripts/vue_merge_request_widget/constants.js index 1a469f9b7bb..071f95a28fa 100644 --- a/app/assets/javascripts/vue_merge_request_widget/constants.js +++ b/app/assets/javascripts/vue_merge_request_widget/constants.js @@ -6,7 +6,7 @@ import { stateToComponentMap as classStateMap, stateKey } from './stores/state_m export const FOUR_MINUTES_IN_MS = 1000 * 60 * 4; export const STATE_QUERY_POLLING_INTERVAL_DEFAULT = 5000; -export const STATE_QUERY_POLLING_INTERVAL_BACKOFF = 2; +export const STATE_QUERY_POLLING_INTERVAL_BACKOFF = 1.2; export const SUCCESS = 'success'; export const WARNING = 'warning'; @@ -202,3 +202,6 @@ export const DETAILED_MERGE_STATUS = { CI_STILL_RUNNING: 'CI_STILL_RUNNING', EXTERNAL_STATUS_CHECKS: 'EXTERNAL_STATUS_CHECKS', }; + +export const MT_SKIP_TRAIN = 'skip'; +export const MT_RESTART_TRAIN = 'restart'; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.vue index 0fb5e13ad82..2ae16eef410 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.js +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/accessibility/index.vue @@ -1,24 +1,37 @@ +<script> import { uniqueId } from 'lodash'; import { __, n__, s__, sprintf } from '~/locale'; import axios from '~/lib/utils/axios_utils'; +import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; import { EXTENSION_ICONS } from '../../constants'; export default { name: 'WidgetAccessibility', - enablePolling: true, i18n: { loading: s__('Reports|Accessibility scanning results are being parsed'), error: s__('Reports|Accessibility scanning failed loading results'), }, - props: ['accessibilityReportPath'], + components: { + MrWidget, + }, + props: { + mr: { + type: Object, + required: true, + }, + }, + data() { + return { + collapsedData: {}, + content: [], + }; + }, computed: { statusIcon() { - return this.collapsedData.status === 'failed' + return this.collapsedData?.status === 'failed' ? EXTENSION_ICONS.warning : EXTENSION_ICONS.success; }, - }, - methods: { summary() { const numOfResults = this.collapsedData?.summary?.errored || 0; @@ -37,13 +50,20 @@ export default { false, ); - return numOfResults === 0 ? successText : warningText; + return numOfResults === 0 ? { title: successText } : { title: warningText }; }, shouldCollapse() { return this.collapsedData?.summary?.errored > 0; }, + }, + methods: { fetchCollapsedData() { - return axios.get(this.accessibilityReportPath); + return axios.get(this.mr.accessibilityReportPath).then((response) => { + this.collapsedData = response.data; + this.content = this.getContent(response.data); + + return response; + }); }, fetchFullData() { return Promise.resolve(this.prepareReports()); @@ -74,9 +94,7 @@ export default { formatMessage(message) { return sprintf(s__('AccessibilityReport|Message: %{message}'), { message }); }, - prepareReports() { - const { collapsedData } = this; - + getContent(collapsedData) { const newErrors = collapsedData.new_errors.map((error) => { return { header: __('New'), @@ -121,3 +139,16 @@ export default { }, }, }; +</script> +<template> + <mr-widget + :error-text="$options.i18n.error" + :status-icon-name="statusIcon" + :loading-text="$options.i18n.loading" + :widget-name="$options.name" + :summary="summary" + :content="content" + :is-collapsible="shouldCollapse" + :fetch-collapsed-data="fetchCollapsedData" + /> +</template> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue index cd3a98effa3..e87b5d20ca0 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/code_quality/index.vue @@ -1,5 +1,5 @@ <script> -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { HTTP_STATUS_OK } from '~/lib/utils/http_status'; import { SEVERITY_ICONS_MR_WIDGET } from '~/ci/reports/codequality_report/constants'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue index e7d8de97f20..a36a58c68de 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/security_reports/mr_widget_security_reports.vue @@ -1,5 +1,5 @@ <script> -import { GlDropdown, GlDropdownItem, GlTooltipDirective as GlTooltip } from '@gitlab/ui'; +import { GlDisclosureDropdown } from '@gitlab/ui'; import MrWidget from '~/vue_merge_request_widget/components/widget/widget.vue'; import { helpPagePath } from '~/helpers/help_page_helper'; import { s__, sprintf } from '~/locale'; @@ -10,11 +10,7 @@ export default { name: 'WidgetSecurityReportsCE', components: { MrWidget, - GlDropdown, - GlDropdownItem, - }, - directives: { - GlTooltip, + GlDisclosureDropdown, }, i18n: { apiError: s__( @@ -76,17 +72,23 @@ export default { summary() { return { title: this.$options.i18n.scansHaveRun }; }, + listboxOptions() { + return this.artifacts.map(({ name, path }) => ({ + text: sprintf(s__('SecurityReports|Download %{artifactName}'), { + artifactName: name, + }), + href: path, + extraAttrs: { + download: '', + rel: 'nofollow', + }, + })); + }, }, methods: { handleIsLoading(value) { this.isLoading = value; }, - - artifactText({ name }) { - return sprintf(s__('SecurityReports|Download %{artifactName}'), { - artifactName: name, - }); - }, }, widgetHelpPopover: { options: { title: s__('ciReport|Security scan results') }, @@ -116,26 +118,12 @@ export default { @is-loading="handleIsLoading" > <template #action-buttons> - <div class="gl-ml-3"> - <gl-dropdown - v-gl-tooltip - icon="download" - size="small" - category="tertiary" - variant="confirm" - right - > - <gl-dropdown-item - v-for="artifact in artifacts" - :key="artifact.path" - :href="artifact.path" - :data-testid="`download-${artifact.name}`" - download - > - {{ artifactText(artifact) }} - </gl-dropdown-item> - </gl-dropdown> - </div> + <gl-disclosure-dropdown + class="gl-ml-3" + size="small" + icon="download" + :items="listboxOptions" + /> </template> </mr-widget> </template> diff --git a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue index 1b03b9c04e1..c12bc6456a5 100644 --- a/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue +++ b/app/assets/javascripts/vue_merge_request_widget/extensions/test_report/index.vue @@ -19,7 +19,9 @@ import { import { i18n, TESTS_FAILED_STATUS, ERROR_STATUS } from './constants'; export default { - name: 'WidgetTestReport', + // widget name does not match file path because widget name must match telemetry event names + // see https://gitlab.com/gitlab-org/gitlab/-/issues/427061 + name: 'WidgetTestSummary', components: { MrWidget, MrWidgetRow, diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js index 564e9321d54..8bb2f2898eb 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/approvals.js @@ -18,8 +18,16 @@ export default { iid: `${this.mr.iid}`, }; }, - update: (data) => data.project.mergeRequest, + update: (data) => data.project?.mergeRequest, result({ data }) { + // This case can occur when backend returns an empty project due to expired session. + // See https://gitlab.com/gitlab-org/gitlab/-/issues/413627 for more information. + if (!data.project) { + // Needed to suppress several errors. + this.mr.setApprovals({}); + return; + } + const { mergeRequest } = data.project; this.disableCommittersApproval = data.project.mergeRequestsDisableCommittersApproval; diff --git a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js index 2f49252a06b..623b504fcc1 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js +++ b/app/assets/javascripts/vue_merge_request_widget/mixins/ready_to_merge.js @@ -21,13 +21,6 @@ export default { this.mr.preventMerge, ); }, - mergeDisabledText() { - if (this.pipeline?.status === PIPELINE_SKIPPED_STATUS) { - return MERGE_DISABLED_SKIPPED_PIPELINE_TEXT; - } - - return MERGE_DISABLED_TEXT; - }, pipelineMustSucceedConflictText() { return PIPELINE_MUST_SUCCEED_CONFLICT_TEXT; }, diff --git a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue index 02d73cf9cbd..cc116b42f1e 100644 --- a/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue +++ b/app/assets/javascripts/vue_merge_request_widget/mr_widget_options.vue @@ -1,9 +1,6 @@ <script> import { isEmpty, clamp } from 'lodash'; -import { - registerExtension, - registeredExtensions, -} from '~/vue_merge_request_widget/components/extensions'; +import { registeredExtensions } from '~/vue_merge_request_widget/components/extensions'; import SafeHtml from '~/vue_shared/directives/safe_html'; import MrWidgetApprovals from 'ee_else_ce/vue_merge_request_widget/components/approvals/approvals.vue'; import MRWidgetService from 'ee_else_ce/vue_merge_request_widget/services/mr_widget_service'; @@ -55,7 +52,6 @@ import eventHub from './event_hub'; import mergeRequestQueryVariablesMixin from './mixins/merge_request_query_variables'; import getStateQuery from './queries/get_state.query.graphql'; import getStateSubscription from './queries/get_state.subscription.graphql'; -import accessibilityExtension from './extensions/accessibility'; import ReportWidgetContainer from './components/report_widget_container.vue'; import MrWidgetReadyToMerge from './components/states/new_ready_to_merge.vue'; @@ -235,9 +231,6 @@ export default { false, ); }, - shouldShowAccessibilityReport() { - return Boolean(this.mr?.accessibilityReportPath); - }, formattedHumanAccess() { return (this.mr.humanAccess || '').toLowerCase(); }, @@ -268,11 +261,6 @@ export default { this.initPostMergeDeploymentsPolling(); } }, - shouldShowAccessibilityReport(newVal) { - if (newVal) { - this.registerAccessibilityExtension(); - } - }, }, mounted() { MRWidgetService.fetchInitialData() @@ -507,11 +495,6 @@ export default { dismissSuggestPipelines() { this.mr.isDismissedSuggestPipeline = true; }, - registerAccessibilityExtension() { - if (this.shouldShowAccessibilityReport) { - registerExtension(accessibilityExtension); - } - }, }, }; </script> diff --git a/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql index 6b602a0095c..fcaddcc2a42 100644 --- a/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql +++ b/app/assets/javascripts/vue_merge_request_widget/queries/merge_checks.query.graphql @@ -6,7 +6,10 @@ query mergeChecks($projectPath: ID!, $iid: String!) { userPermissions { canMerge } - mergeChecks @client + mergeabilityChecks { + identifier + status + } } } } diff --git a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue index 6803d609dbc..e84b3f53b53 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/alert_details.vue @@ -10,7 +10,7 @@ import { GlTab, GlButton, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import SafeHtml from '~/vue_shared/directives/safe_html'; import highlightCurrentUser from '~/behaviors/markdown/highlight_current_user'; import { fetchPolicies } from '~/lib/graphql'; @@ -30,7 +30,7 @@ import AlertSidebar from './alert_sidebar.vue'; import AlertSummaryRow from './alert_summary_row.vue'; import SystemNote from './system_notes/system_note.vue'; -const containerEl = document.querySelector('.page-with-contextual-sidebar'); +const containerEl = document.querySelector('.layout-page'); export default { i18n: { diff --git a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue index 2d3815439a6..056388f690d 100644 --- a/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue +++ b/app/assets/javascripts/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue @@ -284,13 +284,7 @@ export default { > <div v-if="userName" class="gl-display-inline-flex gl-mt-2" data-testid="assigned-users"> <span class="gl-relative gl-mr-4"> - <img - :alt="userName" - :src="userImg" - :width="32" - class="avatar avatar-inline gl-m-0 s32" - data-qa-selector="avatar_image" - /> + <img :alt="userName" :src="userImg" :width="32" class="avatar avatar-inline gl-m-0 s32" /> </span> <span class="gl-display-flex gl-flex-direction-column gl-overflow-hidden"> <strong class="dropdown-menu-user-full-name"> diff --git a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue index ffbcdefc924..93e1fc4a0c2 100644 --- a/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue +++ b/app/assets/javascripts/vue_shared/components/chronic_duration_input.vue @@ -1,6 +1,6 @@ <script> -import * as Sentry from '@sentry/browser'; import { GlFormInput } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { DurationParseError, outputChronicDuration, diff --git a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue b/app/assets/javascripts/vue_shared/components/ci_badge_link.vue deleted file mode 100644 index abbeac0e098..00000000000 --- a/app/assets/javascripts/vue_shared/components/ci_badge_link.vue +++ /dev/null @@ -1,157 +0,0 @@ -<script> -import { GlBadge, GlTooltipDirective } from '@gitlab/ui'; -import CiIcon from './ci_icon.vue'; - -/** - * Renders CI Badge link with CI icon and status text based on - * API response shared between all places where it is used. - * - * Receives status object containing: - * status: { - * details_path or detailsPath: "/gitlab-org/gitlab-foss/pipelines/8150156" // url - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon - * label:"running" // used for potential tooltip - * text:"running" // text rendered - * } - * - * Used in: - * - Pipelines table - first column - * - Jobs table - first column - * - Pipeline show view - header - * - Job show view - header - * - MR widget - * - Terraform table - * - On-demand scans list - */ - -const badgeSizeOptions = { - sm: 'sm', - md: 'md', - lg: 'lg', -}; - -export default { - components: { - CiIcon, - GlBadge, - }, - directives: { - GlTooltip: GlTooltipDirective, - }, - props: { - status: { - type: Object, - required: true, - }, - showText: { - type: Boolean, - required: false, - default: true, - }, - size: { - type: String, - required: false, - default: badgeSizeOptions.md, - validator(value) { - return badgeSizeOptions[value] !== undefined; - }, - }, - showTooltip: { - type: Boolean, - required: false, - default: true, - }, - useLink: { - type: Boolean, - default: true, - required: false, - }, - }, - computed: { - isNotLargeBadgeSize() { - return this.size !== badgeSizeOptions.lg; - }, - title() { - return this.showTooltip && !this.showText ? this.status?.text : ''; - }, - detailsPath() { - // For now, this can either come from graphQL with camelCase or REST API in snake_case - if (!this.useLink) { - return null; - } - return this.status.detailsPath || this.status.details_path; - }, - badgeStyles() { - switch (this.status.icon) { - case 'status_success': - return { - textColor: 'gl-text-green-700', - variant: 'success', - }; - case 'status_warning': - return { - textColor: 'gl-text-orange-700', - variant: 'warning', - }; - case 'status_failed': - return { - textColor: 'gl-text-red-700', - variant: 'danger', - }; - case 'status_running': - return { - textColor: 'gl-text-blue-700', - variant: 'info', - }; - case 'status_pending': - return { - textColor: 'gl-text-orange-700', - variant: 'warning', - }; - case 'status_canceled': - return { - textColor: 'gl-text-gray-700', - variant: 'neutral', - }; - case 'status_manual': - return { - textColor: 'gl-text-gray-700', - variant: 'neutral', - }; - // default covers the styles for the remainder of CI - // statuses that are not explicitly stated here - default: - return { - textColor: 'gl-text-gray-600', - variant: 'muted', - }; - } - }, - }, -}; -</script> -<template> - <gl-badge - v-gl-tooltip - :class="{ 'gl-px-2': !showText && isNotLargeBadgeSize }" - :title="title" - :href="detailsPath" - :size="size" - :variant="badgeStyles.variant" - data-testid="ci-badge-link" - @click="$emit('ciStatusBadgeClick')" - > - <ci-icon :status="status" /> - - <template v-if="showText"> - <span - class="gl-ml-2 gl-white-space-nowrap" - :class="badgeStyles.textColor" - data-testid="ci-badge-text" - > - {{ status.text }} - </span> - </template> - </gl-badge> -</template> diff --git a/app/assets/javascripts/vue_shared/components/ci_icon.vue b/app/assets/javascripts/vue_shared/components/ci_icon.vue index 6670b931416..a2b6b4642c9 100644 --- a/app/assets/javascripts/vue_shared/components/ci_icon.vue +++ b/app/assets/javascripts/vue_shared/components/ci_icon.vue @@ -1,99 +1,115 @@ <script> -import { GlIcon } from '@gitlab/ui'; +import { GlBadge, GlTooltipDirective, GlIcon } from '@gitlab/ui'; /** * Renders CI icon based on API response shared between all places where it is used. * * Receives status object containing: * status: { - * group:"running" // used for CSS class - * icon: "icon_status_running" // used to render the icon + * icon: "status_running" // used to render the icon and CSS class + * text: "Running", + * detailsPath: '/project1/jobs/1' // can also be details_path * } * - * Used in: - * - Extended MR Popover - * - Jobs show view header - * - Jobs show view sidebar - * - Jobs table - * - Linked pipelines - * - Pipeline graph - * - Pipeline mini graph - * - Pipeline show view badge - * - Pipelines table Badge */ -/* - * These sizes are defined in gitlab-ui/src/scss/variables.scss - * under '$gl-icon-sizes' - */ -const validSizes = [8, 12, 14, 16, 24, 32, 48, 72]; - export default { components: { + GlBadge, GlIcon, }, + directives: { + GlTooltip: GlTooltipDirective, + }, props: { status: { type: Object, required: true, validator(status) { - const { group, icon } = status; - return ( - typeof group === 'string' && - group.length && - typeof icon === 'string' && - icon.startsWith('status_') - ); + const { icon } = status; + return typeof icon === 'string' && icon.startsWith('status_'); }, }, - size: { - type: Number, - required: false, - default: 16, - validator(value) { - return validSizes.includes(value); - }, - }, - isActive: { + showStatusText: { type: Boolean, required: false, default: false, }, - isBorderless: { + showTooltip: { type: Boolean, required: false, - default: false, + default: true, }, - isInteractive: { + useLink: { type: Boolean, + default: true, required: false, - default: false, - }, - cssClasses: { - type: String, - required: false, - default: '', }, }, computed: { - wrapperStyleClasses() { - const status = this.status.group; - return `ci-status-icon ci-status-icon-${status} gl-rounded-full gl-justify-content-center gl-line-height-0`; + title() { + if (this.showTooltip) { + // show tooltip only when not showing text already + return !this.showStatusText ? this.status?.text : null; + } + return null; + }, + ariaLabel() { + // show aria-label only when text is not rendered + if (!this.showStatusText) { + return this.status?.text; + } + return null; + }, + href() { + // href can come from GraphQL (camelCase) or REST API (snake_case) + if (this.useLink) { + return this.status.detailsPath || this.status.details_path; + } + return null; }, icon() { - return this.isBorderless ? `${this.status.icon}_borderless` : this.status.icon; + if (this.status.icon) { + return `${this.status.icon}_borderless`; + } + return null; + }, + variant() { + switch (this.status.icon) { + case 'status_success': + return 'success'; + case 'status_warning': + case 'status_pending': + return 'warning'; + case 'status_failed': + return 'danger'; + case 'status_running': + return 'info'; + // default covers the styles for the remainder of CI + // statuses that are not explicitly stated here + default: + return 'neutral'; + } }, }, }; </script> <template> - <span - :class="[ - wrapperStyleClasses, - { interactive: isInteractive, active: isActive, borderless: isBorderless }, - ]" - :style="{ height: `${size}px`, width: `${size}px` }" + <gl-badge + v-gl-tooltip + class="ci-icon gl-p-2" + :class="`ci-icon-variant-${variant}`" + :variant="variant" + :title="title" + :aria-label="ariaLabel" + :href="href" + size="md" + data-testid="ci-icon" + @click="$emit('ciStatusBadgeClick')" > - <gl-icon :name="icon" :size="size" :class="cssClasses" :aria-label="status.icon" /> - </span> + <span class="ci-icon-gl-icon-wrapper"><gl-icon :name="icon" /></span + ><span v-if="showStatusText" class="gl-mx-2 gl-white-space-nowrap" data-testid="ci-icon-text">{{ + status.text + }}</span> + </gl-badge> </template> diff --git a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue index f62bfb551df..55767c5f4bc 100644 --- a/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue +++ b/app/assets/javascripts/vue_shared/components/diff_stats_dropdown.vue @@ -1,11 +1,5 @@ <script> -import { - GlDropdown, - GlDropdownItem, - GlDropdownText, - GlSearchBoxByType, - GlSprintf, -} from '@gitlab/ui'; +import { GlDisclosureDropdown, GlIcon, GlSearchBoxByType, GlSprintf } from '@gitlab/ui'; import fuzzaldrinPlus from 'fuzzaldrin-plus'; import { __, n__, s__, sprintf } from '~/locale'; @@ -16,12 +10,16 @@ export const i18n = { searchFiles: __('Search files'), }; +const variantCssColorMap = { + success: 'gl-text-green-500', + danger: 'gl-text-red-500', +}; + export default { i18n, components: { - GlDropdown, - GlDropdownItem, - GlDropdownText, + GlDisclosureDropdown, + GlIcon, GlSearchBoxByType, GlSprintf, }, @@ -54,6 +52,15 @@ export default { ? fuzzaldrinPlus.filter(this.files, this.search, { key: 'name' }) : this.files; }, + dropdownItems() { + return this.filteredFiles.map((file) => { + return { + ...file, + text: file.name || this.$options.i18n.noFileNameAvailable, + iconColor: variantCssColorMap[file.iconColor], + }; + }); + }, messageChanged() { return sprintf( n__( @@ -64,21 +71,21 @@ export default { { count: this.changed }, ); }, - - additionsText() { - return n__('Diffs|%d addition', 'Diffs|%d additions', this.added); - }, - deletionsText() { - return n__('Diffs|%d deletion', 'Diffs|%d deletions', this.deleted); - }, }, methods: { - jumpToFile(fileHash) { - window.location.hash = fileHash; - }, focusInput() { this.$refs.search.focusInput(); }, + focusFirstItem() { + if (!this.filteredFiles.length) return; + this.$el.querySelector('.gl-new-dropdown-item:first-child').focus(); + }, + additionsText(numberOfChanges = this.added) { + return n__('Diffs|%d addition', 'Diffs|%d additions', numberOfChanges); + }, + deletionsText(numberOfChanges = this.deleted) { + return n__('Diffs|%d deletion', 'Diffs|%d deletions', numberOfChanges); + }, }, }; </script> @@ -87,15 +94,15 @@ export default { <div> <gl-sprintf :message="messageChanged"> <template #dropdown="{ content: dropdownText }"> - <gl-dropdown + <gl-disclosure-dropdown + :toggle-text="dropdownText" + :items="dropdownItems" category="tertiary" variant="confirm" - :text="dropdownText" data-testid="diff-stats-dropdown" class="gl-vertical-align-baseline" toggle-class="gl-px-0! gl-font-weight-bold!" - menu-class="gl-w-auto!" - no-flip + fluid-width @shown="focusInput" > <template #header> @@ -103,35 +110,38 @@ export default { ref="search" v-model.trim="search" :placeholder="$options.i18n.searchFiles" + class="gl-mx-3 gl-my-4" + @keydown.down="focusFirstItem" /> + <span v-if="!filteredFiles.length" class="gl-mx-3"> + {{ $options.i18n.noFilesFound }} + </span> </template> - <gl-dropdown-item - v-for="file in filteredFiles" - :key="file.href" - :icon-name="file.icon" - :icon-color="file.iconColor" - @click="jumpToFile(file.href)" - > - <div class="gl-display-flex"> - <span v-if="file.name" class="gl-font-weight-bold gl-mr-3 gl-text-truncate">{{ - file.name - }}</span> - <span v-else class="gl-mr-3 gl-font-weight-bold gl-font-style-italic gl-gray-400">{{ - $options.i18n.noFileNameAvailable - }}</span> - <span class="gl-ml-auto gl-white-space-nowrap"> - <span class="gl-text-green-600">+{{ file.added }}</span> - <span class="gl-text-red-500">-{{ file.removed }}</span> - </span> + <template #list-item="{ item }"> + <div class="gl-display-flex gl-gap-3 gl-align-items-center gl-overflow-hidden"> + <gl-icon :name="item.icon" :class="item.iconColor" class="gl-flex-shrink-0" /> + <div class="gl-flex-grow-1 gl-overflow-hidden"> + <div class="gl-display-flex"> + <span + class="gl-font-weight-bold gl-mr-3 gl-flex-grow-1" + :class="item.name ? 'gl-text-truncate' : 'gl-font-style-italic gl-gray-400'" + >{{ item.text }}</span + > + <span class="gl-ml-auto gl-white-space-nowrap" aria-hidden="true"> + <span class="gl-text-green-600">+{{ item.added }}</span> + <span class="gl-text-red-500">-{{ item.removed }}</span> + </span> + <span class="gl-sr-only" + >{{ additionsText(item.added) }}, {{ deletionsText(item.removed) }}</span + > + </div> + <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> + {{ item.path }} + </div> + </div> </div> - <div class="gl-text-gray-700 gl-overflow-hidden gl-text-overflow-ellipsis"> - {{ file.path }} - </div> - </gl-dropdown-item> - <gl-dropdown-text v-if="!filteredFiles.length"> - {{ $options.i18n.noFilesFound }} - </gl-dropdown-text> - </gl-dropdown> + </template> + </gl-disclosure-dropdown> </template> </gl-sprintf> <span @@ -140,12 +150,20 @@ export default { > <gl-sprintf :message="$options.i18n.messageAdditionsDeletions"> <template #additions> - <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText }}</span> + <span class="gl-text-green-600 gl-font-weight-bold">{{ additionsText() }}</span> </template> <template #deletions> - <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText }}</span> + <span class="gl-text-red-500 gl-font-weight-bold">{{ deletionsText() }}</span> </template> </gl-sprintf> </span> </div> </template> + +<style scoped> +/* TODO: Use max-height prop when gitlab-ui got updated. +See https://gitlab.com/gitlab-org/gitlab-ui/-/issues/2374 */ +::v-deep .gl-new-dropdown-inner { + max-height: 310px; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/constants.js b/app/assets/javascripts/vue_shared/components/entity_select/constants.js index 0fb5a2d5534..5bad907c9f9 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/constants.js +++ b/app/assets/javascripts/vue_shared/components/entity_select/constants.js @@ -14,3 +14,13 @@ export const PROJECT_TOGGLE_TEXT = s__('ProjectSelect|Search for project'); export const PROJECT_HEADER_TEXT = s__('ProjectSelect|Select a project'); export const FETCH_PROJECTS_ERROR = __('Unable to fetch projects. Reload the page to try again.'); export const FETCH_PROJECT_ERROR = __('Unable to fetch project. Reload the page to try again.'); + +// Organizations +export const ORGANIZATION_TOGGLE_TEXT = s__('Organization|Search for an organization'); +export const ORGANIZATION_HEADER_TEXT = s__('Organization|Select an organization'); +export const FETCH_ORGANIZATIONS_ERROR = s__( + 'Organization|Unable to fetch organizations. Reload the page to try again.', +); +export const FETCH_ORGANIZATION_ERROR = s__( + 'Organization|Unable to fetch organizations. Reload the page to try again.', +); diff --git a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue index 970c24c6e87..1a215454ab6 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/entity_select.vue @@ -22,6 +22,11 @@ export default { type: String, required: true, }, + description: { + type: String, + required: false, + default: '', + }, inputName: { type: String, required: true, @@ -31,7 +36,7 @@ export default { required: true, }, initialSelection: { - type: String, + type: [String, Number], required: false, default: null, }, @@ -57,6 +62,11 @@ export default { required: false, default: null, }, + toggleClass: { + type: [String, Array, Object], + required: false, + default: '', + }, }, data() { return { @@ -152,6 +162,7 @@ export default { this.searching = true; const name = await this.fetchInitialSelectionText(this.initialSelection); + this.selectedValue = this.initialSelection; this.selectedText = name; this.pristine = false; @@ -178,7 +189,7 @@ export default { </script> <template> - <gl-form-group :label="label"> + <gl-form-group :label="label" :description="description"> <slot name="error"></slot> <template v-if="Boolean($scopedSlots.label)" #label> <slot name="label"></slot> @@ -196,6 +207,7 @@ export default { :no-results-text="noResultsText" :infinite-scroll="hasMoreItems" :infinite-scroll-loading="infiniteScrollLoading" + :toggle-class="toggleClass" searchable @shown="onShown" @search="search" diff --git a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue index eb7b20fa4c1..8a338551fbe 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/group_select.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import axios from '~/lib/utils/axios_utils'; import { normalizeHeaders, parseIntPagination } from '~/lib/utils/common_utils'; import Api, { DEFAULT_PER_PAGE } from '~/api'; diff --git a/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue new file mode 100644 index 00000000000..d068d86d95b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/entity_select/organization_select.vue @@ -0,0 +1,150 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; +import getCurrentUserOrganizationsQuery from '~/organizations/index/graphql/organizations.query.graphql'; +import getOrganizationQuery from '~/organizations/shared/graphql/queries/organization.query.graphql'; +import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; +import { TYPENAME_ORGANIZATION } from '~/graphql_shared/constants'; +import { + ORGANIZATION_TOGGLE_TEXT, + ORGANIZATION_HEADER_TEXT, + FETCH_ORGANIZATIONS_ERROR, + FETCH_ORGANIZATION_ERROR, +} from './constants'; +import EntitySelect from './entity_select.vue'; + +export default { + name: 'OrganizationSelect', + components: { + GlAlert, + EntitySelect, + }, + props: { + block: { + type: Boolean, + required: false, + default: false, + }, + label: { + type: String, + required: false, + default: '', + }, + description: { + type: String, + required: false, + default: '', + }, + inputName: { + type: String, + required: true, + }, + inputId: { + type: String, + required: true, + }, + initialSelection: { + type: [String, Number], + required: false, + default: null, + }, + clearable: { + type: Boolean, + required: false, + default: false, + }, + toggleClass: { + type: [String, Array, Object], + required: false, + default: '', + }, + }, + data() { + return { + errorMessage: '', + }; + }, + methods: { + async fetchOrganizations() { + try { + const { + data: { + currentUser: { + organizations: { nodes }, + }, + }, + } = await this.$apollo.query({ + query: getCurrentUserOrganizationsQuery, + // TODO: implement search support - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. + }); + + return { + items: nodes.map((organization) => ({ + text: organization.name, + value: getIdFromGraphQLId(organization.id), + })), + // TODO: implement pagination - https://gitlab.com/gitlab-org/gitlab/-/issues/429999. + totalPages: 1, + }; + } catch (error) { + this.handleError({ message: FETCH_ORGANIZATIONS_ERROR, error }); + + return { items: [], totalPages: 0 }; + } + }, + async fetchOrganizationName(id) { + try { + const { + data: { + organization: { name }, + }, + } = await this.$apollo.query({ + query: getOrganizationQuery, + variables: { id: convertToGraphQLId(TYPENAME_ORGANIZATION, id) }, + }); + + return name; + } catch (error) { + this.handleError({ message: FETCH_ORGANIZATION_ERROR, error }); + + return ''; + } + }, + handleError({ message, error }) { + Sentry.captureException(error); + this.errorMessage = message; + }, + dismissError() { + this.errorMessage = ''; + }, + }, + i18n: { + toggleText: ORGANIZATION_TOGGLE_TEXT, + selectGroup: ORGANIZATION_HEADER_TEXT, + }, +}; +</script> + +<template> + <entity-select + :block="block" + :label="label" + :description="description" + :input-name="inputName" + :input-id="inputId" + :initial-selection="initialSelection" + :clearable="clearable" + :header-text="$options.i18n.selectGroup" + :default-toggle-text="$options.i18n.toggleText" + :fetch-items="fetchOrganizations" + :fetch-initial-selection-text="fetchOrganizationName" + :toggle-class="toggleClass" + v-on="$listeners" + > + <template #error> + <gl-alert v-if="errorMessage" class="gl-mb-3" variant="danger" @dismiss="dismissError">{{ + errorMessage + }}</gl-alert> + </template> + </entity-select> +</template> diff --git a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue index 13a825a68f6..8c371e3d4ce 100644 --- a/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue +++ b/app/assets/javascripts/vue_shared/components/entity_select/project_select.vue @@ -1,6 +1,6 @@ <script> import { GlAlert } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Api from '~/api'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue index 346384e3023..d39e4d2ee42 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue @@ -292,7 +292,9 @@ export default { this.recentSearchesService.save(resultantSearches); this.recentSearches = []; }, - handleFilterSubmit() { + async handleFilterSubmit() { + this.blurSearchInput(); + await this.$nextTick(); const filterTokens = uniqueTokens(this.filterValue); this.filterValue = filterTokens; @@ -309,7 +311,6 @@ export default { // https://gitlab.com/gitlab-org/gitlab-foss/issues/30821 }); } - this.blurSearchInput(); this.$emit('onFilter', this.removeQuotesEnclosure(filterTokens)); }, historyTokenOptionTitle(historyToken) { diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue index 23de8dd5596..3857dd9c55d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/base_token.vue @@ -7,9 +7,10 @@ import { GlDropdownText, GlLoadingIcon, } from '@gitlab/ui'; -import { debounce } from 'lodash'; +import { debounce, last } from 'lodash'; import { stripQuotes } from '~/lib/utils/text_utility'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { DEBOUNCE_DELAY, FILTERS_NONE_ANY, OPERATOR_NOT, OPERATOR_OR } from '../constants'; import { getRecentlyUsedSuggestions, setTokenValueToRecentlyUsed } from '../filtered_search_utils'; @@ -22,6 +23,7 @@ export default { GlDropdownText, GlLoadingIcon, }, + mixins: [glFeatureFlagMixin()], props: { config: { type: Object, @@ -70,6 +72,11 @@ export default { required: false, default: undefined, }, + multiSelectValues: { + type: Array, + required: false, + default: () => [], + }, }, data() { return { @@ -94,7 +101,11 @@ export default { return this.preloadedSuggestions.map((tokenValue) => tokenValue[this.valueIdentifier]); }, activeTokenValue() { - return this.getActiveTokenValue(this.suggestions, this.value.data); + const data = + this.glFeatures.groupMultiSelectTokens && Array.isArray(this.value.data) + ? last(this.value.data) + : this.value.data; + return this.getActiveTokenValue(this.suggestions, data); }, availableDefaultSuggestions() { if ([OPERATOR_NOT, OPERATOR_OR].includes(this.value.operator)) { @@ -146,10 +157,14 @@ export default { watch: { active: { immediate: true, - handler(newValue) { - if (!newValue && !this.suggestions.length) { - const search = this.searchTerm ? this.searchTerm : this.value.data; - this.$emit('fetch-suggestions', search); + handler(active) { + if (!active && !this.suggestions.length) { + // data could be a string or an array of strings + const selectedItems = [this.value.data].flat(); + selectedItems.forEach((item) => { + const search = this.searchTerm ? this.searchTerm : item; + this.$emit('fetch-suggestions', search); + }); } }, }, @@ -163,6 +178,9 @@ export default { }, methods: { handleInput: debounce(function debouncedSearch({ data, operator }) { + // in multiSelect mode, data could be an array + if (Array.isArray(data)) return; + // Prevent fetching suggestions when data or operator is not present if (data || operator) { this.searchKey = data; @@ -181,8 +199,11 @@ export default { } }, DEBOUNCE_DELAY), handleTokenValueSelected(selectedValue) { - const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); + if (this.glFeatures.groupMultiSelectTokens) { + this.$emit('token-selected', selectedValue); + } + const activeTokenValue = this.getActiveTokenValue(this.suggestions, selectedValue); // Make sure that; // 1. Recently used values feature is enabled // 2. User has actually selected a value @@ -210,6 +231,7 @@ export default { :config="config" :value="value" :active="active" + :multi-select-values="multiSelectValues" v-bind="$attrs" v-on="$listeners" @input="handleInput" diff --git a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue index 4601287b417..c5326ead60d 100644 --- a/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue +++ b/app/assets/javascripts/vue_shared/components/filtered_search_bar/tokens/user_token.vue @@ -1,11 +1,12 @@ <script> -import { GlAvatar, GlFilteredSearchSuggestion } from '@gitlab/ui'; +import { GlAvatar, GlIcon, GlIntersperse, GlFilteredSearchSuggestion } from '@gitlab/ui'; import { compact } from 'lodash'; import { createAlert } from '~/alert'; import { __ } from '~/locale'; import { WORKSPACE_GROUP, WORKSPACE_PROJECT } from '~/issues/constants'; import usersAutocompleteQuery from '~/graphql_shared/queries/users_autocomplete.query.graphql'; +import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import { OPTIONS_NONE_ANY } from '../constants'; import BaseToken from './base_token.vue'; @@ -14,8 +15,11 @@ export default { components: { BaseToken, GlAvatar, + GlIcon, + GlIntersperse, GlFilteredSearchSuggestion, }, + mixins: [glFeatureFlagMixin()], props: { config: { type: Object, @@ -32,8 +36,11 @@ export default { }, data() { return { + // current users visible in list users: this.config.initialUsers || [], + allUsers: this.config.initialUsers || [], loading: false, + selectedUsernames: [], }; }, computed: { @@ -49,13 +56,69 @@ export default { fetchUsersQuery() { return this.config.fetchUsers ? this.config.fetchUsers : this.fetchUsersBySearchTerm; }, + multiSelectEnabled() { + return this.config.multiSelect && this.glFeatures.groupMultiSelectTokens; + }, + }, + watch: { + value: { + deep: true, + immediate: true, + handler(newValue) { + const { data } = newValue; + + if (!this.multiSelectEnabled) { + return; + } + + // don't add empty values to selectedUsernames + if (!data) { + return; + } + + if (Array.isArray(data)) { + this.selectedUsernames = data; + // !active so we don't add strings while searching, e.g. r, ro, roo + // !includes so we don't add the same usernames (if @input is emitted twice) + } else if (!this.active && !this.selectedUsernames.includes(data)) { + this.selectedUsernames = this.selectedUsernames.concat(data); + } + }, + }, }, methods: { getActiveUser(users, data) { return users.find((user) => user.username.toLowerCase() === data.toLowerCase()); }, getAvatarUrl(user) { - return user.avatarUrl || user.avatar_url; + return user?.avatarUrl || user?.avatar_url; + }, + displayNameFor(username) { + return this.getActiveUser(this.allUsers, username)?.name || `@${username}`; + }, + avatarFor(username) { + const user = this.getActiveUser(this.allUsers, username); + return this.getAvatarUrl(user); + }, + addCheckIcon(username) { + return this.multiSelectEnabled && this.selectedUsernames.includes(username); + }, + addPadding(username) { + return this.multiSelectEnabled && !this.selectedUsernames.includes(username); + }, + handleSelected(username) { + if (!this.multiSelectEnabled) { + return; + } + + const index = this.selectedUsernames.indexOf(username); + if (index > -1) { + this.selectedUsernames.splice(index, 1); + } else { + this.selectedUsernames.push(username); + } + + this.$emit('input', { ...this.value, data: '' }); }, fetchUsersBySearchTerm(search) { return this.$apollo @@ -79,6 +142,7 @@ export default { // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 this.users = Array.isArray(res) ? compact(res) : compact(res.data); + this.allUsers = this.allUsers.concat(this.users); }) .catch(() => createAlert({ @@ -103,18 +167,32 @@ export default { :get-active-token-value="getActiveUser" :default-suggestions="defaultUsers" :preloaded-suggestions="preloadedUsers" + :multi-select-values="selectedUsernames" v-bind="$attrs" @fetch-suggestions="fetchUsers" + @token-selected="handleSelected" v-on="$listeners" > <template #view="{ viewTokenProps: { inputValue, activeTokenValue } }"> - <gl-avatar - v-if="activeTokenValue" - :size="16" - :src="getAvatarUrl(activeTokenValue)" - class="gl-mr-2" - /> - {{ activeTokenValue ? activeTokenValue.name : inputValue }} + <gl-intersperse v-if="multiSelectEnabled" separator=","> + <span + v-for="(username, index) in selectedUsernames" + :key="username" + :class="{ 'gl-ml-2': index > 0 }" + ><gl-avatar :size="16" :src="avatarFor(username)" class="gl-mr-1" />{{ + displayNameFor(username) + }}</span + > + </gl-intersperse> + <template v-else> + <gl-avatar + v-if="activeTokenValue" + :size="16" + :src="getAvatarUrl(activeTokenValue)" + class="gl-mr-2" + /> + {{ activeTokenValue ? activeTokenValue.name : inputValue }} + </template> </template> <template #suggestions-list="{ suggestions }"> <gl-filtered-search-suggestion @@ -122,7 +200,15 @@ export default { :key="user.username" :value="user.username" > - <div class="gl-display-flex"> + <div + class="gl-display-flex gl-align-items-center" + :class="{ 'gl-pl-6': addPadding(user.username) }" + > + <gl-icon + v-if="addCheckIcon(user.username)" + name="check" + class="gl-mr-3 gl-text-secondary gl-flex-shrink-0" + /> <gl-avatar :size="32" :src="getAvatarUrl(user)" /> <div> <div>{{ user.name }}</div> diff --git a/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js b/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js new file mode 100644 index 00000000000..7c32e38a299 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/errors_alert.stories.js @@ -0,0 +1,21 @@ +import ErrorsAlert from './errors_alert.vue'; + +export default { + component: ErrorsAlert, + title: 'vue_shared/form/errors_alert', +}; + +const defaultProps = { + errors: ['Name must be at least 5 characters.', 'Name cannot contain special characters.'], +}; + +const Template = (args) => ({ + components: { ErrorsAlert }, + data() { + return { errors: args.errors }; + }, + template: `<errors-alert v-model="errors" />`, +}); + +export const Default = Template.bind({}); +Default.args = defaultProps; diff --git a/app/assets/javascripts/vue_shared/components/form/errors_alert.vue b/app/assets/javascripts/vue_shared/components/form/errors_alert.vue new file mode 100644 index 00000000000..3e33168781b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/form/errors_alert.vue @@ -0,0 +1,42 @@ +<script> +import { GlAlert } from '@gitlab/ui'; +import { n__ } from '~/locale'; + +export default { + components: { GlAlert }, + model: { + prop: 'errors', + }, + props: { + errors: { + type: Array, + required: true, + }, + }, + computed: { + title() { + return n__( + 'The form contains the following error:', + 'The form contains the following errors:', + this.errors.length, + ); + }, + }, +}; +</script> + +<template> + <gl-alert + v-if="errors.length" + class="gl-mb-5" + :title="title" + variant="danger" + @dismiss="$emit('input', [])" + > + <ul class="gl-pl-5 gl-mb-0"> + <li v-for="error in errors" :key="error"> + {{ error }} + </li> + </ul> + </gl-alert> +</template> diff --git a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue index d97f1ae6135..0455685627d 100644 --- a/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue +++ b/app/assets/javascripts/vue_shared/components/form/input_copy_toggle_visibility.vue @@ -94,8 +94,12 @@ export default { computedValueIsVisible() { return !this.showToggleVisibilityButton || this.valueIsVisible; }, - inputType() { - return this.computedValueIsVisible ? 'text' : 'password'; + formInputClass() { + return [ + 'gl-font-monospace! gl-cursor-default!', + { 'input-copy-show-disc': !this.computedValueIsVisible }, + this.formInputGroupProps.class, + ]; }, }, mounted() { @@ -157,10 +161,9 @@ export default { ref="input" :readonly="readonly" :width="size" - class="gl-font-monospace! gl-cursor-default!" + :class="formInputClass" v-bind="formInputGroupProps" :value="value" - :type="inputType" @input="handleInput" @click="handleClick" /> @@ -194,3 +197,8 @@ export default { </template> </gl-form-group> </template> +<style> +.input-copy-show-disc { + -webkit-text-security: disc; +} +</style> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/constants.js b/app/assets/javascripts/vue_shared/components/list_selector/constants.js new file mode 100644 index 00000000000..cff9c56a1c0 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/constants.js @@ -0,0 +1,6 @@ +import { __ } from '~/locale'; + +export const CONFIG = { + users: { title: __('Users'), icon: 'user', filterKey: 'username', showNamespaceDropdown: true }, + groups: { title: __('Groups'), icon: 'group', filterKey: 'name' }, +}; diff --git a/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue new file mode 100644 index 00000000000..2d24cc5553b --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/group_item.vue @@ -0,0 +1,55 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + name: 'GroupItem', + components: { + GlAvatar, + GlButton, + }, + props: { + data: { + type: Object, + required: true, + }, + canDelete: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + deleteButtonLabel() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, + fullName() { + return this.data.fullName; + }, + name() { + return this.data.name; + }, + avatarUrl() { + return this.data.avatarUrl; + }, + }, +}; +</script> + +<template> + <span class="gl-display-flex gl-align-items-center gl-gap-3" @click="$emit('select', name)"> + <gl-avatar :alt="fullName" :size="32" :src="avatarUrl" /> + <span class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <span class="gl-font-weight-bold">{{ fullName }}</span> + <span class="gl-text-gray-600">@{{ name }}</span> + </span> + + <gl-button + v-if="canDelete" + icon="remove" + :aria-label="deleteButtonLabel" + category="tertiary" + @click="$emit('delete', name)" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/index.vue b/app/assets/javascripts/vue_shared/components/list_selector/index.vue new file mode 100644 index 00000000000..b8480a0c496 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/index.vue @@ -0,0 +1,193 @@ +<script> +import { GlCard, GlIcon, GlCollapsibleListbox, GlSearchBoxByType } from '@gitlab/ui'; +import { parseBoolean } from '~/lib/utils/common_utils'; +import { createAlert } from '~/alert'; +import { __ } from '~/locale'; +import groupsAutocompleteQuery from '~/graphql_shared/queries/groups_autocomplete.query.graphql'; +import Api from '~/api'; +import UserItem from './user_item.vue'; +import GroupItem from './group_item.vue'; +import { CONFIG } from './constants'; + +const I18N = { + allGroups: __('All groups'), + projectGroups: __('Project groups'), + apiErrorMessage: __('An error occurred while fetching. Please try again.'), +}; + +export default { + name: 'ListSelector', + i18n: I18N, + components: { + GlCard, + GlIcon, + GlSearchBoxByType, + GlCollapsibleListbox, + }, + props: { + title: { + type: String, + required: true, + }, + type: { + type: String, + required: true, + }, + selectedItems: { + type: Array, + required: false, + default: () => [], + }, + projectPath: { + type: String, + required: false, + default: null, + }, + groupPath: { + type: String, + required: false, + default: null, + }, + }, + data() { + return { + searchValue: '', + isProjectNamespace: 'true', + selected: [], + items: [], + }; + }, + computed: { + config() { + return CONFIG[this.type]; + }, + isUserVariant() { + return this.type === 'users'; + }, + component() { + return this.isUserVariant ? UserItem : GroupItem; + }, + namespaceDropdownText() { + return parseBoolean(this.isProjectNamespace) + ? this.$options.i18n.projectGroups + : this.$options.i18n.allGroups; + }, + }, + methods: { + async handleSearchInput(search) { + this.$refs.results.open(); + + try { + if (this.isUserVariant) { + this.items = await this.fetchUsersBySearchTerm(search); + } else { + this.items = await this.fetchGroupsBySearchTerm(search); + } + } catch (e) { + createAlert({ + message: this.$options.i18n.apiErrorMessage, + }); + } + }, + async fetchUsersBySearchTerm(search) { + let users = []; + if (parseBoolean(this.isProjectNamespace)) { + users = await Api.projectUsers(this.projectPath, search); + } else { + const groupMembers = await Api.groupMembers(this.groupPath, { query: search }); + users = groupMembers?.data || []; + } + + return users?.map((user) => ({ text: user.name, value: user.username, ...user })); + }, + fetchGroupsBySearchTerm(search) { + return this.$apollo + .query({ + query: groupsAutocompleteQuery, + variables: { search }, + }) + .then(({ data }) => + data?.groups.nodes.map((group) => ({ + text: group.fullName, + value: group.name, + ...group, + })), + ); + }, + getItemByKey(key) { + return this.items.find((item) => item[this.config.filterKey] === key); + }, + handleSelectItem(key) { + this.$emit('select', this.getItemByKey(key)); + }, + handleDeleteItem(key) { + this.$emit('delete', key); + }, + handleSelectNamespace() { + this.items = []; + this.searchValue = ''; + }, + }, + namespaceOptions: [ + { text: I18N.projectGroups, value: 'true' }, + { text: I18N.allGroups, value: 'false' }, + ], +}; +</script> + +<template> + <gl-card header-class="gl-new-card-header gl-border-none" body-class="gl-card-footer"> + <template #header + ><strong data-testid="list-selector-title" + >{{ title }} + <span class="gl-text-gray-700 gl-ml-3" + ><gl-icon :name="config.icon" /> {{ selectedItems.length }}</span + ></strong + ></template + > + + <div class="gl-display-flex gl-gap-3" :class="{ 'gl-mb-4': selectedItems.length }"> + <gl-collapsible-listbox + ref="results" + v-model="selected" + class="list-selector gl-display-block gl-flex-grow-1" + :items="items" + multiple + @shown="$refs.search.focusInput()" + > + <template #toggle> + <gl-search-box-by-type + ref="search" + v-model="searchValue" + autofocus + debounce="500" + @input="handleSearchInput" + /> + </template> + + <template #list-item="{ item }"> + <component :is="component" :data="item" @select="handleSelectItem" /> + </template> + </gl-collapsible-listbox> + + <gl-collapsible-listbox + v-if="config.showNamespaceDropdown" + v-model="isProjectNamespace" + :toggle-text="namespaceDropdownText" + :items="$options.namespaceOptions" + @select="handleSelectNamespace" + /> + </div> + + <component + :is="component" + v-for="(item, index) of selectedItems" + :key="index" + :class="{ 'gl-border-t': index > 0 }" + class="gl-p-3" + :data="item" + can-delete + @delete="handleDeleteItem" + /> + </gl-card> +</template> diff --git a/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue b/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue new file mode 100644 index 00000000000..fdbc767db81 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/list_selector/user_item.vue @@ -0,0 +1,55 @@ +<script> +import { GlAvatar, GlButton } from '@gitlab/ui'; +import { sprintf, __ } from '~/locale'; + +export default { + name: 'UserItem', + components: { + GlAvatar, + GlButton, + }, + props: { + data: { + type: Object, + required: true, + }, + canDelete: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + deleteButtonLabel() { + return sprintf(__('Delete %{name}'), { name: this.name }); + }, + name() { + return this.data.name; + }, + username() { + return this.data.username; + }, + avatarUrl() { + return this.data.avatarUrl; + }, + }, +}; +</script> + +<template> + <span class="gl-display-flex gl-align-items-center gl-gap-3" @click="$emit('select', username)"> + <gl-avatar :alt="name" :size="32" :src="avatarUrl" /> + <span class="gl-display-flex gl-flex-direction-column gl-flex-grow-1"> + <span class="gl-font-weight-bold">{{ name }}</span> + <span class="gl-text-gray-600">@{{ username }}</span> + </span> + + <gl-button + v-if="canDelete" + icon="remove" + :aria-label="deleteButtonLabel" + category="tertiary" + @click="$emit('delete', username)" + /> + </span> +</template> diff --git a/app/assets/javascripts/vue_shared/components/markdown/header.vue b/app/assets/javascripts/vue_shared/components/markdown/header.vue index 741bdfd211b..cc3c95a047b 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/header.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/header.vue @@ -492,7 +492,7 @@ export default { tracking-property="quickAction" /> <comment-templates-dropdown - v-if="!previewMarkdown && newCommentTemplatePath && glFeatures.savedReplies" + v-if="!previewMarkdown && newCommentTemplatePath" :new-comment-template-path="newCommentTemplatePath" @select="insertSavedReply" /> diff --git a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue index 4a3c3cf0053..73c030b23dc 100644 --- a/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue +++ b/app/assets/javascripts/vue_shared/components/markdown/markdown_editor.vue @@ -190,7 +190,7 @@ export default { renderMarkdown(markdown) { const url = setUrlParams( { render_quick_actions: this.supportsQuickActions }, - joinPaths(gon.relative_url_root || window.location.origin, this.renderMarkdownPath), + joinPaths(window.location.origin, gon.relative_url_root, this.renderMarkdownPath), ); return axios.post(url, { text: markdown }).then(({ data }) => data.body); }, diff --git a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js index 27237f2f16b..6d74c1d083a 100644 --- a/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js +++ b/app/assets/javascripts/vue_shared/components/markdown_drawer/utils/fetch.js @@ -1,4 +1,4 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { helpPagePath } from '~/helpers/help_page_helper'; import axios from '~/lib/utils/axios_utils'; diff --git a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue index 0ec8b6e2a0a..3bee539688b 100644 --- a/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue +++ b/app/assets/javascripts/vue_shared/components/notes/noteable_warning.vue @@ -64,7 +64,7 @@ export default { }); }, lockedContextText() { - return sprintf(__('This %{noteableTypeText} is locked.'), { + return sprintf(__('The discussion in this %{noteableTypeText} is locked.'), { noteableTypeText: this.noteableTypeText, }); }, @@ -80,7 +80,7 @@ export default { <gl-sprintf :message=" __( - 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and %{lockedLinkStart}locked%{lockedLinkEnd}.', + 'This %{noteableTypeText} is %{confidentialLinkStart}confidential%{confidentialLinkEnd} and its %{lockedLinkStart}discussion is locked%{lockedLinkEnd}.', ) " > diff --git a/app/assets/javascripts/vue_shared/components/notes/system_note.vue b/app/assets/javascripts/vue_shared/components/notes/system_note.vue index 81cbbf951ad..6a5884e4857 100644 --- a/app/assets/javascripts/vue_shared/components/notes/system_note.vue +++ b/app/assets/javascripts/vue_shared/components/notes/system_note.vue @@ -30,12 +30,10 @@ import { renderGFM } from '~/behaviors/markdown/render_gfm'; import TimelineEntryItem from './timeline_entry_item.vue'; const MAX_VISIBLE_COMMIT_LIST_COUNT = 3; -const MR_ICON_COLORS = { +const ICON_COLORS = { check: 'gl-bg-green-100 gl-text-green-700', 'merge-request-close': 'gl-bg-red-100 gl-text-red-700', merge: 'gl-bg-blue-100 gl-text-blue-700', -}; -const ICON_COLORS = { 'issue-close': 'gl-bg-blue-100 gl-text-blue-700', }; @@ -76,6 +74,9 @@ export default { noteAnchorId() { return `note_${this.note.id}`; }, + isAllowedIcon() { + return Object.keys(ICON_COLORS).includes(this.note.system_note_icon_name); + }, isTargetNote() { return this.targetNoteHash === this.noteAnchorId; }, @@ -95,15 +96,8 @@ export default { isMergeRequest() { return this.getNoteableData.noteableType === 'MergeRequest'; }, - hasIconColors() { - if (!this.isMergeRequest) return true; - - return this.isMergeRequest && MR_ICON_COLORS[this.note.system_note_icon_name]; - }, iconBgClass() { - const colors = this.isMergeRequest ? MR_ICON_COLORS : ICON_COLORS; - - return colors[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; + return ICON_COLORS[this.note.system_note_icon_name] || 'gl-bg-gray-50 gl-text-gray-600'; }, }, mounted() { @@ -140,17 +134,16 @@ export default { :class="[ iconBgClass, { - 'mr-system-note-empty gl-bg-gray-900!': !hasIconColors, - 'gl-w-6 gl-h-6 gl-mt-n1 gl-ml-2': !isMergeRequest, - 'mr-system-note-icon': isMergeRequest, + 'system-note-icon': isAllowedIcon, + 'system-note-tiny-dot gl-bg-gray-900!': !isAllowedIcon, }, ]" class="gl-float-left gl--flex-center gl-rounded-full gl-relative timeline-icon" > <gl-icon - v-if="note.system_note_icon_name && hasIconColors" + v-if="isAllowedIcon" :name="note.system_note_icon_name" - :size="isMergeRequest ? 12 : 16" + :size="12" data-testid="timeline-icon" /> </div> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue index 9bce9402afa..e2fd4477f0a 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/blame_info.vue @@ -2,7 +2,6 @@ import { GlTooltipDirective } from '@gitlab/ui'; import SafeHtml from '~/vue_shared/directives/safe_html'; import CommitInfo from '~/repository/components/commit_info.vue'; -import { calculateBlameOffset, toggleBlameClasses } from '../utils'; export default { name: 'BlameInfo', @@ -14,25 +13,11 @@ export default { SafeHtml, }, props: { - blameData: { + blameInfo: { type: Array, required: true, }, }, - computed: { - blameInfo() { - return this.blameData.map((blame, index) => ({ - ...blame, - blameOffset: calculateBlameOffset(blame.lineno, index), - })); - }, - }, - mounted() { - toggleBlameClasses(this.blameData, true); - }, - destroyed() { - toggleBlameClasses(this.blameData, false); - }, }; </script> <template> @@ -41,10 +26,11 @@ export default { <commit-info v-for="(blame, index) in blameInfo" :key="index" - :class="{ 'gl-border-t': index !== 0 }" + :class="{ 'gl-border-t': blame.blameOffset !== '0px' }" class="gl-display-flex gl-absolute gl-px-3" :style="{ top: blame.blameOffset }" :commit="blame.commit" + :prev-blame-link="blame.commitData && blame.commitData.projectBlameLink" /> </div> </div> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue index 8dac6327a99..3b6dcace8fe 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/components/chunk_new.vue @@ -56,7 +56,6 @@ export default { data() { return { hasAppeared: false, - isLoading: true, }; }, computed: { @@ -68,17 +67,6 @@ export default { return getPageSearchString(this.blamePath, page); }, }, - created() { - if (this.chunkIndex === 0) { - // Display first chunk ASAP in order to improve perceived performance - this.isLoading = false; - return; - } - - window.requestIdleCallback(() => { - this.isLoading = false; - }); - }, methods: { handleChunkAppear() { this.hasAppeared = true; @@ -91,37 +79,37 @@ export default { }; </script> <template> - <gl-intersection-observer @appear="handleChunkAppear"> - <div class="gl-display-flex"> - <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> - <div - v-for="(n, index) in totalLines" - :key="index" - data-testid="line-numbers" - class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + <div class="gl-display-flex"> + <div v-if="shouldHighlight" class="gl-display-flex gl-flex-direction-column"> + <div + v-for="(n, index) in totalLines" + :key="index" + data-testid="line-numbers" + class="gl-p-0! gl-z-index-3 diff-line-num gl-border-r gl-display-flex line-links line-numbers" + > + <a + class="gl-user-select-none gl-shadow-none! file-line-blame" + :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" + ></a> + <a + :id="`L${calculateLineNumber(index)}`" + class="gl-user-select-none gl-shadow-none! file-line-num" + :href="`#L${calculateLineNumber(index)}`" + :data-line-number="calculateLineNumber(index)" > - <a - class="gl-user-select-none gl-shadow-none! file-line-blame" - :href="`${blamePath}${pageSearchString}#L${calculateLineNumber(index)}`" - ></a> - <a - :id="`L${calculateLineNumber(index)}`" - class="gl-user-select-none gl-shadow-none! file-line-num" - :href="`#L${calculateLineNumber(index)}`" - :data-line-number="calculateLineNumber(index)" - > - {{ calculateLineNumber(index) }} - </a> - </div> + {{ calculateLineNumber(index) }} + </a> </div> + </div> - <div v-else-if="!isLoading" class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> - <!-- Placeholder for line numbers while content is not highlighted --> - </div> + <div v-else class="line-numbers gl-p-0! gl-mr-3 gl-text-transparent"> + <!-- Placeholder for line numbers while content is not highlighted --> + </div> + <gl-intersection-observer class="gl-w-full" @appear="handleChunkAppear"> <pre class="gl-m-0 gl-p-0! gl-w-full gl-overflow-visible! gl-border-none! code highlight gl-line-height-0" - ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else-if="!isLoading" v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> - </div> - </gl-intersection-observer> + ><code v-if="shouldHighlight" v-safe-html="highlightedContent" data-testid="content"></code><code v-else v-once class="line gl-white-space-pre-wrap! gl-ml-1" data-testid="content" v-text="rawContent"></code></pre> + </gl-intersection-observer> + </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql new file mode 100644 index 00000000000..a5f3f348cfc --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/source_viewer/queries/blame_data.query.graphql @@ -0,0 +1,36 @@ +#import "~/graphql_shared/fragments/author.fragment.graphql" + +query getBlameData($fullPath: ID!, $filePath: String!, $fromLine: Int, $toLine: Int) { + project(fullPath: $fullPath) { + id + repository { + blobs(paths: [$filePath]) { + nodes { + id + blame(fromLine: $fromLine, toLine: $toLine) { + firstLine + groups { + lineno + span + commit { + id + titleHtml + message + authoredDate + authorGravatar + webPath + author { + ...Author + } + sha + } + commitData { + projectBlameLink + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue index c7353ed6785..dcefa66c403 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue +++ b/app/assets/javascripts/vue_shared/components/source_viewer/source_viewer_new.vue @@ -1,10 +1,15 @@ <script> +import { debounce } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SafeHtml from '~/vue_shared/directives/safe_html'; import Tracking from '~/tracking'; import addBlobLinksTracking from '~/blob/blob_links_tracking'; import LineHighlighter from '~/blob/line_highlighter'; import { EVENT_ACTION, EVENT_LABEL_VIEWER } from './constants'; import Chunk from './components/chunk_new.vue'; +import Blame from './components/blame_info.vue'; +import { calculateBlameOffset, shouldRender, toggleBlameClasses } from './utils'; +import blameDataQuery from './queries/blame_data.query.graphql'; /* * Note, this is a new experimental version of the SourceViewer, it is not ready for production use. @@ -15,6 +20,7 @@ export default { name: 'SourceViewerNew', components: { Chunk, + Blame, }, directives: { SafeHtml, @@ -30,13 +36,55 @@ export default { required: false, default: () => [], }, + showBlame: { + type: Boolean, + required: false, + default: false, + }, + projectPath: { + type: String, + required: true, + }, }, data() { return { lineHighlighter: new LineHighlighter(), + blameData: [], + renderedChunks: [], }; }, + computed: { + blameInfo() { + return this.blameData.reduce((result, blame, index) => { + if (shouldRender(this.blameData, index)) { + result.push({ + ...blame, + blameOffset: calculateBlameOffset(blame.lineno, index), + }); + } + + return result; + }, []); + }, + }, + watch: { + showBlame: { + handler(shouldShow) { + toggleBlameClasses(this.blameData, shouldShow); + this.requestBlameInfo(this.renderedChunks[0]); + }, + immediate: true, + }, + blameData: { + handler(blameData) { + if (!this.showBlame) return; + toggleBlameClasses(blameData, true); + }, + immediate: true, + }, + }, created() { + this.handleAppear = debounce(this.handleChunkAppear, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); this.track(EVENT_ACTION, { label: EVENT_LABEL_VIEWER, property: this.blob.language }); addBlobLinksTracking(); }, @@ -44,10 +92,39 @@ export default { this.selectLine(); }, methods: { + async handleChunkAppear(chunkIndex, handleOverlappingChunk = true) { + if (!this.renderedChunks.includes(chunkIndex)) { + this.renderedChunks.push(chunkIndex); + await this.requestBlameInfo(chunkIndex); + + if (chunkIndex > 0 && handleOverlappingChunk) { + // request the blame information for overlapping chunk incase it is visible in the DOM + this.handleChunkAppear(chunkIndex - 1, false); + } + } + }, + async requestBlameInfo(chunkIndex) { + const chunk = this.chunks[chunkIndex]; + if (!this.showBlame || !chunk) return; + + const { data } = await this.$apollo.query({ + query: blameDataQuery, + variables: { + fullPath: this.projectPath, + filePath: this.blob.path, + fromLine: chunk.startingFrom + 1, + toLine: chunk.startingFrom + chunk.totalLines, + }, + }); + + const blob = data?.project?.repository?.blobs?.nodes[0]; + const blameGroups = blob?.blame?.groups; + const isDuplicate = this.blameData.includes(blameGroups[0]); + if (blameGroups && !isDuplicate) this.blameData.push(...blameGroups); + }, async selectLine() { await this.$nextTick(); - const scrollEnabled = false; - this.lineHighlighter.highlightHash(this.$route.hash, scrollEnabled); + this.lineHighlighter.highlightHash(this.$route.hash); }, }, userColorScheme: window.gon.user_color_scheme, @@ -55,24 +132,27 @@ export default { </script> <template> - <div - class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto" - :class="$options.userColorScheme" - data-type="simple" - :data-path="blob.path" - data-qa-selector="blob_viewer_file_content" - > - <chunk - v-for="(chunk, _, index) in chunks" - :key="index" - :chunk-index="index" - :is-highlighted="Boolean(chunk.isHighlighted)" - :raw-content="chunk.rawContent" - :highlighted-content="chunk.highlightedContent" - :total-lines="chunk.totalLines" - :starting-from="chunk.startingFrom" - :blame-path="blob.blamePath" - @appear="selectLine" - /> + <div class="gl-display-flex"> + <blame v-if="showBlame && blameInfo.length" :blame-info="blameInfo" /> + + <div + class="file-content code js-syntax-highlight blob-content gl-display-flex gl-flex-direction-column gl-overflow-auto gl-w-full" + :class="$options.userColorScheme" + data-type="simple" + :data-path="blob.path" + > + <chunk + v-for="(chunk, index) in chunks" + :key="index" + :chunk-index="index" + :is-highlighted="Boolean(chunk.isHighlighted)" + :raw-content="chunk.rawContent" + :highlighted-content="chunk.highlightedContent" + :total-lines="chunk.totalLines" + :starting-from="chunk.startingFrom" + :blame-path="blob.blamePath" + @appear="() => handleAppear(index)" + /> + </div> </div> </template> diff --git a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js index af01653fc0d..596829b51a4 100644 --- a/app/assets/javascripts/vue_shared/components/source_viewer/utils.js +++ b/app/assets/javascripts/vue_shared/components/source_viewer/utils.js @@ -1,6 +1,7 @@ const BLAME_INFO_CLASSLIST = ['gl-border-t', 'gl-border-gray-500', 'gl-pt-3!']; const PADDING_BOTTOM_LARGE = 'gl-pb-6!'; const PADDING_BOTTOM_SMALL = 'gl-pb-3!'; +const VIEWER_SELECTOR = '.file-holder .blob-viewer'; const findLineNumberElement = (lineNumber) => document.getElementById(`L${lineNumber}`); @@ -8,8 +9,18 @@ const findLineContentElement = (lineNumber) => document.getElementById(`LC${line export const calculateBlameOffset = (lineNumber) => { if (lineNumber === 1) return '0px'; - const lineContentOffset = findLineContentElement(lineNumber)?.offsetTop; - return `${lineContentOffset}px`; + const blobViewerOffset = document.querySelector(VIEWER_SELECTOR)?.getBoundingClientRect().top; + const lineContentOffset = findLineContentElement(lineNumber)?.getBoundingClientRect().top; + return `${lineContentOffset - blobViewerOffset}px`; +}; + +export const shouldRender = (data, index) => { + const prevBlame = data[index - 1]; + const currBlame = data[index]; + const identicalSha = currBlame.commit.sha === prevBlame?.commit?.sha; + const lineNumberSmaller = currBlame.lineno < prevBlame?.lineno; + + return !identicalSha || lineNumberSmaller; }; export const toggleBlameClasses = (blameData, isVisible) => { @@ -17,7 +28,9 @@ export const toggleBlameClasses = (blameData, isVisible) => { * Adds/removes classes to line number/content elements to match the line with the blame info * */ const method = isVisible ? 'add' : 'remove'; - blameData.forEach(({ lineno, span }) => { + blameData.forEach(({ lineno, span }, index) => { + if (!shouldRender(blameData, index)) return; + const lineNumberEl = findLineNumberElement(lineno)?.parentElement; const lineContentEl = findLineContentElement(lineno); const lineNumberSpanEl = findLineNumberElement(lineno + span - 1)?.parentElement; diff --git a/app/assets/javascripts/vue_shared/components/toggle_labels.vue b/app/assets/javascripts/vue_shared/components/toggle_labels.vue index 05c837e32f0..db20e1288aa 100644 --- a/app/assets/javascripts/vue_shared/components/toggle_labels.vue +++ b/app/assets/javascripts/vue_shared/components/toggle_labels.vue @@ -54,7 +54,6 @@ export default { label-position="left" aria-describedby="board-labels-toggle-text" data-testid="show-labels-toggle" - data-qa-selector="show_labels_toggle" class="gl-flex-direction-row" @change="setShowLabels" /> diff --git a/app/assets/javascripts/vue_shared/components/users_table/constants.js b/app/assets/javascripts/vue_shared/components/users_table/constants.js new file mode 100644 index 00000000000..2a063a1be33 --- /dev/null +++ b/app/assets/javascripts/vue_shared/components/users_table/constants.js @@ -0,0 +1,3 @@ +export const USER_AVATAR_SIZE = 32; + +export const LENGTH_OF_USER_NOTE_TOOLTIP = 100; diff --git a/app/assets/javascripts/admin/users/components/user_avatar.vue b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue index dd354794cf3..5d86f90880d 100644 --- a/app/assets/javascripts/admin/users/components/user_avatar.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/user_avatar.vue @@ -1,7 +1,7 @@ <script> import { GlAvatarLabeled, GlBadge, GlIcon, GlTooltipDirective } from '@gitlab/ui'; import { truncate } from '~/lib/utils/text_utility'; -import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from '../constants'; +import { USER_AVATAR_SIZE, LENGTH_OF_USER_NOTE_TOOLTIP } from './constants'; export default { directives: { @@ -23,12 +23,21 @@ export default { }, }, computed: { + subLabel() { + if (this.user.email) { + return { + label: this.user.email, + link: `mailto:${this.user.email}`, + }; + } + + return { + label: `@${this.user.username}`, + }; + }, adminUserHref() { return this.adminUserPath.replace('id', this.user.username); }, - adminUserMailto() { - return `mailto:${this.user.email}`; - }, userNoteShort() { return truncate(this.user.note, LENGTH_OF_USER_NOTE_TOOLTIP); }, @@ -48,9 +57,9 @@ export default { :size="$options.USER_AVATAR_SIZE" :src="user.avatarUrl" :label="user.name" - :sub-label="user.email" + :sub-label="subLabel.label" :label-link="adminUserHref" - :sub-label-link="adminUserMailto" + :sub-label-link="subLabel.link" > <template #meta> <div v-if="user.note" class="gl-text-gray-500 gl-p-1"> diff --git a/app/assets/javascripts/admin/users/components/users_table.vue b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue index 65737be1e67..be164bb07a3 100644 --- a/app/assets/javascripts/admin/users/components/users_table.vue +++ b/app/assets/javascripts/vue_shared/components/users_table/users_table.vue @@ -1,12 +1,8 @@ <script> import { GlSkeletonLoader, GlTable } from '@gitlab/ui'; -import { createAlert } from '~/alert'; -import { convertNodeIdsFromGraphQLIds } from '~/graphql_shared/utils'; import { thWidthPercent } from '~/lib/utils/table_utility'; -import { s__, __ } from '~/locale'; +import { __ } from '~/locale'; import UserDate from '~/vue_shared/components/user_date.vue'; -import getUsersGroupCountsQuery from '../graphql/queries/get_users_group_counts.query.graphql'; -import UserActions from './user_actions.vue'; import UserAvatar from './user_avatar.vue'; export default { @@ -14,7 +10,6 @@ export default { GlSkeletonLoader, GlTable, UserAvatar, - UserActions, UserDate, }, props: { @@ -22,49 +17,20 @@ export default { type: Array, required: true, }, - paths: { - type: Object, + adminUserPath: { + type: String, required: true, }, - }, - data() { - return { - groupCounts: [], - }; - }, - apollo: { groupCounts: { - query: getUsersGroupCountsQuery, - variables() { - return { - usernames: this.users.map((user) => user.username), - }; - }, - update(data) { - const nodes = data?.users?.nodes || []; - const parsedIds = convertNodeIdsFromGraphQLIds(nodes); - - return parsedIds.reduce((acc, { id, groupCount }) => { - acc[id] = groupCount || 0; - return acc; - }, {}); - }, - error(error) { - createAlert({ - message: this.$options.i18n.groupCountFetchError, - captureError: true, - error, - }); - }, - skip() { - return !this.users.length; - }, + type: Object, + required: false, + default: () => ({}), + }, + groupCountsLoading: { + type: Boolean, + required: false, + default: false, }, - }, - i18n: { - groupCountFetchError: s__( - 'AdminUsers|Could not load user group counts. Please refresh the page to try again.', - ), }, fields: [ { @@ -112,7 +78,7 @@ export default { :tbody-tr-attr="{ 'data-testid': 'user-row-content' }" > <template #cell(name)="{ item: user }"> - <user-avatar :user="user" :admin-user-path="paths.adminUser" /> + <user-avatar :user="user" :admin-user-path="adminUserPath" /> </template> <template #cell(createdAt)="{ item: { createdAt } }"> @@ -125,17 +91,19 @@ export default { <template #cell(groupCount)="{ item: { id } }"> <div :data-testid="`user-group-count-${id}`"> - <gl-skeleton-loader v-if="$apollo.loading" :width="40" :lines="1" /> - <span v-else>{{ groupCounts[id] }}</span> + <gl-skeleton-loader v-if="groupCountsLoading" :width="40" :lines="1" /> + <span v-else>{{ groupCounts[id] || 0 }}</span> </div> </template> <template #cell(projectsCount)="{ item: { id, projectsCount } }"> - <div :data-testid="`user-project-count-${id}`">{{ projectsCount }}</div> + <div :data-testid="`user-project-count-${id}`"> + {{ projectsCount || 0 }} + </div> </template> <template #cell(settings)="{ item: user }"> - <user-actions :user="user" :paths="paths" :show-button-labels="true" /> + <slot name="user-actions" :user="user"></slot> </template> </gl-table> </div> diff --git a/app/assets/javascripts/vue_shared/components/web_ide_link.vue b/app/assets/javascripts/vue_shared/components/web_ide_link.vue index 9fb0add5522..441b4c31b3a 100644 --- a/app/assets/javascripts/vue_shared/components/web_ide_link.vue +++ b/app/assets/javascripts/vue_shared/components/web_ide_link.vue @@ -335,7 +335,7 @@ export default { :variant="isBlob ? 'confirm' : 'default'" :category="isBlob ? 'primary' : 'secondary'" :toggle-text="$options.i18n.toggleText" - data-qa-selector="action_dropdown" + data-testid="action-dropdown" fluid-width block @shown="$emit('shown')" @@ -347,7 +347,7 @@ export default { v-for="action in actions" :key="action.key" :item="action" - :data-qa-selector="`${action.key}_menu_item`" + :data-testid="`${action.key}-menu-item`" @action="executeAction(action)" > <template #list-item> diff --git a/app/assets/javascripts/vue_shared/directives/safe_html.js b/app/assets/javascripts/vue_shared/directives/safe_html.js index 450c7fc1bc5..c731f742771 100644 --- a/app/assets/javascripts/vue_shared/directives/safe_html.js +++ b/app/assets/javascripts/vue_shared/directives/safe_html.js @@ -11,7 +11,7 @@ const DEFAULT_CONFIG = { const transform = (el, binding) => { if (binding.oldValue !== binding.value) { - const config = { ...DEFAULT_CONFIG, ...(binding.arg ?? {}) }; + const config = { ...DEFAULT_CONFIG, ...binding.arg }; el.textContent = ''; diff --git a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js index 79946ebaecd..a1abb079cc2 100644 --- a/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js +++ b/app/assets/javascripts/vue_shared/gl_feature_flags_plugin.js @@ -2,12 +2,11 @@ export default (Vue) => { Vue.mixin({ provide() { return { - glFeatures: - { - ...window.gon?.features, - // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460 - ...window.gon?.licensed_features, - } || {}, + glFeatures: { + ...window.gon?.features, + // TODO: extract into glLicensedFeatures https://gitlab.com/gitlab-org/gitlab/-/issues/322460 + ...window.gon?.licensed_features, + }, }; }, }); diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue index 45fde45f516..dae3ddfe016 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_body.vue @@ -74,6 +74,11 @@ export default { required: false, default: 0, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, computed: { isUpdated() { @@ -161,6 +166,7 @@ export default { :issuable="issuable" :status-icon="statusIcon" :enable-edit="enableEdit" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" > <template #status-badge> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue index a9b5e3a66a8..62a2b44e660 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_header.vue @@ -221,7 +221,7 @@ export default { @click="handleRightSidebarToggleClick" /> </div> - <div class="detail-page-header-actions gl-display-flex"> + <div class="detail-page-header-actions gl-align-self-center gl-display-flex"> <slot name="header-actions"></slot> </div> </div> diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue index 3878c16c8d0..040f49c7c25 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_show_root.vue @@ -147,6 +147,7 @@ export default { :description-help-path="descriptionHelpPath" :task-list-update-path="taskListUpdatePath" :task-list-lock-version="taskListLockVersion" + :workspace-type="workspaceType" @edit-issuable="$emit('edit-issuable', $event)" @task-list-update-success="$emit('task-list-update-success', $event)" @task-list-update-failure="$emit('task-list-update-failure')" diff --git a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue index da71adc8abd..5387e39e3eb 100644 --- a/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue +++ b/app/assets/javascripts/vue_shared/issuable/show/components/issuable_title.vue @@ -1,5 +1,6 @@ <script> import { GlIcon, GlBadge, GlButton, GlIntersectionObserver, GlTooltipDirective } from '@gitlab/ui'; +import ConfidentialityBadge from '~/vue_shared/components/confidentiality_badge.vue'; import SafeHtml from '~/vue_shared/directives/safe_html'; import { STATUS_OPEN } from '~/issues/constants'; import { __ } from '~/locale'; @@ -13,6 +14,7 @@ export default { GlBadge, GlButton, GlIntersectionObserver, + ConfidentialityBadge, }, directives: { GlTooltip: GlTooltipDirective, @@ -31,6 +33,11 @@ export default { type: Boolean, required: true, }, + workspaceType: { + type: String, + required: false, + default: '', + }, }, data() { return { @@ -79,9 +86,7 @@ export default { class="issue-sticky-header gl-fixed gl-z-index-3 gl-bg-white gl-border-1 gl-border-b-solid gl-border-b-gray-100 gl-py-3" data-testid="header" > - <div - class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto gl-px-5" - > + <div class="issue-sticky-header-text gl-display-flex gl-align-items-baseline gl-mx-auto"> <gl-badge class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" :variant="badgeVariant" @@ -91,6 +96,12 @@ export default { <slot name="status-badge"></slot> </span> </gl-badge> + <confidentiality-badge + v-if="issuable.confidential" + class="gl-white-space-nowrap gl-mr-3 gl-align-self-center" + :issuable-type="issuable.type" + :workspace-type="workspaceType" + /> <p class="gl-font-weight-bold gl-overflow-hidden gl-white-space-nowrap gl-text-overflow-ellipsis gl-my-0" :title="issuable.title" diff --git a/app/assets/javascripts/webhooks/components/push_events.vue b/app/assets/javascripts/webhooks/components/push_events.vue index 91d7e21500a..b5e0a4b2348 100644 --- a/app/assets/javascripts/webhooks/components/push_events.vue +++ b/app/assets/javascripts/webhooks/components/push_events.vue @@ -43,7 +43,7 @@ export default { value="all_branches" data-testid="rule_all_branches" > - <div data-qa-selector="strategy_radio_all">{{ __('All branches') }}</div> + <div>{{ __('All branches') }}</div> </gl-form-radio> <!-- wildcard --> @@ -52,7 +52,7 @@ export default { value="wildcard" data-testid="rule_wildcard" > - <div data-qa-selector="strategy_radio_wildcard"> + <div> {{ s__('Webhooks|Wildcard pattern') }} </div> </gl-form-radio> @@ -61,7 +61,6 @@ export default { v-if="branchFilterStrategyData === 'wildcard'" v-model="pushEventsBranchFilterData" name="hook[push_events_branch_filter]" - data-qa-selector="webhook_branch_filter_field" data-testid="webhook_branch_filter_field" /> </div> @@ -85,7 +84,7 @@ export default { value="regex" data-testid="rule_regex" > - <div data-qa-selector="strategy_radio_regex"> + <div> {{ s__('Webhooks|Regular expression') }} </div> </gl-form-radio> @@ -94,7 +93,6 @@ export default { v-if="branchFilterStrategyData === 'regex'" v-model="pushEventsBranchFilterData" name="hook[push_events_branch_filter]" - data-qa-selector="webhook_branch_filter_field" data-testid="webhook_branch_filter_field" /> </div> diff --git a/app/assets/javascripts/work_items/components/notes/system_note.vue b/app/assets/javascripts/work_items/components/notes/system_note.vue index 7903adea9bd..31cfe387b6e 100644 --- a/app/assets/javascripts/work_items/components/notes/system_note.vue +++ b/app/assets/javascripts/work_items/components/notes/system_note.vue @@ -26,6 +26,11 @@ import { __ } from '~/locale'; import NoteHeader from '~/notes/components/note_header.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +const ALLOWED_ICONS = ['issue-close']; +const ICON_COLORS = { + 'issue-close': 'gl-bg-blue-100! gl-text-blue-700', +}; + export default { i18n: { deleteButtonLabel: __('Remove description history'), @@ -66,6 +71,12 @@ export default { noteAnchorId() { return `note_${this.noteId}`; }, + getIconColor() { + return ICON_COLORS[this.note.systemNoteIconName] || ''; + }, + isAllowedIcon() { + return ALLOWED_ICONS.includes(this.note.systemNoteIconName); + }, isTargetNote() { return this.targetNoteHash === this.noteAnchorId; }, @@ -102,9 +113,16 @@ export default { class="note system-note note-wrapper" > <div - class="gl-float-left gl--flex-center gl-rounded-full gl-mt-n1 gl-ml-2 gl-w-6 gl-h-6 gl-bg-gray-50 gl-text-gray-600" + :class="[ + getIconColor, + { + 'gl-bg-gray-50 gl-text-gray-600 system-note-icon': isAllowedIcon, + 'system-note-tiny-dot gl-bg-gray-900!': !isAllowedIcon, + }, + ]" + class="gl-float-left gl--flex-center gl-rounded-full gl-relative" > - <gl-icon :name="note.systemNoteIconName" /> + <gl-icon v-if="isAllowedIcon" :size="12" :name="note.systemNoteIconName" /> </div> <div class="timeline-content"> <div class="note-header"> diff --git a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue index c867e53dc30..c3b7b7a2953 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_add_note.vue @@ -1,5 +1,5 @@ <script> -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; import { ASC } from '~/notes/constants'; import { __ } from '~/locale'; @@ -105,7 +105,7 @@ export default { }; }, update(data) { - return data.workspace.workItems.nodes[0]; + return data.workspace.workItems.nodes[0] ?? {}; }, skip() { return !this.workItemIid; @@ -150,13 +150,13 @@ export default { }; }, isProjectArchived() { - return this.workItem?.project?.archived; + return this.workItem.archived; }, canCreateNote() { - return this.workItem?.userPermissions?.createNote; + return this.workItem.userPermissions?.createNote; }, workItemState() { - return this.workItem?.state; + return this.workItem.state; }, commentButtonText() { return this.isNewDiscussion ? __('Comment') : __('Reply'); diff --git a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue index c7d8a50f402..1e6bd9ff1ac 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_comment_form.vue @@ -8,7 +8,7 @@ import { STATE_OPEN, TRACKING_CATEGORY_SHOW, TASK_TYPE_NAME } from '~/work_items import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; import MarkdownEditor from '~/vue_shared/components/markdown/markdown_editor.vue'; -import WorkItemStateToggleButton from '~/work_items/components/work_item_state_toggle_button.vue'; +import WorkItemStateToggle from '~/work_items/components/work_item_state_toggle.vue'; import CommentFieldLayout from '~/notes/components/comment_field_layout.vue'; export default { @@ -29,7 +29,7 @@ export default { MarkdownEditor, GlFormCheckbox, GlIcon, - WorkItemStateToggleButton, + WorkItemStateToggle, }, directives: { GlTooltip: GlTooltipDirective, @@ -195,7 +195,6 @@ export default { :autocomplete-data-sources="autocompleteDataSources" :form-field-props="formFieldProps" :add-spacing-classes="false" - data-testid="work-item-add-comment" use-bottom-toolbar supports-quick-actions :autofocus="autofocus" @@ -230,7 +229,7 @@ export default { @click="$emit('submitForm', { commentText, isNoteInternal })" >{{ commentButtonTextComputed }} </gl-button> - <work-item-state-toggle-button + <work-item-state-toggle v-if="isNewDiscussion" class="gl-ml-3" :work-item-id="workItemId" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note.vue b/app/assets/javascripts/work_items/components/notes/work_item_note.vue index f4c654f054c..11aecc65803 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note.vue @@ -1,6 +1,6 @@ <script> import { GlAvatarLink, GlAvatar } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import toast from '~/vue_shared/plugins/global_toast'; import { __ } from '~/locale'; import Tracking from '~/tracking'; @@ -96,6 +96,7 @@ export default { data() { return { isEditing: false, + workItem: {}, }; }, computed: { @@ -163,13 +164,13 @@ export default { return this.authorId === this.currentUserId; }, isWorkItemAuthor() { - return getIdFromGraphQLId(this.workItem?.author?.id) === this.authorId; + return getIdFromGraphQLId(this.workItem.author?.id) === this.authorId; }, projectName() { - return this.workItem?.project?.name; + return this.workItem.namespace?.name; }, isWorkItemConfidential() { - return this.workItem?.confidential; + return this.workItem.confidential; }, }, apollo: { @@ -184,7 +185,7 @@ export default { }; }, update(data) { - return data.workspace?.workItems?.nodes[0]; + return data.workspace?.workItems?.nodes[0] ?? {}; }, skip() { return !this.workItemIid; diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue index 2cdf8b5ea9d..cb9a560f9e1 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_actions.vue @@ -5,7 +5,7 @@ import { GlDisclosureDropdown, GlDisclosureDropdownItem, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __, sprintf } from '~/locale'; import UserAccessRoleBadge from '~/vue_shared/components/user_access_role_badge.vue'; import ReplyButton from '~/notes/components/note_actions/reply_button.vue'; @@ -207,7 +207,6 @@ export default { <gl-button v-if="showEdit" v-gl-tooltip - data-testid="edit-work-item-note" data-track-action="click_button" data-track-label="edit_button" category="tertiary" @@ -219,7 +218,6 @@ export default { <gl-disclosure-dropdown ref="dropdown" v-gl-tooltip - data-testid="work-item-note-actions" icon="ellipsis_v" text-sr-only placement="right" diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue index 17d22e66530..75a8a7b29c0 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_awards_list.vue @@ -1,5 +1,5 @@ <script> -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; import { getMutation, optimisticAwardUpdate } from '../../notes/award_utils'; diff --git a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue index bccbec903b4..e073fddeddb 100644 --- a/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue +++ b/app/assets/javascripts/work_items/components/notes/work_item_note_signed_out.vue @@ -27,5 +27,8 @@ export default { </script> <template> - <div v-safe-html="signedOutText" class="disabled-comment gl-text-center gl-relative"></div> + <div + v-safe-html="signedOutText" + class="disabled-comment gl-text-center gl-text-secondary gl-relative" + ></div> </template> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue index 49813edf6fc..cbe7de4abcd 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_link_child_contents.vue @@ -1,6 +1,6 @@ <script> -import { GlLabel, GlLink, GlIcon, GlTooltipDirective } from '@gitlab/ui'; -import { __ } from '~/locale'; +import { GlLabel, GlLink, GlIcon, GlTooltipDirective, GlButton } from '@gitlab/ui'; +import { __, s__ } from '~/locale'; import { isScopedLabel } from '~/lib/utils/common_utils'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; import WorkItemLinkChildMetadata from 'ee_else_ce/work_items/components/shared/work_item_link_child_metadata.vue'; @@ -15,21 +15,21 @@ import { WIDGET_TYPE_LABELS, WORK_ITEM_NAME_TO_ICON_MAP, } from '../../constants'; -import WorkItemLinksMenu from './work_item_links_menu.vue'; export default { i18n: { confidential: __('Confidential'), created: __('Created'), closed: __('Closed'), + remove: s__('WorkItem|Remove'), }, components: { GlLabel, GlLink, GlIcon, + GlButton, RichTimestampTooltip, WorkItemLinkChildMetadata, - WorkItemLinksMenu, }, directives: { GlTooltip: GlTooltipDirective, @@ -52,6 +52,16 @@ export default { required: false, default: false, }, + showLabels: { + type: Boolean, + required: false, + default: true, + }, + }, + data() { + return { + isFocused: false, + }; }, computed: { labels() { @@ -106,6 +116,12 @@ export default { } return false; }, + showRemove() { + return this.canUpdate && this.isFocused; + }, + displayLabels() { + return this.showLabels && this.labels.length; + }, }, methods: { showScopedLabel(label) { @@ -117,8 +133,12 @@ export default { <template> <div - class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base" + class="item-body work-item-link-child gl-relative gl-display-flex gl-flex-grow-1 gl-overflow-break-word gl-min-w-0 gl-pl-3 gl-pr-2 gl-py-2 gl-mx-n2 gl-rounded-base gl-gap-3" data-testid="links-child" + @mouseover="isFocused = true" + @mouseleave="isFocused = false" + @focusin="isFocused = true" + @focusout="isFocused = false" > <div class="item-contents gl-display-flex gl-flex-grow-1 gl-flex-wrap gl-min-w-0"> <div @@ -168,7 +188,7 @@ export default { class="gl-ml-6 ml-xl-0" /> </div> - <div v-if="labels.length" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> + <div v-if="displayLabels" class="gl-display-flex gl-flex-wrap gl-flex-basis-full gl-ml-6"> <gl-label v-for="label in labels" :key="label.id" @@ -181,10 +201,16 @@ export default { /> </div> </div> - <div v-if="canUpdate" class="gl-ml-0 gl-sm-ml-auto! gl-display-inline-flex"> - <work-item-links-menu - data-testid="links-menu" - @removeChild="$emit('removeChild', childItem)" + <div v-if="canUpdate"> + <gl-button + :class="{ 'gl-visibility-visible': showRemove }" + class="gl-visibility-hidden" + category="tertiary" + size="small" + icon="close" + :aria-label="$options.i18n.remove" + data-testid="remove-work-item-link" + @click="$emit('removeChild', childItem)" /> </div> </div> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue b/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue deleted file mode 100644 index 12b7bade31d..00000000000 --- a/app/assets/javascripts/work_items/components/shared/work_item_links_menu.vue +++ /dev/null @@ -1,28 +0,0 @@ -<script> -import { GlDisclosureDropdown, GlDisclosureDropdownItem } from '@gitlab/ui'; - -export default { - components: { - GlDisclosureDropdownItem, - GlDisclosureDropdown, - }, -}; -</script> - -<template> - <div class="gl-ml-5"> - <gl-disclosure-dropdown - category="tertiary" - toggle-class="btn-icon btn-sm" - icon="ellipsis_v" - data-testid="work_items_links_menu" - :aria-label="__(`More actions`)" - text-sr-only - no-caret - > - <gl-disclosure-dropdown-item @action="$emit('removeChild')"> - <template #list-item>{{ s__('WorkItem|Remove') }}</template> - </gl-disclosure-dropdown-item> - </gl-disclosure-dropdown> - </div> -</template> diff --git a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue index 3595ab631df..c122db6c902 100644 --- a/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue +++ b/app/assets/javascripts/work_items/components/shared/work_item_token_input.vue @@ -1,20 +1,29 @@ <script> -import { GlTokenSelector } from '@gitlab/ui'; +import { GlTokenSelector, GlAlert } from '@gitlab/ui'; import { debounce } from 'lodash'; + import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { isNumeric } from '~/lib/utils/number_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import SafeHtml from '~/vue_shared/directives/safe_html'; +import { highlighter } from 'ee_else_ce/gfm_auto_complete'; +import groupWorkItemsQuery from '../../graphql/group_work_items.query.graphql'; import projectWorkItemsQuery from '../../graphql/project_work_items.query.graphql'; import { WORK_ITEMS_TYPE_MAP, I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER, + I18N_WORK_ITEM_SEARCH_ERROR, sprintfWorkItem, } from '../../constants'; export default { components: { GlTokenSelector, + GlAlert, }, + directives: { SafeHtml }, + inject: ['isGroup'], props: { value: { type: Array, @@ -47,30 +56,37 @@ export default { }, apollo: { availableWorkItems: { - query: projectWorkItemsQuery, + query() { + return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery; + }, variables() { return { fullPath: this.fullPath, - searchTerm: this.search?.title || this.search, + searchTerm: '', types: this.childrenType ? [this.childrenType] : [], - in: this.search ? 'TITLE' : undefined, + isNumber: false, }; }, skip() { return !this.searchStarted; }, update(data) { - return data.workspace.workItems.nodes.filter( - (wi) => !this.childrenIds.includes(wi.id) && this.parentWorkItemId !== wi.id, - ); + return [ + ...this.filterItems(data.workspace.workItemsByIid?.nodes), + ...this.filterItems(data.workspace.workItems.nodes), + ]; + }, + error() { + this.error = sprintfWorkItem(I18N_WORK_ITEM_SEARCH_ERROR, this.childrenTypeName); }, }, }, data() { return { availableWorkItems: [], - search: '', + query: '', searchStarted: false, + error: '', }; }, computed: { @@ -101,7 +117,24 @@ export default { methods: { getIdFromGraphQLId, setSearchKey(value) { - this.search = value; + this.query = value; + + // Query parameters for searching by text + const variables = { + searchTerm: value, + in: value ? 'TITLE' : undefined, + iid: null, + isNumber: false, + }; + + // Check if it is a number, add iid as query parameter + if (isNumeric(value) && value) { + variables.iid = value; + variables.isNumber = true; + } + + // Fetch combined results of search by iid and search by title. + this.$apollo.queries.availableWorkItems.refetch(variables); }, handleFocus() { this.searchStarted = true; @@ -125,33 +158,58 @@ export default { } }); }, + formatResults(input) { + if (!this.query) { + return input; + } + + return highlighter(`<span class="gl-text-black-normal">${input}</span>`, this.query); + }, + unsetError() { + this.error = ''; + }, + filterItems(items) { + return ( + items?.filter( + (wi) => !this.childrenIds.includes(wi.id) && this.parentWorkItemId !== wi.id, + ) || [] + ); + }, }, }; </script> <template> - <gl-token-selector - ref="tokenSelector" - v-model="workItemsToAdd" - :dropdown-items="availableWorkItems" - :loading="isLoading" - :placeholder="addInputPlaceholder" - menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" - :container-class="tokenSelectorContainerClass" - data-testid="work-item-token-select-input" - @text-input="debouncedSearchKeyUpdate" - @focus="handleFocus" - @mouseover.native="handleMouseOver" - @mouseout.native="handleMouseOut" - @token-add="focusInputText" - @token-remove="focusInputText" - @blur="handleBlur" - > - <template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template> - <template #dropdown-item-content="{ dropdownItem }"> - <div class="gl-display-flex"> - <div class="gl-text-secondary gl-font-sm gl-mr-4">{{ dropdownItem.iid }}</div> - <div class="gl-text-truncate">{{ dropdownItem.title }}</div> - </div> - </template> - </gl-token-selector> + <div> + <gl-alert v-if="error" variant="danger" class="gl-mb-3" @dismiss="unsetError"> + {{ error }} + </gl-alert> + <gl-token-selector + ref="tokenSelector" + v-model="workItemsToAdd" + :dropdown-items="availableWorkItems" + :loading="isLoading" + :placeholder="addInputPlaceholder" + menu-class="gl-dropdown-menu-wide dropdown-reduced-height gl-min-h-7!" + :container-class="tokenSelectorContainerClass" + data-testid="work-item-token-select-input" + @text-input="debouncedSearchKeyUpdate" + @focus="handleFocus" + @mouseover.native="handleMouseOver" + @mouseout.native="handleMouseOut" + @token-add="focusInputText" + @token-remove="focusInputText" + @blur="handleBlur" + > + <template #token-content="{ token }"> {{ token.iid }} {{ token.title }} </template> + <template #dropdown-item-content="{ dropdownItem }"> + <div class="gl-display-flex"> + <div + v-safe-html="formatResults(dropdownItem.iid)" + class="gl-text-secondary gl-font-sm gl-mr-4" + ></div> + <div v-safe-html="formatResults(dropdownItem.title)" class="gl-text-truncate"></div> + </div> + </template> + </gl-token-selector> + </div> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_actions.vue b/app/assets/javascripts/work_items/components/work_item_actions.vue index 02d2ea24ca0..0a71fbc9a34 100644 --- a/app/assets/javascripts/work_items/components/work_item_actions.vue +++ b/app/assets/javascripts/work_items/components/work_item_actions.vue @@ -8,7 +8,7 @@ import { GlToggle, } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __, s__ } from '~/locale'; import Tracking from '~/tracking'; @@ -20,12 +20,12 @@ import { I18N_WORK_ITEM_DELETE, I18N_WORK_ITEM_ARE_YOU_SURE_DELETE, TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, - TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, TEST_ID_NOTIFICATIONS_TOGGLE_FORM, TEST_ID_DELETE_ACTION, TEST_ID_PROMOTE_ACTION, TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, TEST_ID_COPY_REFERENCE_ACTION, + TEST_ID_TOGGLE_ACTION, I18N_WORK_ITEM_ERROR_CONVERTING, WORK_ITEM_TYPE_VALUE_KEY_RESULT, WORK_ITEM_TYPE_VALUE_OBJECTIVE, @@ -36,11 +36,12 @@ import { import updateWorkItemNotificationsMutation from '../graphql/update_work_item_notifications.mutation.graphql'; import convertWorkItemMutation from '../graphql/work_item_convert.mutation.graphql'; import projectWorkItemTypesQuery from '../graphql/project_work_item_types.query.graphql'; +import WorkItemStateToggle from './work_item_state_toggle.vue'; export default { i18n: { - enableTaskConfidentiality: s__('WorkItem|Turn on confidentiality'), - disableTaskConfidentiality: s__('WorkItem|Turn off confidentiality'), + enableConfidentiality: s__('WorkItem|Turn on confidentiality'), + disableConfidentiality: s__('WorkItem|Turn off confidentiality'), notifications: s__('WorkItem|Notifications'), notificationOn: s__('WorkItem|Notifications turned on.'), notificationOff: s__('WorkItem|Notifications turned off.'), @@ -54,25 +55,30 @@ export default { GlDropdownDivider, GlModal, GlToggle, + WorkItemStateToggle, }, directives: { GlModal: GlModalDirective, }, mixins: [Tracking.mixin({ label: 'actions_menu' })], isLoggedIn: isLoggedIn(), - notificationsToggleTestId: TEST_ID_NOTIFICATIONS_TOGGLE_ACTION, notificationsToggleFormTestId: TEST_ID_NOTIFICATIONS_TOGGLE_FORM, confidentialityTestId: TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION, copyReferenceTestId: TEST_ID_COPY_REFERENCE_ACTION, copyCreateNoteEmailTestId: TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION, deleteActionTestId: TEST_ID_DELETE_ACTION, promoteActionTestId: TEST_ID_PROMOTE_ACTION, + stateToggleTestId: TEST_ID_TOGGLE_ACTION, inject: ['isGroup'], props: { fullPath: { type: String, required: true, }, + workItemState: { + type: String, + required: true, + }, workItemId: { type: String, required: false, @@ -128,6 +134,11 @@ export default { required: false, default: false, }, + workItemParentId: { + type: String, + required: false, + default: null, + }, }, apollo: { workItemTypes: { @@ -165,6 +176,11 @@ export default { canPromoteToObjective() { return this.canUpdate && this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT; }, + confidentialItemText() { + return this.isConfidential + ? this.$options.i18n.disableConfidentiality + : this.$options.i18n.enableConfidentiality; + }, objectiveWorkItemTypeId() { return this.workItemTypes.find((type) => type.name === WORK_ITEM_TYPE_VALUE_OBJECTIVE).id; }, @@ -267,7 +283,7 @@ export default { icon="ellipsis_v" data-testid="work-item-actions-dropdown" text-sr-only - :text="__('More actions')" + :toggle-text="__('More actions')" category="tertiary" :auto-close="false" no-caret @@ -282,7 +298,6 @@ export default { <gl-toggle :value="subscribedToNotifications" :label="$options.i18n.notifications" - :data-testid="$options.notificationsToggleTestId" class="work-item-notification-toggle" label-position="left" @change="toggleNotifications($event)" @@ -299,49 +314,56 @@ export default { > <template #list-item>{{ __('Promote to objective') }}</template> </gl-disclosure-dropdown-item> - <template v-if="canUpdate && !isParentConfidential"> - <gl-disclosure-dropdown-item - :data-testid="$options.confidentialityTestId" - @action="handleToggleWorkItemConfidentiality" - ><template #list-item>{{ - isConfidential - ? $options.i18n.disableTaskConfidentiality - : $options.i18n.enableTaskConfidentiality - }}</template></gl-disclosure-dropdown-item - > - </template> + + <gl-disclosure-dropdown-item + v-if="canUpdate && !isParentConfidential" + :data-testid="$options.confidentialityTestId" + @action="handleToggleWorkItemConfidentiality" + > + <template #list-item>{{ confidentialItemText }}</template> + </gl-disclosure-dropdown-item> + + <work-item-state-toggle + v-if="canUpdate" + :data-testid="$options.stateToggleTestId" + :work-item-id="workItemId" + :work-item-state="workItemState" + :work-item-parent-id="workItemParentId" + :work-item-type="workItemType" + show-as-dropdown-item + /> + <gl-disclosure-dropdown-item - ref="workItemReference" :data-testid="$options.copyReferenceTestId" :data-clipboard-text="workItemReference" @action="copyToClipboard(workItemReference, $options.i18n.referenceCopied)" - ><template #list-item>{{ - $options.i18n.copyReference - }}</template></gl-disclosure-dropdown-item > - <template v-if="$options.isLoggedIn && workItemCreateNoteEmail"> - <gl-disclosure-dropdown-item - ref="workItemCreateNoteEmail" - :data-testid="$options.copyCreateNoteEmailTestId" - :data-clipboard-text="workItemCreateNoteEmail" - @action="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" - ><template #list-item>{{ - i18n.copyCreateNoteEmail - }}</template></gl-disclosure-dropdown-item - > - </template> - <gl-dropdown-divider v-if="canDelete" /> + <template #list-item>{{ $options.i18n.copyReference }}</template> + </gl-disclosure-dropdown-item> + <gl-disclosure-dropdown-item - v-if="canDelete" - :data-testid="$options.deleteActionTestId" - variant="danger" - @action="handleDelete" + v-if="$options.isLoggedIn && workItemCreateNoteEmail" + :data-testid="$options.copyCreateNoteEmailTestId" + :data-clipboard-text="workItemCreateNoteEmail" + @action="copyToClipboard(workItemCreateNoteEmail, $options.i18n.emailAddressCopied)" > - <template #list-item - ><span class="text-danger">{{ i18n.deleteWorkItem }}</span></template - > + <template #list-item>{{ i18n.copyCreateNoteEmail }}</template> </gl-disclosure-dropdown-item> + + <template v-if="canDelete"> + <gl-dropdown-divider /> + <gl-disclosure-dropdown-item + :data-testid="$options.deleteActionTestId" + variant="danger" + @action="handleDelete" + > + <template #list-item> + <span class="text-danger">{{ i18n.deleteWorkItem }}</span> + </template> + </gl-disclosure-dropdown-item> + </template> </gl-disclosure-dropdown> + <gl-modal ref="modal" modal-id="work-item-confirm-delete" diff --git a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue index fd01d855782..7d09a003926 100644 --- a/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_attributes_wrapper.vue @@ -13,6 +13,7 @@ import { WIDGET_TYPE_WEIGHT, WORK_ITEM_TYPE_VALUE_KEY_RESULT, WORK_ITEM_TYPE_VALUE_OBJECTIVE, + WORK_ITEM_TYPE_VALUE_TASK, } from '../constants'; import WorkItemDueDate from './work_item_due_date.vue'; import WorkItemAssignees from './work_item_assignees.vue'; @@ -98,7 +99,8 @@ export default { showWorkItemParent() { return ( this.workItemType === WORK_ITEM_TYPE_VALUE_OBJECTIVE || - this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT + this.workItemType === WORK_ITEM_TYPE_VALUE_KEY_RESULT || + this.workItemType === WORK_ITEM_TYPE_VALUE_TASK ); }, workItemParent() { diff --git a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue index 44bd17b59a2..f806946509f 100644 --- a/app/assets/javascripts/work_items/components/work_item_award_emoji.vue +++ b/app/assets/javascripts/work_items/components/work_item_award_emoji.vue @@ -1,13 +1,14 @@ <script> -import * as Sentry from '@sentry/browser'; import { produce } from 'immer'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { getIdFromGraphQLId, convertToGraphQLId } from '~/graphql_shared/utils'; import AwardsList from '~/vue_shared/components/awards_list.vue'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { TYPENAME_USER } from '~/graphql_shared/constants'; -import workItemAwardEmojiQuery from '../graphql/award_emoji.query.graphql'; +import groupWorkItemAwardEmojiQuery from '../graphql/group_award_emoji.query.graphql'; +import projectWorkItemAwardEmojiQuery from '../graphql/award_emoji.query.graphql'; import updateAwardEmojiMutation from '../graphql/update_award_emoji.mutation.graphql'; import { EMOJI_THUMBSDOWN, @@ -23,6 +24,7 @@ export default { components: { AwardsList, }, + inject: ['isGroup'], props: { workItemId: { type: String, @@ -75,7 +77,9 @@ export default { }, apollo: { awardEmoji: { - query: workItemAwardEmojiQuery, + query() { + return this.isGroup ? groupWorkItemAwardEmojiQuery : projectWorkItemAwardEmojiQuery; + }, variables() { return { iid: this.workItemIid, @@ -116,7 +120,7 @@ export default { after: this.pageInfo?.endCursor, }, }); - } catch (error) { + } catch { this.$emit('error', I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR); } }, @@ -139,7 +143,7 @@ export default { return this.awardEmoji.nodes; } - // else make a copy of unmutable list and return the list after adding the new emoji + // else make a copy of immutable list and return the list after adding the new emoji const awardEmojiNodes = [...this.awardEmoji.nodes]; awardEmojiNodes.push({ name, @@ -162,7 +166,7 @@ export default { }, updateWorkItemAwardEmojiWidgetCache({ cache, name, toggledOn }) { const query = { - query: workItemAwardEmojiQuery, + query: this.isGroup ? groupWorkItemAwardEmojiQuery : projectWorkItemAwardEmojiQuery, variables: { fullPath: this.workItemFullpath, iid: this.workItemIid, @@ -234,7 +238,6 @@ export default { <template> <div v-if="!isLoading" class="gl-mt-3"> <awards-list - data-testid="work-item-award-list" :awards="awards" :can-award-emoji="$options.isLoggedIn" :current-user-id="currentUserId" diff --git a/app/assets/javascripts/work_items/components/work_item_created_updated.vue b/app/assets/javascripts/work_items/components/work_item_created_updated.vue index 460b5d35187..d352d66196a 100644 --- a/app/assets/javascripts/work_items/components/work_item_created_updated.vue +++ b/app/assets/javascripts/work_items/components/work_item_created_updated.vue @@ -86,7 +86,7 @@ export default { </script> <template> - <div class="gl-mb-3 gl-text-gray-700"> + <div class="gl-mb-3 gl-text-gray-700 gl-mt-3"> <work-item-state-badge v-if="workItemState" :work-item-state="workItemState" /> <gl-loading-icon v-if="updateInProgress" :inline="true" class="gl-mr-3" /> <confidentiality-badge diff --git a/app/assets/javascripts/work_items/components/work_item_description.vue b/app/assets/javascripts/work_items/components/work_item_description.vue index b7f3ac93cdb..77c573b47e4 100644 --- a/app/assets/javascripts/work_items/components/work_item_description.vue +++ b/app/assets/javascripts/work_items/components/work_item_description.vue @@ -1,6 +1,6 @@ <script> import { GlAlert, GlButton, GlForm, GlFormGroup } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { helpPagePath } from '~/helpers/help_page_helper'; import { getDraft, clearDraft, updateDraft } from '~/lib/utils/autosave'; import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; @@ -244,13 +244,7 @@ export default { @keydown.ctrl.enter="updateWorkItem" /> <div class="gl-display-flex"> - <gl-alert - v-if="hasConflicts" - :dismissible="false" - variant="danger" - class="gl-w-full" - data-testid="work-item-description-conflicts" - > + <gl-alert v-if="hasConflicts" :dismissible="false" variant="danger" class="gl-w-full"> <p> {{ s__( diff --git a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue index 07e03eba1d1..124e05db431 100644 --- a/app/assets/javascripts/work_items/components/work_item_description_rendered.vue +++ b/app/assets/javascripts/work_items/components/work_item_description_rendered.vue @@ -114,7 +114,7 @@ export default { v-else ref="gfm-content" v-safe-html="descriptionHtml" - class="md gl-mb-5 gl-min-h-8" + class="md gl-mb-5 gl-min-h-8 gl-clearfix" data-testid="work-item-description" @change="toggleCheckboxes" ></div> diff --git a/app/assets/javascripts/work_items/components/work_item_detail.vue b/app/assets/javascripts/work_items/components/work_item_detail.vue index 53929775684..45d3aa564a5 100644 --- a/app/assets/javascripts/work_items/components/work_item_detail.vue +++ b/app/assets/javascripts/work_items/components/work_item_detail.vue @@ -50,7 +50,6 @@ import WorkItemDescription from './work_item_description.vue'; import WorkItemNotes from './work_item_notes.vue'; import WorkItemDetailModal from './work_item_detail_modal.vue'; import WorkItemAwardEmoji from './work_item_award_emoji.vue'; -import WorkItemStateToggleButton from './work_item_state_toggle_button.vue'; import WorkItemRelationships from './work_item_relationships/work_item_relationships.vue'; import WorkItemTypeIcon from './work_item_type_icon.vue'; @@ -61,7 +60,6 @@ export default { }, isLoggedIn: isLoggedIn(), components: { - WorkItemStateToggleButton, GlAlert, GlButton, GlLoadingIcon, @@ -146,9 +144,9 @@ export default { if (isEmpty(this.workItem)) { this.setEmptyState(); } - if (!this.isModal && this.workItem.project) { - const path = this.workItem.project?.fullPath - ? ` · ${this.workItem.project.fullPath}` + if (!this.isModal && this.workItem.namespace) { + const path = this.workItem.namespace.fullPath + ? ` · ${this.workItem.namespace.fullPath}` : ''; document.title = `${this.workItem.title} · ${this.workItem?.workItemType?.name}${path}`; @@ -181,19 +179,19 @@ export default { return this.workItemType ? `#${this.workItem.iid}` : ''; }, canUpdate() { - return this.workItem?.userPermissions?.updateWorkItem; + return this.workItem.userPermissions?.updateWorkItem; }, canDelete() { - return this.workItem?.userPermissions?.deleteWorkItem; + return this.workItem.userPermissions?.deleteWorkItem; }, canSetWorkItemMetadata() { - return this.workItem?.userPermissions?.setWorkItemMetadata; + return this.workItem.userPermissions?.setWorkItemMetadata; }, canAssignUnassignUser() { return this.workItemAssignees && this.canSetWorkItemMetadata; }, projectFullPath() { - return this.workItem?.project?.fullPath; + return this.workItem.namespace?.fullPath; }, workItemsMvc2Enabled() { return this.glFeatures.workItemsMvc2; @@ -222,7 +220,7 @@ export default { return this.parentWorkItem?.webUrl; }, workItemIconName() { - return this.workItem?.workItemType?.iconName; + return this.workItem.workItemType?.iconName; }, noAccessSvgPath() { return `data:image/svg+xml;utf8,${encodeURIComponent(noAccessSvg)}`; @@ -274,6 +272,18 @@ export default { showWorkItemLinkedItems() { return this.hasLinkedWorkItems && this.workItemLinkedItems; }, + titleClassHeader() { + return { + 'gl-sm-display-none!': this.parentWorkItem, + 'gl-w-full': !this.parentWorkItem, + }; + }, + titleClassComponent() { + return { + 'gl-sm-display-block!': !this.parentWorkItem, + 'gl-display-none gl-sm-display-block!': this.parentWorkItem, + }; + }, }, mounted() { if (this.modalWorkItemIid) { @@ -285,7 +295,7 @@ export default { }, methods: { isWidgetPresent(type) { - return this.workItem?.widgets?.find((widget) => widget.type === type); + return this.workItem.widgets?.find((widget) => widget.type === type); }, toggleConfidentiality(confidentialStatus) { this.updateInProgress = true; @@ -409,7 +419,20 @@ export default { </gl-skeleton-loader> </div> <template v-else> - <div class="gl-display-flex gl-align-items-center" data-testid="work-item-body"> + <div class="gl-sm-display-none! gl-display-flex"> + <gl-button + v-if="isModal" + class="gl-ml-auto" + category="tertiary" + data-testid="work-item-close" + icon="close" + :aria-label="__('Close')" + @click="$emit('close')" + /> + </div> + <div + class="gl-display-block gl-sm-display-flex! gl-align-items-flex-start gl-flex-direction-column gl-sm-flex-direction-row gl-gap-3 gl-pt-3" + > <ul v-if="parentWorkItem" class="list-unstyled gl-display-flex gl-min-w-0 gl-mr-auto gl-mb-0 gl-z-index-0" @@ -440,53 +463,55 @@ export default { </li> </ul> <div - v-else-if="!error && !workItemLoading" - class="gl-mr-auto" + v-if="!error && !workItemLoading" + :class="titleClassHeader" data-testid="work-item-type" > - <work-item-type-icon - :work-item-icon-name="workItemIconName" + <work-item-title + v-if="workItem.title" + ref="title" + class="gl-sm-display-block!" + :work-item-id="workItem.id" + :work-item-title="workItem.title" :work-item-type="workItemType" - show-text + :work-item-parent-id="workItemParentId" + :can-update="canUpdate" + @error="updateError = $event" + /> + </div> + <div class="detail-page-header-actions gl-display-flex gl-align-self-start gl-gap-3"> + <work-item-todos + v-if="showWorkItemCurrentUserTodos" + :work-item-id="workItem.id" + :work-item-iid="workItemIid" + :work-item-fullpath="projectFullPath" + :current-user-todos="currentUserTodos" + @error="updateError = $event" + /> + <work-item-actions + :full-path="fullPath" + :work-item-id="workItem.id" + :subscribed-to-notifications="workItemNotificationsSubscribed" + :work-item-type="workItemType" + :work-item-type-id="workItemTypeId" + :can-delete="canDelete" + :can-update="canUpdate" + :is-confidential="workItem.confidential" + :is-parent-confidential="parentWorkItemConfidentiality" + :work-item-reference="workItem.reference" + :work-item-create-note-email="workItem.createNoteEmail" + :is-modal="isModal" + :work-item-state="workItem.state" + :work-item-parent-id="workItemParentId" + @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" + @toggleWorkItemConfidentiality="toggleConfidentiality" + @error="updateError = $event" + @promotedToObjective="$emit('promotedToObjective', workItemIid)" /> - {{ workItemBreadcrumbReference }} </div> - <work-item-state-toggle-button - v-if="canUpdate" - :work-item-id="workItem.id" - :work-item-state="workItem.state" - :work-item-parent-id="workItemParentId" - :work-item-type="workItemType" - @error="updateError = $event" - /> - <work-item-todos - v-if="showWorkItemCurrentUserTodos" - :work-item-id="workItem.id" - :work-item-iid="workItemIid" - :work-item-fullpath="projectFullPath" - :current-user-todos="currentUserTodos" - @error="updateError = $event" - /> - <work-item-actions - :full-path="fullPath" - :work-item-id="workItem.id" - :subscribed-to-notifications="workItemNotificationsSubscribed" - :work-item-type="workItemType" - :work-item-type-id="workItemTypeId" - :can-delete="canDelete" - :can-update="canUpdate" - :is-confidential="workItem.confidential" - :is-parent-confidential="parentWorkItemConfidentiality" - :work-item-reference="workItem.reference" - :work-item-create-note-email="workItem.createNoteEmail" - :is-modal="isModal" - @deleteWorkItem="$emit('deleteWorkItem', { workItemType, workItemId: workItem.id })" - @toggleWorkItemConfidentiality="toggleConfidentiality" - @error="updateError = $event" - @promotedToObjective="$emit('promotedToObjective', workItemIid)" - /> <gl-button v-if="isModal" + class="gl-display-none gl-sm-display-block!" category="tertiary" data-testid="work-item-close" icon="close" @@ -496,8 +521,9 @@ export default { </div> <div> <work-item-title - v-if="workItem.title" + v-if="workItem.title && parentWorkItem" ref="title" + :class="titleClassComponent" :work-item-id="workItem.id" :work-item-title="workItem.title" :work-item-type="workItemType" diff --git a/app/assets/javascripts/work_items/components/work_item_due_date.vue b/app/assets/javascripts/work_items/components/work_item_due_date.vue index 1aa62a2b906..704fe6fb11d 100644 --- a/app/assets/javascripts/work_items/components/work_item_due_date.vue +++ b/app/assets/javascripts/work_items/components/work_item_due_date.vue @@ -1,6 +1,6 @@ <script> import { GlButton, GlDatepicker, GlFormGroup } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { getDateWithUTC, newDateAsLocaleTime } from '~/lib/utils/datetime/date_calculation_utility'; import { s__ } from '~/locale'; import Tracking from '~/tracking'; diff --git a/app/assets/javascripts/work_items/components/work_item_labels.vue b/app/assets/javascripts/work_items/components/work_item_labels.vue index 3cdbf816421..7a5d3b1155f 100644 --- a/app/assets/javascripts/work_items/components/work_item_labels.vue +++ b/app/assets/javascripts/work_items/components/work_item_labels.vue @@ -3,7 +3,8 @@ import { GlTokenSelector, GlLabel, GlSkeletonLoader } from '@gitlab/ui'; import { debounce, uniqueId, without } from 'lodash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import Tracking from '~/tracking'; -import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import groupLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql'; +import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -90,7 +91,9 @@ export default { }, }, searchLabels: { - query: labelSearchQuery, + query() { + return this.isGroup ? groupLabelsQuery : projectLabelsQuery; + }, variables() { return { fullPath: this.fullPath, diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue index f4de7c1dddc..b6ea09edbd4 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_children_wrapper.vue @@ -1,7 +1,7 @@ <script> -import * as Sentry from '@sentry/browser'; import produce from 'immer'; import Draggable from 'vuedraggable'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { isLoggedIn } from '~/lib/utils/common_utils'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -50,6 +50,11 @@ export default { required: false, default: false, }, + showLabels: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -151,9 +156,6 @@ export default { update(data) { return data.workspace.workItems.nodes[0]; }, - context: { - isSingleRequest: true, - }, }); }, prefetchWorkItem({ iid }) { @@ -280,6 +282,7 @@ export default { :confidential="child.confidential" :work-item-type="workItemType" :has-indirect-children="hasIndirectChildren" + :show-labels="showLabels" @mouseover="prefetchWorkItem(child)" @mouseout="clearPrefetching" @removeChild="removeChild" diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue index 847a3585ac4..49454c3d9f3 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_link_child.vue @@ -1,7 +1,7 @@ <script> import { GlButton, GlTooltipDirective } from '@gitlab/ui'; import { cloneDeep } from 'lodash'; -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __, s__ } from '~/locale'; import { isScopedLabel } from '~/lib/utils/common_utils'; import { createAlert } from '~/alert'; @@ -49,6 +49,11 @@ export default { required: false, default: '', }, + showLabels: { + type: Boolean, + required: false, + default: true, + }, }, data() { return { @@ -231,6 +236,7 @@ export default { :can-update="canUpdate" :parent-work-item-id="issuableGid" :work-item-type="workItemType" + :show-labels="showLabels" @click="$emit('click', $event)" @removeChild="$emit('removeChild', childItem)" /> @@ -241,6 +247,7 @@ export default { :work-item-id="issuableGid" :work-item-type="workItemType" :children="children" + :show-labels="showLabels" @removeChild="removeChild" @click="$emit('click', $event)" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue index 7fa6ac2c57f..dd0a26c0b9c 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_links.vue @@ -5,6 +5,7 @@ import { GlIcon, GlLoadingIcon, GlTooltipDirective, + GlToggle, } from '@gitlab/ui'; import { isEmpty } from 'lodash'; import { s__ } from '~/locale'; @@ -15,7 +16,12 @@ import { isMetaKey } from '~/lib/utils/common_utils'; import { getParameterByName, setUrlParams, updateHistory } from '~/lib/utils/url_utility'; import AbuseCategorySelector from '~/abuse_reports/components/abuse_category_selector.vue'; -import { FORM_TYPES, WIDGET_ICONS, WORK_ITEM_STATUS_TEXT } from '../../constants'; +import { + FORM_TYPES, + WIDGET_ICONS, + WORK_ITEM_STATUS_TEXT, + I18N_WORK_ITEM_SHOW_LABELS, +} from '../../constants'; import { findHierarchyWidgetChildren } from '../../utils'; import { removeHierarchyChild } from '../../graphql/cache_utils'; import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; @@ -36,6 +42,7 @@ export default { WorkItemDetailModal, AbuseCategorySelector, WorkItemChildrenWrapper, + GlToggle, }, directives: { GlTooltip: GlTooltipDirective, @@ -65,9 +72,6 @@ export default { update(data) { return data.workspace.workItems.nodes[0] ?? {}; }, - context: { - isSingleRequest: true, - }, skip() { return !this.iid; }, @@ -107,6 +111,7 @@ export default { reportedUserId: 0, reportedUrl: '', widgetName: 'tasks', + showLabels: true, }; }, computed: { @@ -204,6 +209,7 @@ export default { addChildButtonLabel: s__('WorkItem|Add'), addChildOptionLabel: s__('WorkItem|Existing task'), createChildOptionLabel: s__('WorkItem|New task'), + showLabelsLabel: I18N_WORK_ITEM_SHOW_LABELS, }, WIDGET_TYPE_TASK_ICON: WIDGET_ICONS.TASK, WORK_ITEM_STATUS_TEXT, @@ -227,6 +233,14 @@ export default { </span> </template> <template #header-right> + <gl-toggle + class="gl-mr-4" + :value="showLabels" + :label="$options.i18n.showLabelsLabel" + label-position="left" + label-id="relationship-toggle-labels" + @change="showLabels = $event" + /> <gl-disclosure-dropdown v-if="canUpdate && canAddTask" placement="right" @@ -282,6 +296,7 @@ export default { :full-path="fullPath" :work-item-id="issuableGid" :work-item-iid="iid" + :show-labels="showLabels" @error="error = $event" @show-modal="openChild" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue index b61b3b2e0d3..3d09a90169c 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree.vue @@ -1,10 +1,12 @@ <script> +import { GlToggle } from '@gitlab/ui'; import { FORM_TYPES, WIDGET_TYPE_HIERARCHY, WORK_ITEMS_TREE_TEXT_MAP, WORK_ITEM_TYPE_ENUM_OBJECTIVE, WORK_ITEM_TYPE_ENUM_KEY_RESULT, + I18N_WORK_ITEM_SHOW_LABELS, } from '../../constants'; import WidgetWrapper from '../widget_wrapper.vue'; import OkrActionsSplitButton from './okr_actions_split_button.vue'; @@ -21,6 +23,7 @@ export default { WidgetWrapper, WorkItemLinksForm, WorkItemChildrenWrapper, + GlToggle, }, props: { fullPath: { @@ -68,6 +71,7 @@ export default { formType: null, childType: null, widgetName: 'tasks', + showLabels: true, }; }, computed: { @@ -99,6 +103,9 @@ export default { this.$emit('show-modal', { event, modalWorkItem: child }); }, }, + i18n: { + showLabelsLabel: I18N_WORK_ITEM_SHOW_LABELS, + }, }; </script> @@ -114,6 +121,14 @@ export default { {{ $options.WORK_ITEMS_TREE_TEXT_MAP[workItemType].title }} </template> <template #header-right> + <gl-toggle + class="gl-mr-4" + :value="showLabels" + :label="$options.i18n.showLabelsLabel" + label-position="left" + label-id="relationship-toggle-labels" + @change="showLabels = $event" + /> <okr-actions-split-button v-if="canUpdate" @showCreateObjectiveForm=" @@ -160,6 +175,7 @@ export default { :work-item-id="workItemId" :work-item-iid="workItemIid" :work-item-type="workItemType" + :show-labels="showLabels" @error="error = $event" @show-modal="showModal" /> diff --git a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue index 401223c3593..af181fa4e7e 100644 --- a/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue +++ b/app/assets/javascripts/work_items/components/work_item_links/work_item_tree_children.vue @@ -22,6 +22,11 @@ export default { required: false, default: false, }, + showLabels: { + type: Boolean, + required: false, + default: true, + }, }, }; </script> @@ -35,6 +40,7 @@ export default { :issuable-gid="workItemId" :child-item="child" :work-item-type="workItemType" + :show-labels="showLabels" @removeChild="$emit('removeChild', $event)" @click="$emit('click', Object.assign($event, { childItem: child }))" /> diff --git a/app/assets/javascripts/work_items/components/work_item_milestone.vue b/app/assets/javascripts/work_items/components/work_item_milestone.vue index a2cbb7f7598..9c6fa158169 100644 --- a/app/assets/javascripts/work_items/components/work_item_milestone.vue +++ b/app/assets/javascripts/work_items/components/work_item_milestone.vue @@ -1,15 +1,7 @@ <script> -import { - GlFormGroup, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, - GlSkeletonLoader, - GlSearchBoxByType, - GlDropdownText, -} from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import { GlCollapsibleListbox, GlFormGroup, GlSkeletonLoader } from '@gitlab/ui'; import { debounce } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; import { s__, __ } from '~/locale'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; @@ -22,7 +14,8 @@ import { TRACKING_CATEGORY_SHOW, } from '../constants'; -const noMilestoneId = 'no-milestone-id'; +export const noMilestoneId = 'no-milestone-id'; +const noMilestoneItem = { text: s__('WorkItem|No milestone'), value: noMilestoneId }; export default { i18n: { @@ -37,13 +30,9 @@ export default { EXPIRED_TEXT: __('(expired)'), }, components: { + GlCollapsibleListbox, GlFormGroup, - GlDropdown, - GlDropdownItem, - GlDropdownDivider, GlSkeletonLoader, - GlSearchBoxByType, - GlDropdownText, }, mixins: [Tracking.mixin()], props: { @@ -74,11 +63,23 @@ export default { data() { return { localMilestone: this.workItemMilestone, + localMilestoneId: this.workItemMilestone?.id, searchTerm: '', shouldFetch: false, updateInProgress: false, - isFocused: false, milestones: [], + dropdownGroups: [ + { + text: this.$options.i18n.NO_MILESTONE, + textSrOnly: true, + options: [noMilestoneItem], + }, + { + text: __('Milestones'), + textSrOnly: true, + options: [], + }, + ], }; }, computed: { @@ -103,23 +104,29 @@ export default { isLoadingMilestones() { return this.$apollo.queries.milestones.loading; }, - isNoMilestone() { - return this.localMilestone?.id === noMilestoneId || !this.localMilestone?.id; + milestonesList() { + return ( + this.milestones.map(({ id, title, expired }) => { + return { + value: id, + text: title, + expired, + }; + }) ?? [] + ); }, - dropdownClasses() { - return { - 'gl-text-gray-500!': this.canUpdate && this.isNoMilestone, - 'is-not-focused': !this.isFocused, - 'gl-min-w-20': true, - }; + toggleClasses() { + const toggleClasses = ['gl-max-w-full']; + + if (this.localMilestoneId === noMilestoneId) { + toggleClasses.push('gl-text-gray-500!'); + } + return toggleClasses; }, }, watch: { - workItemMilestone: { - handler(newVal) { - this.localMilestone = newVal; - }, - deep: true, + milestones() { + this.dropdownGroups[1].options = this.milestonesList; }, }, created() { @@ -152,15 +159,11 @@ export default { this.localMilestone = milestone; }, onDropdownShown() { - this.$refs.search.focusInput(); this.shouldFetch = true; - this.isFocused = true; }, onDropdownHide() { - this.isFocused = false; this.searchTerm = ''; this.shouldFetch = false; - this.updateMilestone(); }, setSearchKey(value) { this.searchTerm = value; @@ -169,6 +172,9 @@ export default { return this.localMilestone?.id === milestone?.id; }, updateMilestone() { + this.localMilestone = + this.milestones.find(({ id }) => id === this.localMilestoneId) ?? noMilestoneItem; + if (this.workItemMilestone?.id === this.localMilestone?.id) { return; } @@ -182,8 +188,7 @@ export default { input: { id: this.workItemId, milestoneWidget: { - milestoneId: - this.localMilestone?.id === 'no-milestone-id' ? null : this.localMilestone?.id, + milestoneId: this.localMilestoneId === noMilestoneId ? null : this.localMilestoneId, }, }, }, @@ -222,50 +227,45 @@ export default { > {{ dropdownText }} </span> - <gl-dropdown + + <gl-collapsible-listbox v-else id="milestone-value" + v-model="localMilestoneId" + :items="dropdownGroups" + category="tertiary" data-testid="work-item-milestone-dropdown" - class="gl-pl-0 gl-max-w-full work-item-field-value" - :toggle-class="dropdownClasses" - :text="dropdownText" + class="gl-max-w-full" + :toggle-text="dropdownText" :loading="updateInProgress" + :toggle-class="toggleClasses" + searchable + @select="updateMilestone" @shown="onDropdownShown" - @hide="onDropdownHide" + @hidden="onDropdownHide" + @search="debouncedSearchKeyUpdate" > - <template #header> - <gl-search-box-by-type ref="search" :value="searchTerm" @input="debouncedSearchKeyUpdate" /> + <template #list-item="{ item }"> + {{ item.text }} + <span v-if="item.expired">{{ $options.i18n.EXPIRED_TEXT }}</span> </template> - <gl-dropdown-item - data-testid="no-milestone" - is-check-item - :is-checked="isNoMilestone" - @click="handleMilestoneClick({ id: 'no-milestone-id' })" - > - {{ $options.i18n.NO_MILESTONE }} - </gl-dropdown-item> - <gl-dropdown-divider /> - <gl-dropdown-text v-if="isLoadingMilestones"> - <gl-skeleton-loader :height="90"> + <template #footer> + <gl-skeleton-loader v-if="isLoadingMilestones" :height="90"> <rect width="380" height="10" x="10" y="15" rx="4" /> <rect width="280" height="10" x="10" y="30" rx="4" /> <rect width="380" height="10" x="10" y="50" rx="4" /> <rect width="280" height="10" x="10" y="65" rx="4" /> </gl-skeleton-loader> - </gl-dropdown-text> - <template v-else-if="milestones.length"> - <gl-dropdown-item - v-for="milestone in milestones" - :key="milestone.id" - is-check-item - :is-checked="isMilestoneChecked(milestone)" - @click="handleMilestoneClick(milestone)" + + <div + v-else-if="!milestones.length" + aria-live="assertive" + class="gl-pl-7 gl-pr-5 gl-py-3 gl-font-base gl-text-gray-600" + data-testid="no-results-text" > - {{ milestone.title }} - <template v-if="milestone.expired">{{ $options.i18n.EXPIRED_TEXT }}</template> - </gl-dropdown-item> + {{ $options.i18n.NO_MATCHING_RESULTS }} + </div> </template> - <gl-dropdown-text v-else>{{ $options.i18n.NO_MATCHING_RESULTS }}</gl-dropdown-text> - </gl-dropdown> + </gl-collapsible-listbox> </gl-form-group> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_notes.vue b/app/assets/javascripts/work_items/components/work_item_notes.vue index fe8aea99f53..6756acd4495 100644 --- a/app/assets/javascripts/work_items/components/work_item_notes.vue +++ b/app/assets/javascripts/work_items/components/work_item_notes.vue @@ -1,7 +1,7 @@ <script> import { GlSkeletonLoader, GlModal } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { uniqueId } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { __ } from '~/locale'; import { scrollToTargetOnResize } from '~/lib/utils/resize_observer'; import { TYPENAME_DISCUSSION, TYPENAME_NOTE } from '~/graphql_shared/constants'; @@ -170,9 +170,6 @@ export default { apollo: { workItemNotes: { query: workItemNotesByIidQuery, - context: { - isSingleRequest: true, - }, variables() { return { fullPath: this.fullPath, diff --git a/app/assets/javascripts/work_items/components/work_item_parent.vue b/app/assets/javascripts/work_items/components/work_item_parent.vue index e16299f482f..ce30f7985cf 100644 --- a/app/assets/javascripts/work_items/components/work_item_parent.vue +++ b/app/assets/javascripts/work_items/components/work_item_parent.vue @@ -1,18 +1,20 @@ <script> import { GlFormGroup, GlCollapsibleListbox } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; import { debounce } from 'lodash'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { s__ } from '~/locale'; import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; +import { removeHierarchyChild } from '../graphql/cache_utils'; +import groupWorkItemsQuery from '../graphql/group_work_items.query.graphql'; import projectWorkItemsQuery from '../graphql/project_work_items.query.graphql'; import { I18N_WORK_ITEM_ERROR_UPDATING, sprintfWorkItem, - WORK_ITEM_TYPE_ENUM_OBJECTIVE, + SUPPORTED_PARENT_TYPE_MAP, } from '../constants'; export default { @@ -31,7 +33,7 @@ export default { GlCollapsibleListbox, }, mixins: [glFeatureFlagMixin()], - inject: ['fullPath'], + inject: ['fullPath', 'isGroup'], props: { workItemId: { type: String, @@ -60,7 +62,7 @@ export default { searchStarted: false, availableWorkItems: [], localSelectedItem: this.parent?.id, - isNotFocused: true, + oldParent: this.parent, }; }, computed: { @@ -80,13 +82,8 @@ export default { workItems() { return this.availableWorkItems.map(({ id, title }) => ({ text: title, value: id })); }, - listboxCategory() { - return this.searchStarted ? 'secondary' : 'tertiary'; - }, - listboxClasses() { - return { - 'is-not-focused': this.isNotFocused && !this.searchStarted, - }; + parentType() { + return SUPPORTED_PARENT_TYPE_MAP[this.workItemType]; }, }, watch: { @@ -101,13 +98,17 @@ export default { }, apollo: { availableWorkItems: { - query: projectWorkItemsQuery, + query() { + return this.isGroup ? groupWorkItemsQuery : projectWorkItemsQuery; + }, variables() { return { fullPath: this.fullPath, searchTerm: this.search, - types: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + types: this.parentType, in: this.search ? 'TITLE' : undefined, + iid: null, + isNumber: false, }; }, skip() { @@ -146,6 +147,14 @@ export default { }, }, }, + update: (cache) => + removeHierarchyChild({ + cache, + fullPath: this.fullPath, + iid: this.oldParent?.iid, + isGroup: this.isGroup, + workItem: { id: this.workItemId }, + }), }); if (errors.length) { @@ -171,19 +180,10 @@ export default { }, onListboxShown() { this.searchStarted = true; - this.isNotFocused = false; }, onListboxHide() { this.searchStarted = false; this.search = ''; - this.isNotFocused = true; - }, - setListboxFocused() { - // This is to match the caret behaviour of parent listbox - // to the other dropdown fields of work items - if (document.activeElement.parentElement.id !== 'work-item-parent-listbox-value') { - this.isNotFocused = true; - } }, }, }; @@ -206,30 +206,20 @@ export default { > {{ listboxText }} </span> - <div - v-else - :class="{ 'gl-max-w-max-content': !workItemsMvc2Enabled }" - @mouseover="isNotFocused = false" - @mouseleave="setListboxFocused" - @focusout="isNotFocused = true" - @focusin="isNotFocused = false" - > + <div v-else :class="{ 'gl-max-w-max-content': !workItemsMvc2Enabled }"> <gl-collapsible-listbox id="work-item-parent-listbox-value" class="gl-max-w-max-content" data-testid="work-item-parent-listbox" - block searchable - :no-caret="isNotFocused && !searchStarted" is-check-centered - :category="listboxCategory" + category="tertiary" :searching="isLoading" :header-text="$options.i18n.assignParentLabel" :no-results-text="$options.i18n.noMatchingResults" :loading="updateInProgress" :items="workItems" :toggle-text="listboxText" - :toggle-class="listboxClasses" :selected="localSelectedItem" :reset-button-label="$options.i18n.unAssign" @reset="unAssignParent" diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue index d242db95896..c98bd6ce1e9 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_add_relationship_form.vue @@ -4,6 +4,7 @@ import { GlFormGroup, GlForm, GlFormRadioGroup, GlButton, GlAlert } from '@gitla import { __, s__ } from '~/locale'; import WorkItemTokenInput from '../shared/work_item_token_input.vue'; import addLinkedItemsMutation from '../../graphql/add_linked_items.mutation.graphql'; +import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import { LINK_ITEM_FORM_HEADER_LABEL, @@ -23,6 +24,7 @@ export default { GlAlert, WorkItemTokenInput, }, + inject: ['isGroup'], props: { workItemId: { type: String, @@ -121,7 +123,7 @@ export default { }, ) => { const queryArgs = { - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.workItemFullPath, iid: this.workItemIid }, }; const sourceData = cache.readQuery(queryArgs); diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue index 002c1786044..e70c79ea68f 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationship_list.vue @@ -19,6 +19,11 @@ export default { type: Boolean, required: true, }, + showLabels: { + type: Boolean, + required: false, + default: true, + }, }, }; </script> @@ -42,6 +47,7 @@ export default { :child-item="linkedItem.workItem" :can-update="canUpdate" :show-task-icon="true" + :show-labels="showLabels" @click="$emit('showModal', { event: $event, child: linkedItem.workItem })" @removeChild="$emit('removeLinkedItem', linkedItem.workItem)" /> diff --git a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue index 20427fe96c4..790804a8934 100644 --- a/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue +++ b/app/assets/javascripts/work_items/components/work_item_relationships/work_item_relationships.vue @@ -1,6 +1,6 @@ <script> import { produce } from 'immer'; -import { GlLoadingIcon, GlIcon, GlButton, GlLink } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon, GlButton, GlLink, GlToggle } from '@gitlab/ui'; import { s__ } from '~/locale'; import { helpPagePath } from '~/helpers/help_page_helper'; @@ -8,7 +8,11 @@ import { helpPagePath } from '~/helpers/help_page_helper'; import groupWorkItemByIidQuery from '../../graphql/group_work_item_by_iid.query.graphql'; import workItemByIidQuery from '../../graphql/work_item_by_iid.query.graphql'; import removeLinkedItemsMutation from '../../graphql/remove_linked_items.mutation.graphql'; -import { WIDGET_TYPE_LINKED_ITEMS, LINKED_CATEGORIES_MAP } from '../../constants'; +import { + WIDGET_TYPE_LINKED_ITEMS, + LINKED_CATEGORIES_MAP, + I18N_WORK_ITEM_SHOW_LABELS, +} from '../../constants'; import WidgetWrapper from '../widget_wrapper.vue'; import WorkItemRelationshipList from './work_item_relationship_list.vue'; @@ -24,6 +28,7 @@ export default { WidgetWrapper, WorkItemRelationshipList, WorkItemAddRelationshipForm, + GlToggle, }, inject: ['isGroup'], props: { @@ -60,9 +65,6 @@ export default { update(data) { return data.workspace.workItems.nodes[0] ?? {}; }, - context: { - isSingleRequest: true, - }, skip() { return !this.workItemIid; }, @@ -97,6 +99,7 @@ export default { linksBlocks: [], isShownLinkItemForm: false, widgetName: 'linkeditems', + showLabels: true, }; }, computed: { @@ -150,7 +153,7 @@ export default { return; } const queryArgs = { - query: workItemByIidQuery, + query: this.isGroup ? groupWorkItemByIidQuery : workItemByIidQuery, variables: { fullPath: this.workItemFullPath, iid: this.workItemIid }, }; const sourceData = cache.readQuery(queryArgs); @@ -200,6 +203,7 @@ export default { blockingTitle: s__('WorkItem|Blocking'), blockedByTitle: s__('WorkItem|Blocked by'), addLinkedWorkItemButtonLabel: s__('WorkItem|Add'), + showLabelsLabel: I18N_WORK_ITEM_SHOW_LABELS, }, }; </script> @@ -222,11 +226,18 @@ export default { </div> </template> <template #header-right> + <gl-toggle + :value="showLabels" + :label="$options.i18n.showLabelsLabel" + label-position="left" + label-id="relationship-toggle-labels" + @change="showLabels = $event" + /> <gl-button v-if="canAdminWorkItemLink" data-testid="link-item-add-button" size="small" - class="gl-ml-3" + class="gl-ml-4" @click="showLinkItemForm" > <slot name="add-button-text">{{ $options.i18n.addLinkedWorkItemButtonLabel }}</slot> @@ -264,6 +275,7 @@ export default { :linked-items="linksBlocks" :heading="$options.i18n.blockingTitle" :can-update="canAdminWorkItemLink" + :show-labels="showLabels" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" @removeLinkedItem="removeLinkedItem" /> @@ -276,6 +288,7 @@ export default { :linked-items="linksIsBlockedBy" :heading="$options.i18n.blockedByTitle" :can-update="canAdminWorkItemLink" + :show-labels="showLabels" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" @removeLinkedItem="removeLinkedItem" /> @@ -284,6 +297,7 @@ export default { :linked-items="linksRelatesTo" :heading="$options.i18n.relatedToTitle" :can-update="canAdminWorkItemLink" + :show-labels="showLabels" @showModal="$emit('showModal', { event: $event.event, modalWorkItem: $event.child })" @removeLinkedItem="removeLinkedItem" /> diff --git a/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue index 0ea30845466..581ef9ec945 100644 --- a/app/assets/javascripts/work_items/components/work_item_state_toggle_button.vue +++ b/app/assets/javascripts/work_items/components/work_item_state_toggle.vue @@ -1,9 +1,8 @@ <script> -import { GlButton } from '@gitlab/ui'; -import * as Sentry from '@sentry/browser'; +import { GlButton, GlDisclosureDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; -import { __, sprintf } from '~/locale'; -import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; +import { __ } from '~/locale'; import { getUpdateWorkItemMutation } from '~/work_items/components/update_work_item'; import { sprintfWorkItem, @@ -17,6 +16,8 @@ import { export default { components: { GlButton, + GlDisclosureDropdownItem, + GlLoadingIcon, }, mixins: [Tracking.mixin()], props: { @@ -37,6 +38,11 @@ export default { required: false, default: null, }, + showAsDropdownItem: { + type: Boolean, + required: false, + default: false, + }, }, data() { return { @@ -51,9 +57,7 @@ export default { const baseText = this.isWorkItemOpen ? __('Close %{workItemType}') : __('Reopen %{workItemType}'); - return capitalizeFirstCharacter( - sprintf(baseText, { workItemType: this.workItemType.toLowerCase() }), - ); + return sprintfWorkItem(baseText, this.workItemType); }, tracking() { return { @@ -62,6 +66,12 @@ export default { property: `type_${this.workItemType}`, }; }, + toggleInProgressText() { + const baseText = this.isWorkItemOpen + ? __('Closing %{workItemType}') + : __('Reopening %{workItemType}'); + return sprintfWorkItem(baseText, this.workItemType); + }, }, methods: { async updateWorkItem() { @@ -104,10 +114,18 @@ export default { </script> <template> - <gl-button - :loading="updateInProgress" - data-testid="work-item-state-toggle" - @click="updateWorkItem" - >{{ toggleWorkItemStateText }}</gl-button - > + <gl-disclosure-dropdown-item v-if="showAsDropdownItem" @action="updateWorkItem"> + <template #list-item> + <template v-if="updateInProgress"> + <gl-loading-icon inline size="sm" /> + {{ toggleInProgressText }} + </template> + <template v-else> + {{ toggleWorkItemStateText }} + </template> + </template> + </gl-disclosure-dropdown-item> + <gl-button v-else :loading="updateInProgress" @click="updateWorkItem">{{ + toggleWorkItemStateText + }}</gl-button> </template> diff --git a/app/assets/javascripts/work_items/components/work_item_title.vue b/app/assets/javascripts/work_items/components/work_item_title.vue index c52a6854fad..9b5803421dd 100644 --- a/app/assets/javascripts/work_items/components/work_item_title.vue +++ b/app/assets/javascripts/work_items/components/work_item_title.vue @@ -1,10 +1,12 @@ <script> -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import Tracking from '~/tracking'; import { sprintfWorkItem, I18N_WORK_ITEM_ERROR_UPDATING, TRACKING_CATEGORY_SHOW, + WORK_ITEM_TITLE_MAX_LENGTH, + I18N_MAX_CHARS_IN_WORK_ITEM_TITLE_MESSAGE, } from '../constants'; import { getUpdateWorkItemMutation } from './update_work_item'; import ItemTitle from './item_title.vue'; @@ -56,6 +58,11 @@ export default { return; } + if (updatedTitle.length > WORK_ITEM_TITLE_MAX_LENGTH) { + this.$emit('error', sprintfWorkItem(I18N_MAX_CHARS_IN_WORK_ITEM_TITLE_MESSAGE)); + return; + } + const input = { id: this.workItemId, title: updatedTitle, diff --git a/app/assets/javascripts/work_items/components/work_item_todos.vue b/app/assets/javascripts/work_items/components/work_item_todos.vue index e6d7f2067ba..62518616398 100644 --- a/app/assets/javascripts/work_items/components/work_item_todos.vue +++ b/app/assets/javascripts/work_items/components/work_item_todos.vue @@ -175,17 +175,12 @@ export default { <template> <gl-button v-gl-tooltip.hover - data-testid="work-item-todos-action" :loading="isLoading" :title="buttonLabel" - category="tertiary" + category="secondary" :aria-label="buttonLabel" @click="onToggle" > - <gl-icon - data-testid="work-item-todos-icon" - :class="{ 'gl-fill-blue-500': pendingTodo }" - :name="buttonIcon" - /> + <gl-icon :class="{ 'gl-fill-blue-500': pendingTodo }" :name="buttonIcon" /> </gl-button> </template> diff --git a/app/assets/javascripts/work_items/constants.js b/app/assets/javascripts/work_items/constants.js index a64172acff4..daa72204609 100644 --- a/app/assets/javascripts/work_items/constants.js +++ b/app/assets/javascripts/work_items/constants.js @@ -35,6 +35,7 @@ export const WORK_ITEM_TYPE_ENUM_TEST_CASE = 'TEST_CASE'; export const WORK_ITEM_TYPE_ENUM_REQUIREMENTS = 'REQUIREMENTS'; export const WORK_ITEM_TYPE_ENUM_OBJECTIVE = 'OBJECTIVE'; export const WORK_ITEM_TYPE_ENUM_KEY_RESULT = 'KEY_RESULT'; +export const WORK_ITEM_TYPE_ENUM_EPIC = 'EPIC'; export const WORK_ITEM_TYPE_VALUE_EPIC = 'Epic'; export const WORK_ITEM_TYPE_VALUE_INCIDENT = 'Incident'; @@ -45,6 +46,8 @@ export const WORK_ITEM_TYPE_VALUE_REQUIREMENTS = 'Requirements'; export const WORK_ITEM_TYPE_VALUE_KEY_RESULT = 'Key Result'; export const WORK_ITEM_TYPE_VALUE_OBJECTIVE = 'Objective'; +export const WORK_ITEM_TITLE_MAX_LENGTH = 255; + export const i18n = { fetchErrorTitle: s__('WorkItem|Work item not found'), fetchError: s__( @@ -91,8 +94,9 @@ export const I18N_WORK_ITEM_FETCH_AWARD_EMOJI_ERROR = s__( export const I18N_WORK_ITEM_CREATE_BUTTON_LABEL = s__('WorkItem|Create %{workItemType}'); export const I18N_WORK_ITEM_ADD_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}'); export const I18N_WORK_ITEM_ADD_MULTIPLE_BUTTON_LABEL = s__('WorkItem|Add %{workItemType}s'); -export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__( - 'WorkItem|Search existing %{workItemType}s', +export const I18N_WORK_ITEM_SEARCH_INPUT_PLACEHOLDER = s__('WorkItem|Search existing items'); +export const I18N_WORK_ITEM_SEARCH_ERROR = s__( + 'WorkItem|Something went wrong while fetching the %{workItemType}. Please try again.', ); export const I18N_WORK_ITEM_CONFIDENTIALITY_CHECKBOX_LABEL = s__( 'WorkItem|This %{workItemType} is confidential and should only be visible to team members with at least Reporter access', @@ -108,6 +112,11 @@ export const I18N_WORK_ITEM_ERROR_COPY_EMAIL = s__( 'WorkItem|Something went wrong while copying the %{workItemType} email address. Please try again.', ); +export const I18N_MAX_CHARS_IN_WORK_ITEM_TITLE_MESSAGE = sprintf( + s__('WorkItem|Title cannot have more than %{WORK_ITEM_TITLE_MAX_LENGTH} characters.'), + { WORK_ITEM_TITLE_MAX_LENGTH }, +); + export const I18N_WORK_ITEM_COPY_CREATE_NOTE_EMAIL = s__( 'WorkItem|Copy %{workItemType} email address', ); @@ -122,6 +131,7 @@ export const I18N_MAX_WORK_ITEMS_NOTE_LABEL = sprintf( s__('WorkItem|Add a maximum of %{MAX_WORK_ITEMS} items at a time.'), { MAX_WORK_ITEMS }, ); +export const I18N_WORK_ITEM_SHOW_LABELS = s__('WorkItem|Show labels'); export const sprintfWorkItem = (msg, workItemTypeArg, parentWorkItemType = '') => { const workItemType = workItemTypeArg || s__('WorkItem|item'); @@ -178,6 +188,11 @@ export const WORK_ITEMS_TYPE_MAP = { name: s__('WorkItem|Key result'), value: WORK_ITEM_TYPE_VALUE_KEY_RESULT, }, + [WORK_ITEM_TYPE_ENUM_EPIC]: { + icon: `epic`, + name: s__('WorkItem|Epic'), + value: WORK_ITEM_TYPE_VALUE_EPIC, + }, }; export const WORK_ITEMS_TREE_TEXT_MAP = { @@ -246,12 +261,12 @@ export const WORK_ITEM_ACTIVITY_SORT_OPTIONS = [ ]; export const TEST_ID_CONFIDENTIALITY_TOGGLE_ACTION = 'confidentiality-toggle-action'; -export const TEST_ID_NOTIFICATIONS_TOGGLE_ACTION = 'notifications-toggle-action'; export const TEST_ID_NOTIFICATIONS_TOGGLE_FORM = 'notifications-toggle-form'; export const TEST_ID_DELETE_ACTION = 'delete-action'; export const TEST_ID_PROMOTE_ACTION = 'promote-action'; export const TEST_ID_COPY_REFERENCE_ACTION = 'copy-reference-action'; export const TEST_ID_COPY_CREATE_NOTE_EMAIL_ACTION = 'copy-create-note-email-action'; +export const TEST_ID_TOGGLE_ACTION = 'state-toggle-action'; export const TODO_ADD_ICON = 'todo-add'; export const TODO_DONE_ICON = 'todo-done'; @@ -288,3 +303,9 @@ export const LINK_ITEM_FORM_HEADER_LABEL = { [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: s__('WorkItem|The current key result'), [WORK_ITEM_TYPE_VALUE_TASK]: s__('WorkItem|The current task'), }; + +export const SUPPORTED_PARENT_TYPE_MAP = { + [WORK_ITEM_TYPE_VALUE_OBJECTIVE]: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + [WORK_ITEM_TYPE_VALUE_KEY_RESULT]: [WORK_ITEM_TYPE_ENUM_OBJECTIVE], + [WORK_ITEM_TYPE_VALUE_TASK]: [WORK_ITEM_TYPE_ENUM_ISSUE], +}; diff --git a/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql index 82a532e1bea..0b9dc546df3 100644 --- a/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql +++ b/app/assets/javascripts/work_items/graphql/award_emoji.query.graphql @@ -1,7 +1,7 @@ #import "~/graphql_shared/fragments/page_info.fragment.graphql" #import "~/work_items/graphql/award_emoji.fragment.graphql" -query workItemAwardEmojis($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { +query projectWorkItemAwardEmojis($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { workspace: project(fullPath: $fullPath) { id workItems(iid: $iid) { diff --git a/app/assets/javascripts/work_items/graphql/group_award_emoji.query.graphql b/app/assets/javascripts/work_items/graphql/group_award_emoji.query.graphql new file mode 100644 index 00000000000..cdf8c7cad04 --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/group_award_emoji.query.graphql @@ -0,0 +1,27 @@ +#import "~/graphql_shared/fragments/page_info.fragment.graphql" +#import "~/work_items/graphql/award_emoji.fragment.graphql" + +query groupWorkItemAwardEmojis($fullPath: ID!, $iid: String, $after: String, $pageSize: Int) { + workspace: group(fullPath: $fullPath) { + id + workItems(iid: $iid) { + nodes { + id + iid + widgets { + ... on WorkItemWidgetAwardEmoji { + type + awardEmoji(first: $pageSize, after: $after) { + pageInfo { + ...PageInfo + } + nodes { + ...AwardEmojiFragment + } + } + } + } + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/group_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/group_work_items.query.graphql new file mode 100644 index 00000000000..5332e21a0cb --- /dev/null +++ b/app/assets/javascripts/work_items/graphql/group_work_items.query.graphql @@ -0,0 +1,17 @@ +query groupWorkItems( + $searchTerm: String + $fullPath: ID! + $types: [IssueType!] + $in: [IssuableSearchableField!] +) { + workspace: group(fullPath: $fullPath) { + id + workItems(search: $searchTerm, types: $types, in: $in) { + nodes { + id + iid + title + } + } + } +} diff --git a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql index 2be436aa8c2..3aeaaa1116a 100644 --- a/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql +++ b/app/assets/javascripts/work_items/graphql/project_work_items.query.graphql @@ -3,6 +3,8 @@ query projectWorkItems( $fullPath: ID! $types: [IssueType!] $in: [IssuableSearchableField!] + $iid: String = null + $isNumber: Boolean! ) { workspace: project(fullPath: $fullPath) { id @@ -11,8 +13,13 @@ query projectWorkItems( id iid title - state - confidential + } + } + workItemsByIid: workItems(iid: $iid, types: $types) @include(if: $isNumber) { + nodes { + id + iid + title } } } diff --git a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql index fac99310890..ef43b9c026d 100644 --- a/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql +++ b/app/assets/javascripts/work_items/graphql/work_item.fragment.graphql @@ -4,6 +4,7 @@ fragment WorkItem on WorkItem { id iid + archived title state description @@ -13,10 +14,9 @@ fragment WorkItem on WorkItem { closedAt reference(full: true) createNoteEmail - project { + namespace { id fullPath - archived name } author { diff --git a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue index a853018a931..58f74dccd4d 100644 --- a/app/assets/javascripts/work_items/list/components/work_items_list_app.vue +++ b/app/assets/javascripts/work_items/list/components/work_items_list_app.vue @@ -1,5 +1,5 @@ <script> -import * as Sentry from '@sentry/browser'; +import * as Sentry from '~/sentry/sentry_browser_wrapper'; import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; import { STATUS_OPEN } from '~/issues/constants'; diff --git a/app/assets/javascripts/work_items/pages/create_work_item.vue b/app/assets/javascripts/work_items/pages/create_work_item.vue index 31e790254d9..435a1233dce 100644 --- a/app/assets/javascripts/work_items/pages/create_work_item.vue +++ b/app/assets/javascripts/work_items/pages/create_work_item.vue @@ -103,7 +103,7 @@ export default { data: { workspace: { __typename: TYPENAME_PROJECT, - id: workItem.project.id, + id: workItem.namespace.id, workItems: { __typename: 'WorkItemConnection', nodes: [workItem], |