diff options
Diffstat (limited to 'app')
1433 files changed, 14415 insertions, 12343 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], diff --git a/app/assets/stylesheets/application.scss b/app/assets/stylesheets/application.scss index 40228b93e01..ce8ccb2bc08 100644 --- a/app/assets/stylesheets/application.scss +++ b/app/assets/stylesheets/application.scss @@ -31,7 +31,3 @@ @media print { @import 'print'; } - -/* Rules for overriding cloaking in startup-general.scss */ -@import 'startup/cloaking'; -@include cloak-startup-scss(block); diff --git a/app/assets/stylesheets/application_utilities.scss b/app/assets/stylesheets/application_utilities.scss index 817e983a0ec..8bec12784ed 100644 --- a/app/assets/stylesheets/application_utilities.scss +++ b/app/assets/stylesheets/application_utilities.scss @@ -10,3 +10,5 @@ // Gitlab UI util classes @import '@gitlab/ui/src/scss/utilities'; + +@import 'tmp_utilities';
\ No newline at end of file diff --git a/app/assets/stylesheets/components/detail_page.scss b/app/assets/stylesheets/components/detail_page.scss index de8142924f9..a5fd57f6c57 100644 --- a/app/assets/stylesheets/components/detail_page.scss +++ b/app/assets/stylesheets/components/detail_page.scss @@ -36,7 +36,6 @@ } .detail-page-header-actions { - align-self: center; flex: 0 0 auto; &:not(.is-merge-request) { @@ -67,6 +66,8 @@ } .description { + @include clearfix; + margin-top: 6px; } diff --git a/app/assets/stylesheets/framework/buttons.scss b/app/assets/stylesheets/framework/buttons.scss index 8a64b0999b6..88509dbc4a1 100644 --- a/app/assets/stylesheets/framework/buttons.scss +++ b/app/assets/stylesheets/framework/buttons.scss @@ -191,25 +191,6 @@ color: $gray-700; } - // deprecated class - &.btn-text-field { - width: 100%; - text-align: left; - padding: 6px 16px; - border-color: $border-color; - color: $gray-darkest; - background-color: $white; - - &:hover, - &:active, - &:focus { - cursor: text; - box-shadow: none; - border-color: lighten($blue-300, 20%); - color: $gray-darkest; - } - } - &.dot-highlight::after { content: ''; background-color: $blue-500; diff --git a/app/assets/stylesheets/framework/diffs.scss b/app/assets/stylesheets/framework/diffs.scss index 4bf109a0bff..8f07ef73554 100644 --- a/app/assets/stylesheets/framework/diffs.scss +++ b/app/assets/stylesheets/framework/diffs.scss @@ -901,7 +901,6 @@ table.code { @media (max-width: map-get($grid-breakpoints, lg)-1) { .diffs .files { - @include fixed-width-container; flex-direction: column; } diff --git a/app/assets/stylesheets/framework/files.scss b/app/assets/stylesheets/framework/files.scss index 613e504c771..eb627b036fe 100644 --- a/app/assets/stylesheets/framework/files.scss +++ b/app/assets/stylesheets/framework/files.scss @@ -247,6 +247,7 @@ span.idiff { border-bottom: 1px solid $border-color; padding: $gl-padding-8 $gl-padding; margin: 0; + min-height: px-to-rem(42px); border-radius: $border-radius-default $border-radius-default 0 0; @include media-breakpoint-up(md) { diff --git a/app/assets/stylesheets/framework/header.scss b/app/assets/stylesheets/framework/header.scss index 32735679ded..e269ea68e41 100644 --- a/app/assets/stylesheets/framework/header.scss +++ b/app/assets/stylesheets/framework/header.scss @@ -630,11 +630,18 @@ $search-input-field-x-min-width: 200px; header.navbar-gitlab.super-sidebar-logged-out { background-color: $brand-charcoal !important; + li.nav-item > button, li.nav-item > a { - @include gl-text-white; + @include gl-text-gray-100; @include gl-font-weight-normal; &:hover, + &:focus, + &:active { + @include gl-text-white + } + + &:hover, &:focus { background-color: $brand-gray-04; text-decoration: none; diff --git a/app/assets/stylesheets/framework/highlight.scss b/app/assets/stylesheets/framework/highlight.scss index a63ce66e681..a93c2191016 100644 --- a/app/assets/stylesheets/framework/highlight.scss +++ b/app/assets/stylesheets/framework/highlight.scss @@ -33,7 +33,7 @@ padding-right: 10px; white-space: pre; - &:empty::before { + &:empty::before, span:empty::before { content: '\200b'; } } diff --git a/app/assets/stylesheets/framework/icons.scss b/app/assets/stylesheets/framework/icons.scss index 37a2264122d..bfd55fbb53d 100644 --- a/app/assets/stylesheets/framework/icons.scss +++ b/app/assets/stylesheets/framework/icons.scss @@ -1,55 +1,37 @@ -@mixin icon-styles($primary-color, $svg-color) { +@mixin icon-styles($color) { svg, .gl-icon { - fill: $primary-color; - } - - // For the pipeline mini graph, we pass a custom 'gl-border' so that we can enforce - // a border of 1px instead of the thicker svg borders to adhere to design standards. - // If we implement the component with 'isBorderless' and also pass that border, - // this css is to dynamically apply the correct border color for those specific icons. - &.borderless { - border-color: $primary-color; - } - - &.interactive { - &:hover { - background: $svg-color; - } - - &:hover, - &.active { - box-shadow: 0 0 0 1px $primary-color; - } + fill: $color; } } .ci-status-icon-success, .ci-status-icon-passed { - @include icon-styles($green-500, $green-100); + @include icon-styles($green-500); } .ci-status-icon-error, .ci-status-icon-failed { - @include icon-styles($red-500, $red-100); + @include icon-styles($red-500); } .ci-status-icon-pending, .ci-status-icon-waiting-for-resource, +.ci-status-icon-waiting-for-callback, .ci-status-icon-failed-with-warnings, .ci-status-icon-success-with-warnings { - @include icon-styles($orange-500, $orange-100); + @include icon-styles($orange-500); } .ci-status-icon-running { - @include icon-styles($blue-500, $blue-100); + @include icon-styles($blue-500); } .ci-status-icon-canceled, .ci-status-icon-disabled, .ci-status-icon-scheduled, .ci-status-icon-manual { - @include icon-styles($gray-900, $gray-100); + @include icon-styles($gray-900); } .ci-status-icon-notification, @@ -57,7 +39,58 @@ .ci-status-icon-created, .ci-status-icon-skipped, .ci-status-icon-notfound { - @include icon-styles($gray-500, $gray-100); + @include icon-styles($gray-500); +} + +.ci-icon { + // .ci-icon class is used at + // - app/assets/javascripts/vue_shared/components/ci_icon.vue + // - app/helpers/ci/status_helper.rb + .ci-icon-gl-icon-wrapper { + @include gl-rounded-full; + @include gl-line-height-0; + } + + // Makes the borderless CI icons appear slightly bigger than the default 16px. + // Could be fixed by making the SVG fill up the canvas in a follow up issue. + .gl-icon { + // fill: currentColor; + width: 20px; + height: 20px; + margin: -2px; + } + + @mixin ci-icon-style($bg-color, $color, $gl-dark-bg-color: null, $gl-dark-color: null) { + .ci-icon-gl-icon-wrapper { + background-color: $bg-color; + color: $color; + + .gl-dark & { + background-color: $gl-dark-bg-color; + color: $gl-dark-color; + } + } + } + + &.ci-icon-variant-success { + @include ci-icon-style($green-500, $white, $green-600, $green-50) + } + + &.ci-icon-variant-warning { + @include ci-icon-style($orange-500, $white, $orange-600, $orange-50) + } + + &.ci-icon-variant-danger { + @include ci-icon-style($red-500, $white, $red-600, $red-50) + } + + &.ci-icon-variant-info { + @include ci-icon-style($white, $blue-500, $blue-600, $blue-50) + } + + &.ci-icon-variant-neutral { + @include ci-icon-style($white, $gray-500) + } } .password-status-icon-success { diff --git a/app/assets/stylesheets/framework/layout.scss b/app/assets/stylesheets/framework/layout.scss index 171f070d776..33c8a0254fd 100644 --- a/app/assets/stylesheets/framework/layout.scss +++ b/app/assets/stylesheets/framework/layout.scss @@ -4,12 +4,6 @@ html { &.touch .tooltip { display: none !important; } - - @include media-breakpoint-up(sm) { - &.logged-out-marketing-header { - --header-height: 72px; - } - } } body { diff --git a/app/assets/stylesheets/framework/mixins.scss b/app/assets/stylesheets/framework/mixins.scss index df107798a87..0f6fdf18ea0 100644 --- a/app/assets/stylesheets/framework/mixins.scss +++ b/app/assets/stylesheets/framework/mixins.scss @@ -30,15 +30,6 @@ max-width: $max-width; } -/** - * Mixin for fixed width container - */ -@mixin fixed-width-container { - max-width: $limited-layout-width - ($gl-padding * 2); - margin-left: auto; - margin-right: auto; -} - /* * Base mixin for lists in GitLab */ diff --git a/app/assets/stylesheets/framework/page_header.scss b/app/assets/stylesheets/framework/page_header.scss index c2bd475ab90..ad183a64cc5 100644 --- a/app/assets/stylesheets/framework/page_header.scss +++ b/app/assets/stylesheets/framework/page_header.scss @@ -34,12 +34,4 @@ margin-left: 8px; } } - - .ci-status-link { - svg { - position: relative; - top: 2px; - margin: 0 2px 0 3px; - } - } } diff --git a/app/assets/stylesheets/framework/sidebar.scss b/app/assets/stylesheets/framework/sidebar.scss index 0619d5f166e..168aa704a69 100644 --- a/app/assets/stylesheets/framework/sidebar.scss +++ b/app/assets/stylesheets/framework/sidebar.scss @@ -2,7 +2,11 @@ width: 100%; .container-fluid { - padding: 0 $gl-padding; + padding: 0 $container-margin; + + @include media-breakpoint-up(xl) { + padding: 0 $container-margin-xl; + } &.container-blank { background: none; diff --git a/app/assets/stylesheets/framework/variables.scss b/app/assets/stylesheets/framework/variables.scss index a4bb39e0764..ab8547c3fef 100644 --- a/app/assets/stylesheets/framework/variables.scss +++ b/app/assets/stylesheets/framework/variables.scss @@ -468,8 +468,10 @@ $content-wrapper-padding: 100px; $header-zindex: 1000; $zindex-dropdown-menu: 300; $ide-statusbar-height: 25px; -$fixed-layout-width: 1280px; -$limited-layout-width: 990px; +$limited-layout-width: 1006px; +$fixed-layout-width: 1296px; +$container-margin: $gl-padding; +$container-margin-xl: $gl-padding-24; $container-text-max-width: 540px; $border-radius-default: 4px; $border-radius-small: 2px; @@ -485,7 +487,7 @@ $performance-bar-height: 2.5rem; $system-header-height: 16px; $system-footer-height: $system-header-height; $mr-sticky-header-height: 72px; -$mr-review-bar-height: calc(2rem + 13px); +$mr-review-bar-height: calc(2rem + 16px); $flash-height: 52px; $context-header-height: 60px; $top-bar-height: 48px; @@ -655,8 +657,8 @@ $status-icon-size: 22px; */ $discord: #5865f2; $linkedin: #2867b2; +$mastodon: #6364ff; $skype: #0078d7; -$twitter: #1d9bf0; /* * Award emoji @@ -715,10 +717,10 @@ $blame-blue: #254e77; */ $builds-log-bg: #111; $job-log-highlight-height: 18px; -$job-log-line-padding: 55px; +$job-log-line-padding: 63px; $job-line-number-width: 50px; -$job-line-number-margin: 43px; -$job-arrow-margin: 55px; +$job-line-number-margin: 51px; +$job-arrow-margin: 63px; /* * Calendar @@ -810,7 +812,7 @@ $ci-action-icon-size: 22px; $ci-action-icon-size-lg: 24px; $pipeline-dropdown-line-height: 20px; $ci-action-dropdown-button-size: 24px; -$ci-action-dropdown-svg-size: 12px; +$ci-action-dropdown-svg-size: 16px; /* CI variable lists diff --git a/app/assets/stylesheets/page_bundles/_system_note_styles.scss b/app/assets/stylesheets/page_bundles/_system_note_styles.scss new file mode 100644 index 00000000000..68e2b747c52 --- /dev/null +++ b/app/assets/stylesheets/page_bundles/_system_note_styles.scss @@ -0,0 +1,59 @@ +/** +Shared styles for system note dot and icon styles used for MR, Issue, Work Item +*/ +.system-note-tiny-dot { + width: 8px; + height: 8px; + margin-top: 6px; + margin-left: 12px; + margin-right: 8px; + border: 2px solid var(--gray-50, $gray-50); + } + + .system-note-icon { + width: 20px; + height: 20px; + margin-left: 6px; + + &.gl-bg-green-100 { + --bg-color: var(--green-100, #{$green-100}); + } + + &.gl-bg-red-100 { + --bg-color: var(--red-100, #{$red-100}); + } + + &.gl-bg-blue-100 { + --bg-color: var(--blue-100, #{$blue-100}); + } + } + + .system-note-icon:not(.mr-system-note-empty)::before { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + bottom: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, transparent, var(--bg-color)); + + .system-note:first-child & { + display: none; + } + } + + .system-note-icon:not(.mr-system-note-empty)::after { + content: ''; + display: block; + position: absolute; + left: calc(50% - 1px); + top: 100%; + width: 2px; + height: 20px; + background: linear-gradient(to bottom, var(--bg-color), transparent); + + .system-note:last-child & { + display: none; + } + }
\ No newline at end of file diff --git a/app/assets/stylesheets/page_bundles/boards.scss b/app/assets/stylesheets/page_bundles/boards.scss index 5aca697ae26..22e42d0a7f7 100644 --- a/app/assets/stylesheets/page_bundles/boards.scss +++ b/app/assets/stylesheets/page_bundles/boards.scss @@ -39,6 +39,12 @@ width: 400px; } + &.board-add-new-list { + @include media-breakpoint-down(sm) { + width: 100%; + } + } + &.is-collapsed { .board-title-text > span, .issue-count-badge > span { diff --git a/app/assets/stylesheets/page_bundles/branches.scss b/app/assets/stylesheets/page_bundles/branches.scss index daf828fb559..973ba1afb17 100644 --- a/app/assets/stylesheets/page_bundles/branches.scss +++ b/app/assets/stylesheets/page_bundles/branches.scss @@ -42,6 +42,10 @@ .branches-list .branch-item:not(:last-of-type) { border-bottom: 1px solid $border-color; + + .gl-dark & { + border-bottom-color: $gray-800; + } } .branch-item { diff --git a/app/assets/stylesheets/page_bundles/build.scss b/app/assets/stylesheets/page_bundles/build.scss index 16fc0e7ebae..6165ee6e8b4 100644 --- a/app/assets/stylesheets/page_bundles/build.scss +++ b/app/assets/stylesheets/page_bundles/build.scss @@ -48,14 +48,6 @@ border: 1px solid var(--border-color, $border-color); padding: 8px $gl-padding 12px; border-radius: $border-radius-default; - - svg { - position: relative; - top: 3px; - margin-right: 5px; - width: 22px; - height: 22px; - } } .build-loader-animation { diff --git a/app/assets/stylesheets/page_bundles/ci_status.scss b/app/assets/stylesheets/page_bundles/ci_status.scss index 17886ab954a..f2129aa6841 100644 --- a/app/assets/stylesheets/page_bundles/ci_status.scss +++ b/app/assets/stylesheets/page_bundles/ci_status.scss @@ -48,6 +48,7 @@ &.ci-pending, &.ci-waiting-for-resource, + &.ci-waiting-for-callback, &.ci-failed-with-warnings, &.ci-success-with-warnings { @include status-color( diff --git a/app/assets/stylesheets/page_bundles/issuable.scss b/app/assets/stylesheets/page_bundles/issuable.scss index 07614c5271a..05563f8e314 100644 --- a/app/assets/stylesheets/page_bundles/issuable.scss +++ b/app/assets/stylesheets/page_bundles/issuable.scss @@ -1,33 +1,5 @@ @import 'mixins_and_variables_and_functions'; - -.limit-container-width { - .flash-container, - .detail-page-header, - .page-content-header, - .commit-box, - .info-well, - .commit-ci-menu, - .files-changed-inner, - .limited-header-width, - .limited-width-notes { - @include fixed-width-container; - } - - .issuable-details { - .detail-page-description, - .mr-source-target, - .mr-state-widget, - .merge-manually { - @include fixed-width-container; - } - } - - .merge-request-details { - .emoji-list-container { - @include fixed-width-container; - } - } -} +@import 'system_note_styles'; .issuable-details { section { @@ -114,29 +86,6 @@ } } -/* - * Following overrides are done to prevent - * legacy dropdown styles from influencing - * GitLab UI components used within GlDropdown - */ -.issuable-move-dropdown { - .b-dropdown-form { - @include gl-p-0; - } - - .gl-search-box-by-type button.gl-clear-icon-button:hover { - @include gl-bg-transparent; - - &:focus { - @include gl-focus($inset: true); - } - } - - .issuable-move-button:not(.disabled):hover { - @include gl-text-white; - } -} - .suggestion-footer { font-size: 12px; line-height: 15px; diff --git a/app/assets/stylesheets/page_bundles/merge_request.scss b/app/assets/stylesheets/page_bundles/merge_request.scss index e429c0c149e..8dc4401e72c 100644 --- a/app/assets/stylesheets/page_bundles/merge_request.scss +++ b/app/assets/stylesheets/page_bundles/merge_request.scss @@ -88,20 +88,6 @@ $comparison-empty-state-height: 62px; .merge-request-title { margin-bottom: 2px; - - .ci-status-link { - svg { - height: 16px; - width: 16px; - position: relative; - top: 3px; - } - - &:hover, - &:focus { - text-decoration: none; - } - } } } } @@ -147,10 +133,6 @@ $comparison-empty-state-height: 62px; padding: 0; background: transparent; } - - .ci-status-link { - margin-right: 5px; - } } .merge-request-select { diff --git a/app/assets/stylesheets/page_bundles/merge_requests.scss b/app/assets/stylesheets/page_bundles/merge_requests.scss index b00e1813696..847cd3f2ff4 100644 --- a/app/assets/stylesheets/page_bundles/merge_requests.scss +++ b/app/assets/stylesheets/page_bundles/merge_requests.scss @@ -258,15 +258,15 @@ $tabs-holder-z-index: 250; position: sticky; top: calc(#{$calc-application-header-height} + #{$mr-tabs-height} + #{$diff-file-header-top}); + // height calc is fully delegated to the tree_list_height.vue component + height: 0; min-height: 300px; - height: calc(#{$calc-application-viewport-height} - (#{$mr-tabs-height} + #{$diff-file-header-top})); .drag-handle { bottom: 16px; } &.is-sidebar-moved { - height: calc(#{$calc-application-viewport-height} - (#{$mr-sticky-header-height} + #{$diff-file-header-top})); top: calc(#{$calc-application-header-height} + #{$mr-sticky-header-height} + #{$diff-file-header-top}); } } @@ -379,6 +379,10 @@ $tabs-holder-z-index: 250; .deployment-info { margin-bottom: $gl-padding-8; } + + .gl-button { + margin-left: 0; + } } > *:not(:last-child) { @@ -645,6 +649,9 @@ $tabs-holder-z-index: 250; // to the end of the line or to force it to a // new line if there is not enough space. flex-grow: 999; + // Avoid layout shift of title when Mini Graph + // moves below title + padding-top: 5px; } .label-branch { @@ -981,7 +988,7 @@ $tabs-holder-z-index: 250; .merge-request-tabs-container { &.is-merge-request { @include gl-mx-auto; - max-width: $fixed-layout-width - ($gl-padding * 2); + max-width: $fixed-layout-width - ($container-margin-xl * 2); } } } @@ -994,24 +1001,13 @@ $tabs-holder-z-index: 250; } } -.submit-review-dropdown { - &.show .dropdown-menu { - width: calc(100vw - 20px); - max-width: 680px; - max-height: calc(100vh - 50px); - - .gl-dropdown-inner { - max-height: none !important; - } - } - - .gl-dropdown-contents { - padding: $gl-spacing-scale-4 !important; - } +.submit-review-dropdown .gl-new-dropdown-panel { + max-width: none; +} - .md-preview-holder { - max-height: 182px; - } +.submit-review-dropdown-form { + width: calc(100vw - 20px); + max-width: 680px; } .submit-review-dropdown-animated { @@ -1112,7 +1108,7 @@ $tabs-holder-z-index: 250; display: flex; align-items: center; width: 100%; - height: $toggle-sidebar-height; + height: var(--mr-review-bar-height); padding-left: $contextual-sidebar-width; padding-right: $right-sidebar-collapsed-width; background: var(--white, $white); @@ -1128,14 +1124,14 @@ $tabs-holder-z-index: 250; padding-right: 0; } - .dropdown { + .submit-review-dropdown { margin-left: $grid-size; } } .review-bar-content { max-width: $limited-layout-width; - padding: 0 $gl-padding; + padding: 0 $container-margin; width: 100%; margin: 0 auto; } @@ -1198,63 +1194,6 @@ $tabs-holder-z-index: 250; } } -.mr-system-note-icon { - width: 20px; - height: 20px; - margin-left: 6px; - - &.gl-bg-green-100 { - --bg-color: var(--green-100, #{$green-100}); - } - - &.gl-bg-red-100 { - --bg-color: var(--red-100, #{$red-100}); - } - - &.gl-bg-blue-100 { - --bg-color: var(--blue-100, #{$blue-100}); - } -} - -.mr-system-note-icon:not(.mr-system-note-empty)::before { - content: ''; - display: block; - position: absolute; - left: calc(50% - 1px); - bottom: 100%; - width: 2px; - height: 20px; - background: linear-gradient(to bottom, transparent, var(--bg-color)); - - .system-note:first-child & { - display: none; - } -} - -.mr-system-note-icon:not(.mr-system-note-empty)::after { - content: ''; - display: block; - position: absolute; - left: calc(50% - 1px); - top: 100%; - width: 2px; - height: 20px; - background: linear-gradient(to bottom, var(--bg-color), transparent); - - .system-note:last-child & { - display: none; - } -} - -.mr-system-note-empty { - width: 8px; - height: 8px; - margin-top: 6px; - margin-left: 12px; - margin-right: 8px; - border: 2px solid var(--gray-50, $gray-50); -} - .diff-file-discussions-wrapper { @include gl-w-full; diff --git a/app/assets/stylesheets/page_bundles/pipeline.scss b/app/assets/stylesheets/page_bundles/pipeline.scss index 98e9e2b3c27..aaec277cf08 100644 --- a/app/assets/stylesheets/page_bundles/pipeline.scss +++ b/app/assets/stylesheets/page_bundles/pipeline.scss @@ -125,21 +125,27 @@ // They are here to still access a variable or because they use magic values. // scoped to the graph. Do not add other styles. .gl-pipeline-min-h { - min-height: $dropdown-max-height-lg; + min-height: calc(#{$dropdown-max-height-lg} + #{$gl-spacing-scale-6}); } .gl-pipeline-job-width { width: 100%; - max-width: 400px; } .gl-pipeline-job-width\! { width: 100% !important; - max-width: 400px !important; } .gl-downstream-pipeline-job-width { width: 8rem; + + .pipeline-graph-container & { + width: 100%; + + @media (min-width: $breakpoint-sm) { + width: 8rem; + } + } } .gl-linked-pipeline-padding { @@ -154,8 +160,8 @@ // Action Icons in big pipeline-graph nodes &.ci-action-icon-wrapper { - height: 30px; - width: 30px; + height: 24px; + width: 24px; border-radius: 100%; display: block; padding: 0; @@ -163,6 +169,10 @@ } } +.stage-column-title .gl-ci-action-icon-container { + right: 11px; +} + .split-report-section { border-bottom: 1px solid var(--gray-50, $gray-50); @@ -242,3 +252,69 @@ } } } + +.pipeline-graph-container { + .stage-column.is-stage-view:not(:last-of-type)::after { + content: ""; + position: absolute; + top: 100%; + left: $gl-spacing-scale-6; + width: 2px; + height: $gl-spacing-scale-5 * 2; + background-color: $gray-200; + + @media (min-width: $breakpoint-sm) { + top: 1.25rem; + left: 100%; + width: $gl-spacing-scale-5 * 2; + height: 2px; + } + } + + .stage-column, + .stage-column.is-stage-view { + min-width: 1px; + + @media (min-width: $breakpoint-sm) { + min-width: inherit; + max-width: $gl-spacing-scale-48; + + &:first-of-type { + margin-left: $gl-spacing-scale-6; + } + } + } + + .linked-pipeline-container[aria-expanded=true] { + @media (max-width: $breakpoint-sm) { + width: 100%; + + > div { + border-bottom-left-radius: 0; + } + + > div > button { + border-bottom-right-radius: 0 !important; + } + } + } + + .linked-pipelines-column, + .pipeline-show-container, + .pipeline-links-container { + @media (max-width: $breakpoint-sm) { + width: 100%; + } + } + + .pipeline-graph { + @media (max-width: $breakpoint-sm) { + border-top-left-radius: 0; + border-top-right-radius: 0; + } + } + + .pipeline-graph .pipeline-graph { + background-color: $gray-100; + } +} diff --git a/app/assets/stylesheets/page_bundles/pipelines.scss b/app/assets/stylesheets/page_bundles/pipelines.scss index f9c49b0e6ca..bcc0ad112ac 100644 --- a/app/assets/stylesheets/page_bundles/pipelines.scss +++ b/app/assets/stylesheets/page_bundles/pipelines.scss @@ -14,10 +14,6 @@ // - app/assets/javascripts/commit/pipelines/pipelines_bundle.js .pipelines { - .badge { - margin-bottom: 3px; - } - .pipeline-actions { min-width: 170px; //Guarantees buttons don't break in several lines. diff --git a/app/assets/stylesheets/page_bundles/profile.scss b/app/assets/stylesheets/page_bundles/profile.scss index dbe82f583d1..2c08db048fd 100644 --- a/app/assets/stylesheets/page_bundles/profile.scss +++ b/app/assets/stylesheets/page_bundles/profile.scss @@ -235,13 +235,17 @@ } .twitter-icon { - color: $twitter; + color: var(--gl-text-color, $gl-text-color); } .discord-icon { color: $discord; } +.mastodon-icon { + color: $mastodon; +} + .key-created-at { line-height: 42px; } diff --git a/app/assets/stylesheets/page_bundles/projects.scss b/app/assets/stylesheets/page_bundles/projects.scss index 99c84026762..d252afd0b29 100644 --- a/app/assets/stylesheets/page_bundles/projects.scss +++ b/app/assets/stylesheets/page_bundles/projects.scss @@ -320,10 +320,6 @@ } } - .ci-status-link { - @include gl-text-decoration-none; - } - &:not(.compact) { .controls { @include media-breakpoint-up(lg) { @@ -369,10 +365,6 @@ } } } - - .ci-status-link { - @include gl-display-inline-flex; - } } .icon-container { diff --git a/app/assets/stylesheets/page_bundles/wiki.scss b/app/assets/stylesheets/page_bundles/wiki.scss index 4fb07328493..81e6b4c1191 100644 --- a/app/assets/stylesheets/page_bundles/wiki.scss +++ b/app/assets/stylesheets/page_bundles/wiki.scss @@ -148,7 +148,8 @@ margin: 0; } - ul.wiki-pages ul { + ul.wiki-pages ul, + ul.wiki-pages li:not(.wiki-directory){ padding-left: 20px; } @@ -161,6 +162,16 @@ } } +.right-sidebar.wiki-sidebar { + .active > .wiki-list { + a, + .wiki-list-expand-button, + .wiki-list-collapse-button { + color: $white; + } + } +} + ul.wiki-pages-list.content-list { a { color: var(--blue-600, $blue-600); diff --git a/app/assets/stylesheets/page_bundles/work_items.scss b/app/assets/stylesheets/page_bundles/work_items.scss index 01c6fde80da..ec73f27ed09 100644 --- a/app/assets/stylesheets/page_bundles/work_items.scss +++ b/app/assets/stylesheets/page_bundles/work_items.scss @@ -1,7 +1,8 @@ @import 'mixins_and_variables_and_functions'; +@import 'system_note_styles'; $work-item-field-inset-shadow: inset 0 0 0 $gl-border-size-1 var(--gray-200, $gray-200) !important; -$work-item-overview-right-sidebar-width: 340px; +$work-item-overview-right-sidebar-width: 23rem; $work-item-sticky-header-height: 52px; .gl-token-selector-token-container { @@ -67,6 +68,7 @@ $work-item-sticky-header-height: 52px; } } +//TODO: remove all the styles related to `gl-dropdown` when all `.work-item-dropdown`s are migrated .work-item-dropdown { // duplicate classname because we are fighting with gl-button styles .gl-dropdown-toggle.gl-dropdown-toggle { @@ -95,24 +97,25 @@ $work-item-sticky-header-height: 52px; // need to override the listbox styles to match with dropdown // till the dropdown are converted to listbox - .gl-new-dropdown-toggle { + .gl-new-dropdown-toggle.gl-new-dropdown-toggle { &:hover, &:focus { - background: none !important; box-shadow: $work-item-field-inset-shadow; background-color: $input-bg; - } - .is-not-focused { - &.gl-new-dropdown-button-text { - margin: 0 0.25rem; + .gl-dark & { + // $input-bg is overridden in dark mode but that does not + // work in page bundles currently, manually override here + background-color: var(--gray-50, $input-bg); } } - } - .gl-new-dropdown-toggle.is-not-focused { - .gl-new-dropdown-button-text { - margin: 0 0.25rem; + &:not(:hover, :focus) { + box-shadow: none; + + .gl-new-dropdown-chevron { + visibility: hidden; + } } } diff --git a/app/assets/stylesheets/pages/commits.scss b/app/assets/stylesheets/pages/commits.scss index 8b093e7bb7b..72ea586979f 100644 --- a/app/assets/stylesheets/pages/commits.scss +++ b/app/assets/stylesheets/pages/commits.scss @@ -131,7 +131,7 @@ } .committer { - color: $gl-text-color-tertiary; + color: $gl-text-color-secondary; .commit-author-link { color: $gl-text-color; @@ -144,7 +144,6 @@ vertical-align: text-bottom; } - > .ci-status-link, > .btn, > .commit-sha-group { margin-left: $gl-padding; diff --git a/app/assets/stylesheets/pages/issues.scss b/app/assets/stylesheets/pages/issues.scss index 36efe42aed1..e82a689fe5d 100644 --- a/app/assets/stylesheets/pages/issues.scss +++ b/app/assets/stylesheets/pages/issues.scss @@ -81,17 +81,6 @@ ul.related-merge-requests > li gl-emoji { } } -.related-merge-requests { - .ci-status-link { - display: block; - margin-right: 5px; - } - - svg { - display: block; - } -} - @include media-breakpoint-down(xs) { .detail-page-header { .issuable-meta { @@ -262,6 +251,14 @@ ul.related-merge-requests > li gl-emoji { } } +.issue-sticky-header-text { + padding: 0 $container-margin; + + @include media-breakpoint-up(xl) { + padding: 0 $container-margin-xl; + } +} + .issuable-header-slide-enter-active, .issuable-header-slide-leave-active { @include gl-transition-medium; diff --git a/app/assets/stylesheets/pages/notes.scss b/app/assets/stylesheets/pages/notes.scss index 2722893d04c..8e0fab04ab2 100644 --- a/app/assets/stylesheets/pages/notes.scss +++ b/app/assets/stylesheets/pages/notes.scss @@ -10,16 +10,13 @@ $icon-size-diff: $avatar-icon-size - $system-note-icon-size; $system-note-icon-m-top: $avatar-m-top + $icon-size-diff - 1.3rem; $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; -@mixin vertical-line($left) { - &::before { - content: ''; - border-left: 2px solid var(--gray-50, $gray-50); - position: absolute; - top: 16px; - bottom: 0; - left: calc(#{$left} - 1px); - height: calc(100% + 20px); - } +@mixin vertical-line($top, $left) { + content: ''; + position: absolute; + width: 2px; + left: $left; + top: $top; + height: calc(100% - #{$top}); } @mixin outline-comment() { @@ -32,12 +29,7 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; .limited-width-notes { .main-notes-list::before, .timeline-entry:last-child::before { - content: ''; - position: absolute; - width: 2px; - left: 15px; - top: 15px; - height: calc(100% - 15px); + @include vertical-line(15px, 15px); } .main-notes-list::before { @@ -1143,6 +1135,24 @@ $system-note-icon-m-left: $avatar-m-left + $icon-size-diff / $avatar-m-ratio; } } +.user-activity-content { + &::before { + @include vertical-line(80px, 25px); + background: var(--gray-50, $gray-50); + } + + .system-note-image { + @include gl--flex-center; + top: 14px; + width: 22px; + height: 22px; + + svg { + fill: $gray-600 !important; + } + } +} + //This needs to be deleted when Snippet/Commit comments are convered to Vue // See https://gitlab.com/gitlab-org/gitlab-foss/issues/53918#note_117038785 .unstyled-comments { diff --git a/app/assets/stylesheets/print.scss b/app/assets/stylesheets/print.scss index c3662c3e6ea..3015cfec34f 100644 --- a/app/assets/stylesheets/print.scss +++ b/app/assets/stylesheets/print.scss @@ -67,7 +67,6 @@ nav.navbar-collapse.collapse, .nav, .btn, ul.notes-form, -.ci-status-link::after, .issuable-gutter-toggle, .gutter-toggle, .issuable-details .content-block-small, diff --git a/app/assets/stylesheets/startup/_cloaking.scss b/app/assets/stylesheets/startup/_cloaking.scss deleted file mode 100644 index f60d72a51fb..00000000000 --- a/app/assets/stylesheets/startup/_cloaking.scss +++ /dev/null @@ -1,15 +0,0 @@ -/** - Prevent flashing of content when using startup.css - */ -@mixin cloak-startup-scss($display) { - // General selector for cloaking until ready - .cloak-startup, - // Breadcrumbs and alerts on the top of the page - .content-wrapper > .alert-wrapper, - // Content on pages - #content-body, - // Prevent flashing of haml generated modal contents - .modal-dialog { - display: $display; - } -} diff --git a/app/assets/stylesheets/startup/startup-dark.scss b/app/assets/stylesheets/startup/startup-dark.scss deleted file mode 100644 index 60cbcffd506..00000000000 --- a/app/assets/stylesheets/startup/startup-dark.scss +++ /dev/null @@ -1,1928 +0,0 @@ -// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" -// Please see the feedback issue for more details and help: -// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 -@charset "UTF-8"; -:root { - --white: #333238; -} -*, -*::before, -*::after { - box-sizing: border-box; -} -html { - font-family: sans-serif; - line-height: 1.15; -} -aside, -header { - display: block; -} -body { - margin: 0; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #ececef; - text-align: left; - background-color: #1f1e24; -} -ul { - margin-top: 0; - margin-bottom: 1rem; -} -ul ul { - margin-bottom: 0; -} -strong { - font-weight: bolder; -} -a { - color: #428fdc; - text-decoration: none; - background-color: transparent; -} -a:not([href]):not([class]) { - color: inherit; - text-decoration: none; -} -kbd { - font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono", - "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", - "Courier New", "andale mono", "lucida console", monospace; - font-size: 1em; -} -img { - vertical-align: middle; - border-style: none; -} -svg { - overflow: hidden; - vertical-align: middle; -} -button { - border-radius: 0; -} -input, -button { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} -button, -input { - overflow: visible; -} -button { - text-transform: none; -} -[role="button"] { - cursor: pointer; -} -button:not(:disabled), -[type="button"]:not(:disabled) { - cursor: pointer; -} -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner { - padding: 0; - border-style: none; -} -[type="search"] { - outline-offset: -2px; -} -.list-unstyled { - padding-left: 0; - list-style: none; -} -kbd { - padding: 0.2rem 0.4rem; - font-size: 90%; - color: #333238; - background-color: #ececef; - border-radius: 0.2rem; -} -kbd kbd { - padding: 0; - font-size: 100%; - font-weight: 600; -} -.container-fluid { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} -.form-control { - display: block; - width: 100%; - height: 32px; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - font-weight: 400; - line-height: 1.5; - color: #ececef; - background-color: #333238; - background-clip: padding-box; - border: 1px solid #737278; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.form-control::placeholder { - color: #a4a3a8; - opacity: 1; -} -.form-control:disabled { - background-color: #24232a; - opacity: 1; -} -.btn { - display: inline-block; - font-weight: 400; - color: #ececef; - text-align: center; - vertical-align: middle; - user-select: none; - background-color: transparent; - border: 1px solid transparent; - padding: 0.375rem 0.75rem; - font-size: 1rem; - line-height: 20px; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.btn:disabled { - opacity: 0.65; -} -.btn:not(:disabled):not(.disabled) { - cursor: pointer; -} -.collapse:not(.show) { - display: none; -} -.dropdown { - position: relative; -} -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: 1000; - display: none; - float: left; - min-width: 10rem; - padding: 0.5rem 0; - margin: 0.125rem 0 0; - font-size: 1rem; - color: #ececef; - text-align: left; - list-style: none; - background-color: #333238; - background-clip: padding-box; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 0.25rem; -} -.nav { - display: flex; - flex-wrap: wrap; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0.5rem; -} -.navbar .container-fluid { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; -} -.navbar-nav { - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar-nav .dropdown-menu { - position: static; - float: none; -} -.navbar-collapse { - flex-basis: 100%; - flex-grow: 1; - align-items: center; -} -.navbar-toggler { - padding: 0.25rem 0.75rem; - font-size: 1.25rem; - line-height: 1; - background-color: transparent; - border: 1px solid transparent; - border-radius: 0.25rem; -} -@media (max-width: 575.98px) { - .navbar-expand-sm > .container-fluid { - padding-right: 0; - padding-left: 0; - } -} -@media (min-width: 576px) { - .navbar-expand-sm { - flex-flow: row nowrap; - justify-content: flex-start; - } - .navbar-expand-sm .navbar-nav { - flex-direction: row; - } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-sm > .container-fluid { - flex-wrap: nowrap; - } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-sm .navbar-toggler { - display: none; - } -} -.badge { - display: inline-block; - padding: 0.25em 0.4em; - font-size: 75%; - font-weight: 600; - line-height: 1; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.badge:empty { - display: none; -} -.btn .badge { - position: relative; - top: -1px; -} -.badge-pill { - padding-right: 0.6em; - padding-left: 0.6em; - border-radius: 10rem; -} -.badge-success { - color: #fbfafd; - background-color: #2da160; -} -.badge-info { - color: #fbfafd; - background-color: #428fdc; -} -.badge-warning { - color: #fbfafd; - background-color: #c17d10; -} -.rounded-circle { - border-radius: 50% !important; -} -.d-none { - display: none !important; -} -.d-block { - display: block !important; -} -@media (min-width: 576px) { - .d-sm-none { - display: none !important; - } - .d-sm-inline-block { - display: inline-block !important; - } -} -@media (min-width: 768px) { - .d-md-block { - display: block !important; - } -} -@media (min-width: 992px) { - .d-lg-none { - display: none !important; - } -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -.gl-avatar { - display: inline-flex; - border-width: 1px; - border-style: solid; - border-color: rgba(251, 250, 253, 0.08); - overflow: hidden; - flex-shrink: 0; -} -.gl-avatar-s24 { - width: 1.5rem; - height: 1.5rem; - font-size: 0.75rem; - line-height: 1rem; - border-radius: 0.25rem; -} -.gl-avatar-circle { - border-radius: 50%; -} -.gl-badge { - display: inline-flex; - align-items: center; - font-size: 0.75rem; - font-weight: 400; - line-height: 1rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.gl-badge.sm { - padding-top: 0; - padding-bottom: 0; -} -.gl-badge.badge-info { - background-color: #064787; - color: #9dc7f1; -} -a.gl-badge.badge-info.active, -a.gl-badge.badge-info:active { - color: #e9f3fc; - background-color: #0b5cad; -} -a.gl-badge.badge-info:active { - box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; -} -.gl-badge.badge-success { - background-color: #0d532a; - color: #91d4a8; -} -a.gl-badge.badge-success.active, -a.gl-badge.badge-success:active { - color: #ecf4ee; - background-color: #24663b; -} -a.gl-badge.badge-success:active { - box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; -} -.gl-badge.badge-warning { - background-color: #703800; - color: #e9be74; -} -a.gl-badge.badge-warning.active, -a.gl-badge.badge-warning:active { - color: #fdf1dd; - background-color: #8f4700; -} -a.gl-badge.badge-warning:active { - box-shadow: 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; -} -.gl-button .gl-badge { - top: 0; -} -.gl-form-input, -.gl-form-input.form-control { - background-color: #333238; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - height: auto; - color: #ececef; - box-shadow: inset 0 0 0 1px #737278; - border-style: none; - appearance: none; - -moz-appearance: none; -} -.gl-form-input:disabled, -.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only, -.gl-form-input.form-control:disabled, -.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { - background-color: #1f1e24; - box-shadow: inset 0 0 0 1px #434248; -} -.gl-form-input:disabled, -.gl-form-input.form-control:disabled { - cursor: not-allowed; - color: #89888d; -} -.gl-form-input::placeholder, -.gl-form-input.form-control::placeholder { - color: #737278; -} -.gl-icon { - fill: currentColor; -} -.gl-icon.s12 { - width: 12px; - height: 12px; -} -.gl-icon.s16 { - width: 16px; - height: 16px; -} -.gl-icon.s32 { - width: 32px; - height: 32px; -} -.gl-link { - font-size: 0.875rem; - color: #428fdc; -} -.gl-link:active { - color: #9dc7f1; -} -.gl-link:active { - text-decoration: underline; - outline: 2px solid #1f75cb; - outline-offset: 2px; -} -.gl-button { - display: inline-flex; -} -.gl-button:not(.btn-link):active { - text-decoration: none; -} -.gl-button.gl-button { - border-width: 0; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - background-color: transparent; - line-height: 1rem; - color: #ececef; - fill: currentColor; - box-shadow: inset 0 0 0 1px #535158; - justify-content: center; - align-items: center; - font-size: 0.875rem; - border-radius: 0.25rem; -} -.gl-button.gl-button .gl-button-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-top: 1px; - padding-bottom: 1px; - margin-top: -1px; - margin-bottom: -1px; -} -.gl-button.gl-button.btn-default { - background-color: #333238; -} -.gl-button.gl-button.btn-default:active, -.gl-button.gl-button.btn-default.active { - box-shadow: inset 0 0 0 1px #a4a3a8, 0 0 0 1px #333238, 0 0 0 3px #1f75cb; - outline: none; - background-color: #434248; -} -.gl-button.gl-button.btn-default:active .gl-icon, -.gl-button.gl-button.btn-default.active .gl-icon { - color: #ececef; -} -.gl-button.gl-button.btn-default .gl-icon { - color: #89888d; -} -.gl-search-box-by-type-search-icon { - color: #89888d; - width: 1rem; - position: absolute; - left: 0.5rem; - top: calc(50% - 16px / 2); -} -.gl-search-box-by-type { - display: flex; - position: relative; -} -.gl-search-box-by-type-input, -.gl-search-box-by-type-input.gl-form-input { - height: 2rem; - padding-right: 2rem; - padding-left: 1.75rem; -} -body { - font-size: 0.875rem; -} -button, -html [type="button"], -[role="button"] { - cursor: pointer; -} -strong { - font-weight: bold; -} -svg { - vertical-align: baseline; -} -.form-control { - font-size: 0.875rem; -} -.hidden { - display: none !important; - visibility: hidden !important; -} -.badge:not(.gl-badge) { - padding: 4px 5px; - font-size: 12px; - font-style: normal; - font-weight: 400; - display: inline-block; -} -.divider { - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid #434248; -} -.toggle-sidebar-button .collapse-text, -.toggle-sidebar-button .icon-chevron-double-lg-left { - color: #bfbfc3; -} -html { - overflow-y: scroll; -} -.layout-page { - padding-top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) - ); - padding-bottom: var(--system-footer-height); -} -@media (min-width: 576px) { - .logged-out-marketing-header { - --header-height: 72px; - } -} -.btn { - border-radius: 4px; - font-size: 0.875rem; - font-weight: 400; - padding: 6px 10px; - background-color: #333238; - border-color: #434248; - color: #ececef; - color: #ececef; - white-space: nowrap; -} -.btn:active { - background-color: #333238; - box-shadow: none; -} -.btn:active, -.btn.active { - background-color: #434248; - border-color: #4f4f4f; - color: #ececef; -} -.btn svg { - height: 15px; - width: 15px; -} -.btn svg:not(:last-child) { - margin-right: 5px; -} -.badge.badge-pill:not(.gl-badge) { - font-weight: 400; - background-color: rgba(255, 255, 255, 0.07); - color: #bfbfc3; - vertical-align: baseline; -} -:root { - --performance-bar-height: 0px; - --system-header-height: 0px; - --top-bar-height: 0px; - --system-footer-height: 0px; - --mr-review-bar-height: 0px; - --breakpoint-xs: 0; - --breakpoint-sm: 576px; - --breakpoint-md: 768px; - --breakpoint-lg: 992px; - --breakpoint-xl: 1200px; -} -.with-top-bar { - --top-bar-height: 48px; -} -@media (min-width: 768px) { - .page-with-contextual-sidebar { - --application-bar-left: 56px; - } -} -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - --application-bar-left: 256px; - } - .page-with-icon-sidebar { - --application-bar-left: 56px; - } - .page-with-super-sidebar { - --application-bar-left: 256px; - } - .page-with-super-sidebar-collapsed { - --application-bar-left: 0px; - } -} -.gl-font-sm { - font-size: 12px; -} -.dropdown { - position: relative; -} -.dropdown-menu { - display: none; - position: absolute; - width: auto; - top: 100%; - z-index: 300; - min-width: 240px; - max-width: 500px; - margin-top: 4px; - margin-bottom: 24px; - font-size: 0.875rem; - font-weight: 400; - padding: 8px 0; - background-color: #333238; - border: 1px solid #434248; - border-radius: 0.25rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} -.dropdown-menu ul { - margin: 0; - padding: 0; -} -.dropdown-menu li { - display: block; - text-align: left; - list-style: none; -} -.dropdown-menu li > a, -.dropdown-menu li > button { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - display: block; - font-weight: 400; - position: relative; - padding: 8px 12px; - color: #ececef; - line-height: 16px; - white-space: normal; - overflow: hidden; - text-align: left; - width: 100%; -} -.dropdown-menu li > a:active, -.dropdown-menu li > button:active { - background-color: #4e4c53; - color: #ececef; - outline: 0; - text-decoration: none; -} -.dropdown-menu li > a:active, -.dropdown-menu li > button:active { - box-shadow: inset 0 0 0 2px #1f75cb, inset 0 0 0 3px #333238, - inset 0 0 0 1px #333238; - outline: none; -} -.dropdown-menu .divider { - height: 1px; - margin: 0.25rem 0; - padding: 0; - background-color: #434248; -} -.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) { - margin-right: 40px; -} -@media (max-width: 575.98px) { - .navbar-gitlab li.dropdown { - position: static; - } - .navbar-gitlab li.dropdown.user-counter { - margin-left: 8px !important; - } - .navbar-gitlab li.dropdown.user-counter > a { - padding: 0 4px !important; - } - header.navbar-gitlab .dropdown .dropdown-menu { - width: 100%; - min-width: 100%; - } -} -input { - border-radius: 0.25rem; - color: #ececef; - background-color: #333238; -} -input[type="search"] { - appearance: textfield; -} -.form-control { - border-radius: 4px; - padding: 6px 10px; -} -.form-control::placeholder { - color: #737278; -} -kbd { - display: inline-block; - padding: 3px 5px; - font-size: 0.75rem; - line-height: 10px; - color: var(--gray-700, #bfbfc3); - vertical-align: unset; - background-color: var(--gray-10, #1f1e24); - border-width: 1px; - border-style: solid; - border-color: var(--gray-100, #434248) var(--gray-100, #434248) - var(--gray-200, #535158); - border-image: none; - border-radius: 3px; - box-shadow: 0 -1px 0 var(--gray-200, #535158) inset; -} -.navbar-gitlab { - padding: 0 16px; - z-index: 1000; - margin-bottom: 0; - min-height: var(--header-height, 48px); - border: 0; - position: fixed; - top: calc(var(--system-header-height) + var(--performance-bar-height)); - left: 0; - right: 0; - border-radius: 0; -} -.navbar-gitlab .close-icon { - display: none; -} -.navbar-gitlab .header-content { - width: 100%; - display: flex; - justify-content: space-between; - position: relative; - min-height: var(--header-height, 48px); - padding-left: 0; -} -.navbar-gitlab .header-content .title { - padding-right: 0; - color: currentColor; - display: flex; - position: relative; - margin: 0; - font-size: 18px; - vertical-align: top; - white-space: nowrap; -} -.navbar-gitlab .header-content .title img { - height: 24px; -} -.navbar-gitlab .header-content .title a:not(.canary-badge) { - display: flex; - align-items: center; - padding: 2px 8px; - margin: 4px 2px 4px -8px; - border-radius: 4px; -} -.navbar-gitlab .header-content .title a:not(.canary-badge):active { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 0 3px #1068bf; - outline: none; -} -.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { - margin: 0 2px; -} -.navbar-gitlab .header-search-form { - min-width: 320px; -} -@media (min-width: 768px) and (max-width: 1199.98px) { - .navbar-gitlab .header-search-form { - min-width: 200px; - } -} -.navbar-gitlab .header-search-form .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; -} -.navbar-gitlab .navbar-collapse { - flex: 0 0 auto; - border-top: 0; - padding: 0; -} -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse { - flex: 1 1 auto; - } -} -.navbar-gitlab .navbar-collapse .nav { - flex-wrap: nowrap; -} -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse .nav > li:not(.d-none) a { - margin-left: 0; - } -} -.navbar-gitlab .container-fluid { - padding: 0; -} -.navbar-gitlab .container-fluid .user-counter svg { - margin-right: 3px; -} -.navbar-gitlab .container-fluid .navbar-toggler { - position: relative; - right: -10px; - border-radius: 0; - min-width: 45px; - padding: 0; - margin: 8px 8px 8px 0; - font-size: 14px; - text-align: center; - color: currentColor; -} -.navbar-gitlab .container-fluid .navbar-toggler.active { - color: currentColor; - background-color: transparent; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .navbar-nav { - display: flex; - padding-right: 10px; - flex-direction: row; - } -} -.navbar-gitlab - .container-fluid - .navbar-nav - li - .badge.badge-pill:not(.gl-badge) { - box-shadow: none; - font-weight: 600; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li.header-user { - padding-left: 10px; - } -} -.navbar-gitlab .container-fluid .nav > li > a { - will-change: color; - margin: 4px 0; - padding: 6px 8px; - height: 32px; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li > a { - padding: 0; - } -} -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle { - margin-left: 2px; -} -.navbar-gitlab - .container-fluid - .nav - > li - > a.header-user-dropdown-toggle - .header-user-avatar { - margin-right: 0; -} -.navbar-gitlab .container-fluid .nav > li .header-new-dropdown-toggle { - margin-right: 0; -} -.navbar-sub-nav > li > a, -.navbar-sub-nav > li > button, -.navbar-nav > li > a, -.navbar-nav > li > button { - display: flex; - align-items: center; - justify-content: center; - padding: 6px 8px; - margin: 4px 2px; - font-size: 12px; - color: currentColor; - border-radius: 4px; - height: 32px; - font-weight: 600; -} -.navbar-sub-nav > li > a:active, -.navbar-sub-nav > li > button:active, -.navbar-nav > li > a:active, -.navbar-nav > li > button:active { - box-shadow: 0 0 0 1px rgba(255, 255, 255, 0.6), 0 0 0 3px #1068bf; - outline: none; -} -.navbar-sub-nav > li .top-nav-toggle, -.navbar-sub-nav > li > button, -.navbar-nav > li .top-nav-toggle, -.navbar-nav > li > button { - background: transparent; - border: 0; -} -.navbar-sub-nav .dropdown-menu, -.navbar-nav .dropdown-menu { - position: absolute; -} -.navbar-sub-nav { - display: flex; - align-items: center; - height: 100%; - margin: 0 0 0 6px; -} -.caret-down, -.btn .caret-down { - top: 0; - height: 11px; - width: 11px; - margin-left: 4px; - fill: currentColor; -} -.header-user .dropdown-menu, -.header-new .dropdown-menu { - margin-top: 4px; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid { - font-size: 18px; - } - .navbar-gitlab .container-fluid .navbar-nav { - table-layout: fixed; - width: 100%; - margin: 0; - text-align: right; - } - .navbar-gitlab .container-fluid .navbar-collapse { - margin-left: -8px; - margin-right: -10px; - } - .navbar-gitlab .container-fluid .navbar-collapse .nav > li:not(.d-none) { - flex: 1; - } - .header-user-dropdown-toggle { - text-align: center; - } - .header-user-avatar { - float: none; - } -} -.header-user-avatar { - float: left; - margin-right: 5px; - border-radius: 50%; - border: 1px solid #333238; -} -.notification-dot { - background-color: #9e5400; - height: 12px; - width: 12px; - pointer-events: none; - visibility: hidden; - top: 3px; -} -.tanuki-logo .tanuki { - fill: #e24329; -} -.tanuki-logo .left-cheek, -.tanuki-logo .right-cheek { - fill: #fc6d26; -} -.tanuki-logo .chin { - fill: #fca326; -} -.context-header { - position: relative; - margin-right: 2px; - width: 256px; -} -.context-header > a, -.context-header > button { - font-weight: 600; - display: flex; - width: 100%; - align-items: center; - padding: 10px 16px 10px 10px; - color: #ececef; - background-color: transparent; - border: 0; - text-align: left; -} -.context-header .avatar-container { - flex: 0 0 32px; - background-color: #333238; -} -.context-header .sidebar-context-title { - overflow: hidden; - text-overflow: ellipsis; - color: #ececef; -} -@media (min-width: 768px) { - .page-with-contextual-sidebar { - padding-left: 56px; - } -} -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - padding-left: 256px; - } -} -@media (min-width: 768px) { - .page-with-icon-sidebar { - padding-left: 56px; - } -} -.nav-sidebar { - position: fixed; - bottom: var(--system-footer-height); - left: 0; - z-index: 600; - width: 256px; - top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) + - var(--top-bar-height) - ); - background-color: #1f1e24; - border-right: 1px solid #e9e9e9; - transform: translate3d(0, 0, 0); -} -.nav-sidebar.sidebar-collapsed-desktop { - width: 56px; -} -.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll { - overflow-x: hidden; -} -.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge), -.nav-sidebar.sidebar-collapsed-desktop .nav-item-name, -.nav-sidebar.sidebar-collapsed-desktop .collapse-text { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items > li > a { - min-height: unset; -} -.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item:not(.divider) { - display: block !important; -} -.nav-sidebar.sidebar-collapsed-desktop .avatar-container { - margin: 0 auto; -} -.nav-sidebar.sidebar-collapsed-desktop li.active:not(.fly-out-top-item) > a { - background-color: rgba(41, 41, 97, 0.08); -} -.nav-sidebar a { - text-decoration: none; - color: #ececef; -} -.nav-sidebar li { - white-space: nowrap; -} -.nav-sidebar li .nav-item-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; -} -.nav-sidebar li > a, -.nav-sidebar li > .fly-out-top-item-container { - height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - display: flex; - align-items: center; - border-radius: 0.25rem; - width: auto; - margin: 1px 8px; -} -.nav-sidebar li.active > a { - font-weight: 600; -} -.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(251, 250, 253, 0.08); -} -.nav-sidebar ul { - padding-left: 0; - list-style: none; -} -@media (max-width: 767.98px) { - .nav-sidebar { - left: -256px; - } -} -.nav-sidebar .nav-icon-container { - display: flex; - margin-right: 8px; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item { - display: none; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container { - margin-left: 0; - margin-right: 0; - padding-left: 1rem; - padding-right: 1rem; - cursor: default; - pointer-events: none; - font-size: 0.75rem; - margin-top: -0.25rem; - margin-bottom: -0.25rem; - margin-top: 0; - position: relative; - color: #333238; - background: var(--black, #fff); -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a - strong, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a - strong, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container - strong { - font-weight: 400; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a::before, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a::before, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container::before { - position: absolute; - content: ""; - display: block; - top: 50%; - left: -0.25rem; - margin-top: -0.25rem; - width: 0; - height: 0; - border-top: 0.25rem solid transparent; - border-bottom: 0.25rem solid transparent; - border-right: 0.25rem solid #fff; - border-right-color: var(--black, #fff); -} -@media (min-width: 576px) { - .nav-sidebar a.has-sub-items + .sidebar-sub-level-items { - min-width: 150px; - } -} -.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item { - display: none; -} -.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a, -.nav-sidebar - a.has-sub-items - + .sidebar-sub-level-items - .fly-out-top-item.active - a, -.nav-sidebar - a.has-sub-items - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container { - margin-left: 0; - margin-right: 0; - padding-left: 1rem; - padding-right: 1rem; - cursor: default; - pointer-events: none; - font-size: 0.75rem; - margin-top: 0; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} -@media (min-width: 768px) and (max-width: 1199px) { - .nav-sidebar:not(.sidebar-expanded-mobile) { - width: 56px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll { - overflow-x: hidden; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .badge.badge-pill:not(.fly-out-badge), - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name, - .nav-sidebar:not(.sidebar-expanded-mobile) .collapse-text { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a { - min-height: unset; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item:not(.divider) { - display: block !important; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container { - margin: 0 auto; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - li.active:not(.fly-out-top-item) - > a { - background-color: rgba(41, 41, 97, 0.08); - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { - height: 60px; - width: 56px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { - padding: 10px 4px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { - height: auto; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { - padding: 0.25rem; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .sidebar-top-level-items - > li - .sidebar-sub-level-items:not(.flyout-list) { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container { - margin-right: 0; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button { - width: 55px; - padding: 0 21px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .toggle-sidebar-button - .collapse-text { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .toggle-sidebar-button - .icon-chevron-double-lg-left { - transform: rotate(180deg); - margin: 0; - } -} -.nav-sidebar-inner-scroll { - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: auto; -} -.nav-sidebar-inner-scroll > div.context-header { - margin-top: 0.25rem; -} -.nav-sidebar-inner-scroll > div.context-header a { - height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - display: flex; - align-items: center; - border-radius: 0.25rem; - width: auto; - margin: 1px 8px; - padding: 0.25rem; - margin-bottom: 0.25rem; - margin-top: 0.125rem; - height: auto; -} -.nav-sidebar-inner-scroll > div.context-header a .avatar-container { - font-weight: 400; - flex: none; -} -.sidebar-top-level-items { - margin-bottom: 60px; -} -.sidebar-top-level-items .context-header a { - padding: 0.25rem; - margin-bottom: 0.25rem; - margin-top: 0.125rem; - height: auto; -} -.sidebar-top-level-items .context-header a .avatar-container { - font-weight: 400; - flex: none; -} -.sidebar-top-level-items - > li.active - .sidebar-sub-level-items:not(.is-fly-out-only) { - display: block; -} -.sidebar-top-level-items li > a.gl-link { - color: #ececef; -} -.sidebar-top-level-items li > a.gl-link:active { - text-decoration: none; -} -.sidebar-sub-level-items { - padding-top: 0; - padding-bottom: 0; - display: none; -} -.sidebar-sub-level-items:not(.fly-out-list) li > a { - padding-left: 2.25rem; -} -.toggle-sidebar-button, -.close-nav-button { - height: 48px; - padding: 0 16px; - background-color: #24232a; - border: 0; - color: #bfbfc3; - display: flex; - align-items: center; - background-color: #1f1e24; - position: fixed; - bottom: 0; - width: 255px; -} -.toggle-sidebar-button .collapse-text, -.toggle-sidebar-button .icon-chevron-double-lg-left, -.close-nav-button .collapse-text, -.close-nav-button .icon-chevron-double-lg-left { - color: inherit; -} -.collapse-text { - white-space: nowrap; - overflow: hidden; -} -.sidebar-collapsed-desktop .context-header { - height: 60px; - width: 56px; -} -.sidebar-collapsed-desktop .context-header a { - padding: 10px 4px; -} -.sidebar-collapsed-desktop .sidebar-context-title { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.sidebar-collapsed-desktop .context-header { - height: auto; -} -.sidebar-collapsed-desktop .context-header a { - padding: 0.25rem; -} -.sidebar-collapsed-desktop - .sidebar-top-level-items - > li - .sidebar-sub-level-items:not(.flyout-list) { - display: none; -} -.sidebar-collapsed-desktop .nav-icon-container { - margin-right: 0; -} -.sidebar-collapsed-desktop .toggle-sidebar-button { - width: 55px; - padding: 0 21px; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text { - display: none; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left { - transform: rotate(180deg); - margin: 0; -} -.close-nav-button { - display: none; -} -@media (max-width: 767.98px) { - .close-nav-button { - display: flex; - } - .toggle-sidebar-button { - display: none; - } -} -.super-sidebar { - display: flex; - flex-direction: column; - position: fixed; - top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) - ); - bottom: var(--system-footer-height); - left: 0; - background-color: var(--gray-10, #1f1e24); - border-right: 1px solid rgba(251, 250, 253, 0.08); - transform: translate3d(0, 0, 0); - width: 256px; - z-index: 600; -} -.super-sidebar.super-sidebar-loading { - transform: translate3d(-100%, 0, 0); -} -@media (min-width: 1200px) { - .super-sidebar.super-sidebar-loading { - transform: translate3d(0, 0, 0); - } -} -@media (prefers-reduced-motion: no-preference) { -} -.page-with-super-sidebar { - padding-left: 0; -} -@media (prefers-reduced-motion: no-preference) { -} -@media (min-width: 1200px) { - .page-with-super-sidebar { - padding-left: 256px; - } -} -.page-with-super-sidebar-collapsed .super-sidebar { - transform: translate3d(-100%, 0, 0); -} -@media (min-width: 1200px) { - .page-with-super-sidebar-collapsed { - padding-left: 0; - } -} -input::-moz-placeholder { - color: #737278; - opacity: 1; -} -input::-ms-input-placeholder { - color: #737278; -} -input:-ms-input-placeholder { - color: #737278; -} -svg { - fill: currentColor; -} -svg.s12 { - width: 12px; - height: 12px; -} -svg.s16 { - width: 16px; - height: 16px; -} -svg.s32 { - width: 32px; - height: 32px; -} -svg.s12 { - vertical-align: -1px; -} -svg.s16 { - vertical-align: -3px; -} -.avatar, -.avatar-container { - float: left; - margin-right: 16px; - border-radius: 50%; -} -.avatar.s16, -.avatar-container.s16 { - width: 16px; - height: 16px; - margin-right: 8px; -} -.avatar.s32, -.avatar-container.s32 { - width: 32px; - height: 32px; - margin-right: 8px; -} -.avatar { - transition-property: none; - width: 40px; - height: 40px; - padding: 0; - background: #212027; - overflow: hidden; - box-shadow: inset 0 0 0 1px rgba(251, 250, 253, 0.1); -} -.avatar.avatar-tile { - border-radius: 0; - border: 0; -} -.identicon { - text-align: center; - vertical-align: top; - color: #ececef; - background-color: #333238; -} -.identicon.s16 { - font-size: 10px; - line-height: 16px; -} -.identicon.s32 { - font-size: 14px; - line-height: 32px; -} -.identicon.bg1 { - background-color: #660e00; -} -.identicon.bg2 { - background-color: #232150; -} -.identicon.bg3 { - background-color: #1a1a40; -} -.identicon.bg4 { - background-color: #033464; -} -.identicon.bg5 { - background-color: #0a4020; -} -.identicon.bg6 { - background-color: #5c2900; -} -.identicon.bg7 { - background-color: #333238; -} -.avatar-container { - overflow: hidden; - display: flex; -} -.avatar-container a { - width: 100%; - height: 100%; - display: flex; - text-decoration: none; -} -.avatar-container .avatar { - border-radius: 0; - border: 0; - height: auto; - width: 100%; - margin: 0; - align-self: center; -} -.rect-avatar { - border-radius: 2px; -} -.rect-avatar.s16 { - border-radius: 2px; -} -.rect-avatar.s16 .avatar { - border-radius: 2px; -} -.rect-avatar.s32 { - border-radius: 4px; -} -.rect-avatar.s32 .avatar { - border-radius: 4px; -} -:root { - color-scheme: dark; - --gray-10: #1f1e24; - --gray-50: #333238; - --gray-100: #434248; - --gray-200: #535158; - --gray-700: #bfbfc3; - --gray-900: #ececef; - --border-color: #434248; - --white: #333238; - --black: #fff; -} -body.gl-dark { - color-scheme: dark; - --gray-10: #1f1e24; - --border-color: #434248; - --white: #333238; - --black: #fff; -} -.nav-sidebar, -.toggle-sidebar-button, -.close-nav-button { - background-color: #29282d; - border-right: 1px solid #333238; -} -.gl-avatar:not(.gl-avatar-identicon), -.avatar-container, -.avatar { - background: rgba(251, 250, 253, 0.04); -} -.gl-avatar { - border-style: none; - box-shadow: inset 0 0 0 1px rgba(251, 250, 253, 0.1); -} -body.gl-dark { - --gl-theme-accent: #737278; -} -body.gl-dark .navbar-gitlab { - background-color: #ececef; -} -body.gl-dark .navbar-gitlab .navbar-collapse { - color: #ececef; -} -body.gl-dark .navbar-gitlab .container-fluid .navbar-toggler { - border-left: 1px solid #a3a2a6; - color: #ececef; -} -body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > a, -body.gl-dark .navbar-gitlab .navbar-sub-nav > li.active > button, -body.gl-dark .navbar-gitlab .navbar-nav > li.active > a, -body.gl-dark .navbar-gitlab .navbar-nav > li.active > button { - color: #ececef; - background-color: #333238; -} -body.gl-dark .navbar-gitlab .navbar-sub-nav { - color: #ececef; -} -body.gl-dark .navbar-gitlab .nav > li { - color: #ececef; -} -body.gl-dark .navbar-gitlab .nav > li.header-search { - color: #ececef; -} -body.gl-dark .navbar-gitlab .nav > li > a .notification-dot { - border: 2px solid #ececef; -} -body.gl-dark - .navbar-gitlab - .nav - > li - > a.header-help-dropdown-toggle - .notification-dot { - background-color: #ececef; -} -body.gl-dark - .navbar-gitlab - .nav - > li - > a.header-user-dropdown-toggle - .header-user-avatar { - border-color: #ececef; -} -body.gl-dark .navbar-gitlab .nav > li.active > a { - color: #ececef; - background-color: #333238; -} -body.gl-dark .navbar-gitlab .nav > li.active > a .notification-dot { - border-color: #333238; -} -body.gl-dark - .navbar-gitlab - .nav - > li.active - > a.header-help-dropdown-toggle - .notification-dot { - background-color: #ececef; -} -body.gl-dark .header-search-form { - background-color: rgba(236, 236, 239, 0.2) !important; - border-radius: 4px; -} -body.gl-dark .header-search-form svg.gl-search-box-by-type-search-icon { - color: rgba(236, 236, 239, 0.8); -} -body.gl-dark .header-search-form input { - background-color: transparent; - color: rgba(236, 236, 239, 0.8); - box-shadow: inset 0 0 0 1px rgba(236, 236, 239, 0.4); -} -body.gl-dark .header-search-form input::placeholder { - color: rgba(236, 236, 239, 0.8); -} -body.gl-dark .header-search-form input:active::placeholder { - color: #737278; -} -body.gl-dark .header-search-form .keyboard-shortcut-helper { - color: #ececef; - background-color: rgba(236, 236, 239, 0.2); -} -body.gl-dark .nav-sidebar li.active > a { - color: #ececef; -} -body.gl-dark .nav-sidebar .fly-out-top-item a, -body.gl-dark .nav-sidebar .fly-out-top-item.active a, -body.gl-dark .nav-sidebar .fly-out-top-item .fly-out-top-item-container { - background-color: var(--gray-100, #333238); - color: var(--gray-900, #ececef); -} -body.gl-dark .navbar-gitlab { - background-color: var(--gray-50); - box-shadow: 0 1px 0 0 var(--gray-100); -} -body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > a, -body.gl-dark .navbar-gitlab .navbar-sub-nav li.active > button, -body.gl-dark .navbar-gitlab .navbar-nav li.active > a, -body.gl-dark .navbar-gitlab .navbar-nav li.active > button { - color: var(--gl-text-color); - background-color: var(--gray-200); -} -body.gl-dark .navbar-gitlab .header-search-form { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--border-color) !important; -} -body.gl-dark .navbar-gitlab .header-search-form:active { - background-color: var(--gray-100) !important; - box-shadow: inset 0 0 0 1px var(--blue-200) !important; -} - -.tab-width-8 { - tab-size: 8; -} -.gl-sr-only { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.gl-border-none\! { - border-style: none !important; -} -.gl-display-none { - display: none; -} -.gl-display-flex { - display: flex; -} -@media (min-width: 992px) { - .gl-lg-display-flex { - display: flex; - } -} -@media (min-width: 576px) { - .gl-sm-display-block { - display: block; - } -} -@media (min-width: 992px) { - .gl-lg-display-block { - display: block; - } -} -.gl-align-items-center { - align-items: center; -} -.gl-align-items-stretch { - align-items: stretch; -} -.gl-flex-grow-0\! { - flex-grow: 0 !important; -} -.gl-flex-grow-1 { - flex-grow: 1; -} -.gl-flex-basis-half\! { - flex-basis: 50% !important; -} -.gl-justify-content-end { - justify-content: flex-end; -} -.gl-relative { - position: relative; -} -.gl-absolute { - position: absolute; -} -.gl-top-0 { - top: 0; -} -.gl-right-3 { - right: 0.5rem; -} -.gl-w-full { - width: 100%; -} -.gl-px-3 { - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.gl-pr-2 { - padding-right: 0.25rem; -} -.gl-pt-0 { - padding-top: 0; -} -.gl-mr-auto { - margin-right: auto; -} -.gl-mr-3 { - margin-right: 0.5rem; -} -.gl-ml-n2 { - margin-left: -0.25rem; -} -.gl-ml-3 { - margin-left: 0.5rem; -} -.gl-mx-0\! { - margin-left: 0 !important; - margin-right: 0 !important; -} -.gl-text-right { - text-align: right; -} -.gl-white-space-nowrap { - white-space: nowrap; -} -.gl-font-sm { - font-size: 0.75rem; -} -.gl-font-weight-bold { - font-weight: 600; -} -.gl-z-index-1 { - z-index: 1; -} - -@import "startup/cloaking"; -@include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/startup/startup-general.scss b/app/assets/stylesheets/startup/startup-general.scss deleted file mode 100644 index 04c44dd9603..00000000000 --- a/app/assets/stylesheets/startup/startup-general.scss +++ /dev/null @@ -1,1781 +0,0 @@ -// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" -// Please see the feedback issue for more details and help: -// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 -@charset "UTF-8"; -:root { - --white: #fff; -} -*, -*::before, -*::after { - box-sizing: border-box; -} -html { - font-family: sans-serif; - line-height: 1.15; -} -aside, -header { - display: block; -} -body { - margin: 0; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #333238; - text-align: left; - background-color: #fff; -} -ul { - margin-top: 0; - margin-bottom: 1rem; -} -ul ul { - margin-bottom: 0; -} -strong { - font-weight: bolder; -} -a { - color: #1f75cb; - text-decoration: none; - background-color: transparent; -} -a:not([href]):not([class]) { - color: inherit; - text-decoration: none; -} -kbd { - font-family: var(--default-mono-font, "GitLab Mono"), "JetBrains Mono", - "Menlo", "DejaVu Sans Mono", "Liberation Mono", "Consolas", "Ubuntu Mono", - "Courier New", "andale mono", "lucida console", monospace; - font-size: 1em; -} -img { - vertical-align: middle; - border-style: none; -} -svg { - overflow: hidden; - vertical-align: middle; -} -button { - border-radius: 0; -} -input, -button { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} -button, -input { - overflow: visible; -} -button { - text-transform: none; -} -[role="button"] { - cursor: pointer; -} -button:not(:disabled), -[type="button"]:not(:disabled) { - cursor: pointer; -} -button::-moz-focus-inner, -[type="button"]::-moz-focus-inner { - padding: 0; - border-style: none; -} -[type="search"] { - outline-offset: -2px; -} -.list-unstyled { - padding-left: 0; - list-style: none; -} -kbd { - padding: 0.2rem 0.4rem; - font-size: 90%; - color: #fff; - background-color: #333238; - border-radius: 0.2rem; -} -kbd kbd { - padding: 0; - font-size: 100%; - font-weight: 600; -} -.container-fluid { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} -.form-control { - display: block; - width: 100%; - height: 32px; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - font-weight: 400; - line-height: 1.5; - color: #333238; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #89888d; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.form-control::placeholder { - color: #626168; - opacity: 1; -} -.form-control:disabled { - background-color: #fbfafd; - opacity: 1; -} -.btn { - display: inline-block; - font-weight: 400; - color: #333238; - text-align: center; - vertical-align: middle; - user-select: none; - background-color: transparent; - border: 1px solid transparent; - padding: 0.375rem 0.75rem; - font-size: 1rem; - line-height: 20px; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.btn:disabled { - opacity: 0.65; -} -.btn:not(:disabled):not(.disabled) { - cursor: pointer; -} -.collapse:not(.show) { - display: none; -} -.dropdown { - position: relative; -} -.dropdown-menu { - position: absolute; - top: 100%; - left: 0; - z-index: 1000; - display: none; - float: left; - min-width: 10rem; - padding: 0.5rem 0; - margin: 0.125rem 0 0; - font-size: 1rem; - color: #333238; - text-align: left; - list-style: none; - background-color: #fff; - background-clip: padding-box; - border: 1px solid rgba(0, 0, 0, 0.15); - border-radius: 0.25rem; -} -.nav { - display: flex; - flex-wrap: wrap; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0.5rem; -} -.navbar .container-fluid { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; -} -.navbar-nav { - display: flex; - flex-direction: column; - padding-left: 0; - margin-bottom: 0; - list-style: none; -} -.navbar-nav .dropdown-menu { - position: static; - float: none; -} -.navbar-collapse { - flex-basis: 100%; - flex-grow: 1; - align-items: center; -} -.navbar-toggler { - padding: 0.25rem 0.75rem; - font-size: 1.25rem; - line-height: 1; - background-color: transparent; - border: 1px solid transparent; - border-radius: 0.25rem; -} -@media (max-width: 575.98px) { - .navbar-expand-sm > .container-fluid { - padding-right: 0; - padding-left: 0; - } -} -@media (min-width: 576px) { - .navbar-expand-sm { - flex-flow: row nowrap; - justify-content: flex-start; - } - .navbar-expand-sm .navbar-nav { - flex-direction: row; - } - .navbar-expand-sm .navbar-nav .dropdown-menu { - position: absolute; - } - .navbar-expand-sm > .container-fluid { - flex-wrap: nowrap; - } - .navbar-expand-sm .navbar-collapse { - display: flex !important; - flex-basis: auto; - } - .navbar-expand-sm .navbar-toggler { - display: none; - } -} -.badge { - display: inline-block; - padding: 0.25em 0.4em; - font-size: 75%; - font-weight: 600; - line-height: 1; - text-align: center; - white-space: nowrap; - vertical-align: baseline; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.badge:empty { - display: none; -} -.btn .badge { - position: relative; - top: -1px; -} -.badge-pill { - padding-right: 0.6em; - padding-left: 0.6em; - border-radius: 10rem; -} -.badge-success { - color: #fff; - background-color: #108548; -} -.badge-info { - color: #fff; - background-color: #1f75cb; -} -.badge-warning { - color: #fff; - background-color: #ab6100; -} -.rounded-circle { - border-radius: 50% !important; -} -.d-none { - display: none !important; -} -.d-block { - display: block !important; -} -@media (min-width: 576px) { - .d-sm-none { - display: none !important; - } - .d-sm-inline-block { - display: inline-block !important; - } -} -@media (min-width: 768px) { - .d-md-block { - display: block !important; - } -} -@media (min-width: 992px) { - .d-lg-none { - display: none !important; - } -} -.sr-only { - position: absolute; - width: 1px; - height: 1px; - padding: 0; - margin: -1px; - overflow: hidden; - clip: rect(0, 0, 0, 0); - white-space: nowrap; - border: 0; -} -.gl-avatar { - display: inline-flex; - border-width: 1px; - border-style: solid; - border-color: rgba(31, 30, 36, 0.08); - overflow: hidden; - flex-shrink: 0; -} -.gl-avatar-s24 { - width: 1.5rem; - height: 1.5rem; - font-size: 0.75rem; - line-height: 1rem; - border-radius: 0.25rem; -} -.gl-avatar-circle { - border-radius: 50%; -} -.gl-badge { - display: inline-flex; - align-items: center; - font-size: 0.75rem; - font-weight: 400; - line-height: 1rem; - padding-top: 0.25rem; - padding-bottom: 0.25rem; - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.gl-badge.sm { - padding-top: 0; - padding-bottom: 0; -} -.gl-badge.badge-info { - background-color: #cbe2f9; - color: #0b5cad; -} -a.gl-badge.badge-info.active, -a.gl-badge.badge-info:active { - color: #033464; - background-color: #9dc7f1; -} -a.gl-badge.badge-info:active { - box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; -} -.gl-badge.badge-success { - background-color: #c3e6cd; - color: #24663b; -} -a.gl-badge.badge-success.active, -a.gl-badge.badge-success:active { - color: #0a4020; - background-color: #91d4a8; -} -a.gl-badge.badge-success:active { - box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; -} -.gl-badge.badge-warning { - background-color: #f5d9a8; - color: #8f4700; -} -a.gl-badge.badge-warning.active, -a.gl-badge.badge-warning:active { - color: #5c2900; - background-color: #e9be74; -} -a.gl-badge.badge-warning:active { - box-shadow: 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; -} -.gl-button .gl-badge { - top: 0; -} -.gl-form-input, -.gl-form-input.form-control { - background-color: #fff; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - height: auto; - color: #333238; - box-shadow: inset 0 0 0 1px #89888d; - border-style: none; - appearance: none; - -moz-appearance: none; -} -.gl-form-input:disabled, -.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only, -.gl-form-input.form-control:disabled, -.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { - background-color: #fbfafd; - box-shadow: inset 0 0 0 1px #dcdcde; -} -.gl-form-input:disabled, -.gl-form-input.form-control:disabled { - cursor: not-allowed; - color: #737278; -} -.gl-form-input::placeholder, -.gl-form-input.form-control::placeholder { - color: #89888d; -} -.gl-icon { - fill: currentColor; -} -.gl-icon.s12 { - width: 12px; - height: 12px; -} -.gl-icon.s16 { - width: 16px; - height: 16px; -} -.gl-icon.s32 { - width: 32px; - height: 32px; -} -.gl-link { - font-size: 0.875rem; - color: #1f75cb; -} -.gl-link:active { - color: #0b5cad; -} -.gl-link:active { - text-decoration: underline; - outline: 2px solid #428fdc; - outline-offset: 2px; -} -.gl-button { - display: inline-flex; -} -.gl-button:not(.btn-link):active { - text-decoration: none; -} -.gl-button.gl-button { - border-width: 0; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - background-color: transparent; - line-height: 1rem; - color: #333238; - fill: currentColor; - box-shadow: inset 0 0 0 1px #bfbfc3; - justify-content: center; - align-items: center; - font-size: 0.875rem; - border-radius: 0.25rem; -} -.gl-button.gl-button .gl-button-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-top: 1px; - padding-bottom: 1px; - margin-top: -1px; - margin-bottom: -1px; -} -.gl-button.gl-button.btn-default { - background-color: #fff; -} -.gl-button.gl-button.btn-default:active, -.gl-button.gl-button.btn-default.active { - box-shadow: inset 0 0 0 1px #626168, 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; - background-color: #dcdcde; -} -.gl-button.gl-button.btn-default:active .gl-icon, -.gl-button.gl-button.btn-default.active .gl-icon { - color: #333238; -} -.gl-button.gl-button.btn-default .gl-icon { - color: #737278; -} -.gl-search-box-by-type-search-icon { - color: #737278; - width: 1rem; - position: absolute; - left: 0.5rem; - top: calc(50% - 16px / 2); -} -.gl-search-box-by-type { - display: flex; - position: relative; -} -.gl-search-box-by-type-input, -.gl-search-box-by-type-input.gl-form-input { - height: 2rem; - padding-right: 2rem; - padding-left: 1.75rem; -} -body { - font-size: 0.875rem; -} -button, -html [type="button"], -[role="button"] { - cursor: pointer; -} -strong { - font-weight: bold; -} -svg { - vertical-align: baseline; -} -.form-control { - font-size: 0.875rem; -} -.hidden { - display: none !important; - visibility: hidden !important; -} -.badge:not(.gl-badge) { - padding: 4px 5px; - font-size: 12px; - font-style: normal; - font-weight: 400; - display: inline-block; -} -.divider { - height: 0; - margin: 4px 0; - overflow: hidden; - border-top: 1px solid #dcdcde; -} -.toggle-sidebar-button .collapse-text, -.toggle-sidebar-button .icon-chevron-double-lg-left { - color: #737278; -} -html { - overflow-y: scroll; -} -.layout-page { - padding-top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) - ); - padding-bottom: var(--system-footer-height); -} -@media (min-width: 576px) { - .logged-out-marketing-header { - --header-height: 72px; - } -} -.btn { - border-radius: 4px; - font-size: 0.875rem; - font-weight: 400; - padding: 6px 10px; - background-color: #fff; - border-color: #dcdcde; - color: #333238; - color: #333238; - white-space: nowrap; -} -.btn:active { - background-color: #ececef; - box-shadow: none; -} -.btn:active, -.btn.active { - background-color: #e6e6ea; - border-color: #dedee3; - color: #333238; -} -.btn svg { - height: 15px; - width: 15px; -} -.btn svg:not(:last-child) { - margin-right: 5px; -} -.badge.badge-pill:not(.gl-badge) { - font-weight: 400; - background-color: rgba(0, 0, 0, 0.07); - color: #535158; - vertical-align: baseline; -} -:root { - --performance-bar-height: 0px; - --system-header-height: 0px; - --top-bar-height: 0px; - --system-footer-height: 0px; - --mr-review-bar-height: 0px; - --breakpoint-xs: 0; - --breakpoint-sm: 576px; - --breakpoint-md: 768px; - --breakpoint-lg: 992px; - --breakpoint-xl: 1200px; -} -.with-top-bar { - --top-bar-height: 48px; -} -@media (min-width: 768px) { - .page-with-contextual-sidebar { - --application-bar-left: 56px; - } -} -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - --application-bar-left: 256px; - } - .page-with-icon-sidebar { - --application-bar-left: 56px; - } - .page-with-super-sidebar { - --application-bar-left: 256px; - } - .page-with-super-sidebar-collapsed { - --application-bar-left: 0px; - } -} -.gl-font-sm { - font-size: 12px; -} -.dropdown { - position: relative; -} -.dropdown-menu { - display: none; - position: absolute; - width: auto; - top: 100%; - z-index: 300; - min-width: 240px; - max-width: 500px; - margin-top: 4px; - margin-bottom: 24px; - font-size: 0.875rem; - font-weight: 400; - padding: 8px 0; - background-color: #fff; - border: 1px solid #dcdcde; - border-radius: 0.25rem; - box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); -} -.dropdown-menu ul { - margin: 0; - padding: 0; -} -.dropdown-menu li { - display: block; - text-align: left; - list-style: none; -} -.dropdown-menu li > a, -.dropdown-menu li > button { - background: transparent; - border: 0; - border-radius: 0; - box-shadow: none; - display: block; - font-weight: 400; - position: relative; - padding: 8px 12px; - color: #333238; - line-height: 16px; - white-space: normal; - overflow: hidden; - text-align: left; - width: 100%; -} -.dropdown-menu li > a:active, -.dropdown-menu li > button:active { - background-color: #ececef; - color: #333238; - outline: 0; - text-decoration: none; -} -.dropdown-menu li > a:active, -.dropdown-menu li > button:active { - box-shadow: inset 0 0 0 2px #428fdc, inset 0 0 0 3px #fff, - inset 0 0 0 1px #fff; - outline: none; -} -.dropdown-menu .divider { - height: 1px; - margin: 0.25rem 0; - padding: 0; - background-color: #dcdcde; -} -.dropdown-menu .badge.badge-pill + span:not(.badge):not(.badge-pill) { - margin-right: 40px; -} -@media (max-width: 575.98px) { - .navbar-gitlab li.dropdown { - position: static; - } - .navbar-gitlab li.dropdown.user-counter { - margin-left: 8px !important; - } - .navbar-gitlab li.dropdown.user-counter > a { - padding: 0 4px !important; - } - header.navbar-gitlab .dropdown .dropdown-menu { - width: 100%; - min-width: 100%; - } -} -input { - border-radius: 0.25rem; - color: #333238; - background-color: #fff; -} -input[type="search"] { - appearance: textfield; -} -.form-control { - border-radius: 4px; - padding: 6px 10px; -} -.form-control::placeholder { - color: #89888d; -} -kbd { - display: inline-block; - padding: 3px 5px; - font-size: 0.75rem; - line-height: 10px; - color: var(--gray-700, #535158); - vertical-align: unset; - background-color: var(--gray-10, #fbfafd); - border-width: 1px; - border-style: solid; - border-color: var(--gray-100, #dcdcde) var(--gray-100, #dcdcde) - var(--gray-200, #bfbfc3); - border-image: none; - border-radius: 3px; - box-shadow: 0 -1px 0 var(--gray-200, #bfbfc3) inset; -} -.navbar-gitlab { - padding: 0 16px; - z-index: 1000; - margin-bottom: 0; - min-height: var(--header-height, 48px); - border: 0; - position: fixed; - top: calc(var(--system-header-height) + var(--performance-bar-height)); - left: 0; - right: 0; - border-radius: 0; -} -.navbar-gitlab .close-icon { - display: none; -} -.navbar-gitlab .header-content { - width: 100%; - display: flex; - justify-content: space-between; - position: relative; - min-height: var(--header-height, 48px); - padding-left: 0; -} -.navbar-gitlab .header-content .title { - padding-right: 0; - color: currentColor; - display: flex; - position: relative; - margin: 0; - font-size: 18px; - vertical-align: top; - white-space: nowrap; -} -.navbar-gitlab .header-content .title img { - height: 24px; -} -.navbar-gitlab .header-content .title a:not(.canary-badge) { - display: flex; - align-items: center; - padding: 2px 8px; - margin: 4px 2px 4px -8px; - border-radius: 4px; -} -.navbar-gitlab .header-content .title a:not(.canary-badge):active { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 0 3px #63a6e9; - outline: none; -} -.navbar-gitlab .header-content .navbar-collapse > ul.nav > li:not(.d-none) { - margin: 0 2px; -} -.navbar-gitlab .header-search-form { - min-width: 320px; -} -@media (min-width: 768px) and (max-width: 1199.98px) { - .navbar-gitlab .header-search-form { - min-width: 200px; - } -} -.navbar-gitlab .header-search-form .keyboard-shortcut-helper { - transform: translateY(calc(50% - 2px)); - box-shadow: none; - border-color: transparent; -} -.navbar-gitlab .navbar-collapse { - flex: 0 0 auto; - border-top: 0; - padding: 0; -} -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse { - flex: 1 1 auto; - } -} -.navbar-gitlab .navbar-collapse .nav { - flex-wrap: nowrap; -} -@media (max-width: 575.98px) { - .navbar-gitlab .navbar-collapse .nav > li:not(.d-none) a { - margin-left: 0; - } -} -.navbar-gitlab .container-fluid { - padding: 0; -} -.navbar-gitlab .container-fluid .user-counter svg { - margin-right: 3px; -} -.navbar-gitlab .container-fluid .navbar-toggler { - position: relative; - right: -10px; - border-radius: 0; - min-width: 45px; - padding: 0; - margin: 8px 8px 8px 0; - font-size: 14px; - text-align: center; - color: currentColor; -} -.navbar-gitlab .container-fluid .navbar-toggler.active { - color: currentColor; - background-color: transparent; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .navbar-nav { - display: flex; - padding-right: 10px; - flex-direction: row; - } -} -.navbar-gitlab - .container-fluid - .navbar-nav - li - .badge.badge-pill:not(.gl-badge) { - box-shadow: none; - font-weight: 600; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li.header-user { - padding-left: 10px; - } -} -.navbar-gitlab .container-fluid .nav > li > a { - will-change: color; - margin: 4px 0; - padding: 6px 8px; - height: 32px; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid .nav > li > a { - padding: 0; - } -} -.navbar-gitlab .container-fluid .nav > li > a.header-user-dropdown-toggle { - margin-left: 2px; -} -.navbar-gitlab - .container-fluid - .nav - > li - > a.header-user-dropdown-toggle - .header-user-avatar { - margin-right: 0; -} -.navbar-gitlab .container-fluid .nav > li .header-new-dropdown-toggle { - margin-right: 0; -} -.navbar-sub-nav > li > a, -.navbar-sub-nav > li > button, -.navbar-nav > li > a, -.navbar-nav > li > button { - display: flex; - align-items: center; - justify-content: center; - padding: 6px 8px; - margin: 4px 2px; - font-size: 12px; - color: currentColor; - border-radius: 4px; - height: 32px; - font-weight: 600; -} -.navbar-sub-nav > li > a:active, -.navbar-sub-nav > li > button:active, -.navbar-nav > li > a:active, -.navbar-nav > li > button:active { - box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6), 0 0 0 3px #63a6e9; - outline: none; -} -.navbar-sub-nav > li .top-nav-toggle, -.navbar-sub-nav > li > button, -.navbar-nav > li .top-nav-toggle, -.navbar-nav > li > button { - background: transparent; - border: 0; -} -.navbar-sub-nav .dropdown-menu, -.navbar-nav .dropdown-menu { - position: absolute; -} -.navbar-sub-nav { - display: flex; - align-items: center; - height: 100%; - margin: 0 0 0 6px; -} -.caret-down, -.btn .caret-down { - top: 0; - height: 11px; - width: 11px; - margin-left: 4px; - fill: currentColor; -} -.header-user .dropdown-menu, -.header-new .dropdown-menu { - margin-top: 4px; -} -@media (max-width: 575.98px) { - .navbar-gitlab .container-fluid { - font-size: 18px; - } - .navbar-gitlab .container-fluid .navbar-nav { - table-layout: fixed; - width: 100%; - margin: 0; - text-align: right; - } - .navbar-gitlab .container-fluid .navbar-collapse { - margin-left: -8px; - margin-right: -10px; - } - .navbar-gitlab .container-fluid .navbar-collapse .nav > li:not(.d-none) { - flex: 1; - } - .header-user-dropdown-toggle { - text-align: center; - } - .header-user-avatar { - float: none; - } -} -.header-user-avatar { - float: left; - margin-right: 5px; - border-radius: 50%; - border: 1px solid #f2f2f4; -} -.notification-dot { - background-color: #d99530; - height: 12px; - width: 12px; - pointer-events: none; - visibility: hidden; - top: 3px; -} -.tanuki-logo .tanuki { - fill: #e24329; -} -.tanuki-logo .left-cheek, -.tanuki-logo .right-cheek { - fill: #fc6d26; -} -.tanuki-logo .chin { - fill: #fca326; -} -.context-header { - position: relative; - margin-right: 2px; - width: 256px; -} -.context-header > a, -.context-header > button { - font-weight: 600; - display: flex; - width: 100%; - align-items: center; - padding: 10px 16px 10px 10px; - color: #333238; - background-color: transparent; - border: 0; - text-align: left; -} -.context-header .avatar-container { - flex: 0 0 32px; - background-color: #fff; -} -.context-header .sidebar-context-title { - overflow: hidden; - text-overflow: ellipsis; - color: #333238; -} -@media (min-width: 768px) { - .page-with-contextual-sidebar { - padding-left: 56px; - } -} -@media (min-width: 1200px) { - .page-with-contextual-sidebar { - padding-left: 256px; - } -} -@media (min-width: 768px) { - .page-with-icon-sidebar { - padding-left: 56px; - } -} -.nav-sidebar { - position: fixed; - bottom: var(--system-footer-height); - left: 0; - z-index: 600; - width: 256px; - top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) + - var(--top-bar-height) - ); - background-color: #fbfafd; - border-right: 1px solid #e9e9e9; - transform: translate3d(0, 0, 0); -} -.nav-sidebar.sidebar-collapsed-desktop { - width: 56px; -} -.nav-sidebar.sidebar-collapsed-desktop .nav-sidebar-inner-scroll { - overflow-x: hidden; -} -.nav-sidebar.sidebar-collapsed-desktop .badge.badge-pill:not(.fly-out-badge), -.nav-sidebar.sidebar-collapsed-desktop .nav-item-name, -.nav-sidebar.sidebar-collapsed-desktop .collapse-text { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.nav-sidebar.sidebar-collapsed-desktop .sidebar-top-level-items > li > a { - min-height: unset; -} -.nav-sidebar.sidebar-collapsed-desktop .fly-out-top-item:not(.divider) { - display: block !important; -} -.nav-sidebar.sidebar-collapsed-desktop .avatar-container { - margin: 0 auto; -} -.nav-sidebar.sidebar-collapsed-desktop li.active:not(.fly-out-top-item) > a { - background-color: rgba(41, 41, 97, 0.08); -} -.nav-sidebar a { - text-decoration: none; - color: #333238; -} -.nav-sidebar li { - white-space: nowrap; -} -.nav-sidebar li .nav-item-name { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; -} -.nav-sidebar li > a, -.nav-sidebar li > .fly-out-top-item-container { - height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - display: flex; - align-items: center; - border-radius: 0.25rem; - width: auto; - margin: 1px 8px; -} -.nav-sidebar li.active > a { - font-weight: 600; -} -.nav-sidebar li.active:not(.fly-out-top-item) > a:not(.has-sub-items) { - background-color: rgba(31, 30, 36, 0.08); -} -.nav-sidebar ul { - padding-left: 0; - list-style: none; -} -@media (max-width: 767.98px) { - .nav-sidebar { - left: -256px; - } -} -.nav-sidebar .nav-icon-container { - display: flex; - margin-right: 8px; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item { - display: none; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container { - margin-left: 0; - margin-right: 0; - padding-left: 1rem; - padding-right: 1rem; - cursor: default; - pointer-events: none; - font-size: 0.75rem; - margin-top: -0.25rem; - margin-bottom: -0.25rem; - margin-top: 0; - position: relative; - color: #fff; - background: var(--black, #000); -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a - strong, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a - strong, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container - strong { - font-weight: 400; -} -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - a::before, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item.active - a::before, -.nav-sidebar - a:not(.has-sub-items) - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container::before { - position: absolute; - content: ""; - display: block; - top: 50%; - left: -0.25rem; - margin-top: -0.25rem; - width: 0; - height: 0; - border-top: 0.25rem solid transparent; - border-bottom: 0.25rem solid transparent; - border-right: 0.25rem solid #000; - border-right-color: var(--black, #000); -} -@media (min-width: 576px) { - .nav-sidebar a.has-sub-items + .sidebar-sub-level-items { - min-width: 150px; - } -} -.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item { - display: none; -} -.nav-sidebar a.has-sub-items + .sidebar-sub-level-items .fly-out-top-item a, -.nav-sidebar - a.has-sub-items - + .sidebar-sub-level-items - .fly-out-top-item.active - a, -.nav-sidebar - a.has-sub-items - + .sidebar-sub-level-items - .fly-out-top-item - .fly-out-top-item-container { - margin-left: 0; - margin-right: 0; - padding-left: 1rem; - padding-right: 1rem; - cursor: default; - pointer-events: none; - font-size: 0.75rem; - margin-top: 0; - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; -} -@media (min-width: 768px) and (max-width: 1199px) { - .nav-sidebar:not(.sidebar-expanded-mobile) { - width: 56px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-sidebar-inner-scroll { - overflow-x: hidden; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .badge.badge-pill:not(.fly-out-badge), - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-item-name, - .nav-sidebar:not(.sidebar-expanded-mobile) .collapse-text { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-top-level-items > li > a { - min-height: unset; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .fly-out-top-item:not(.divider) { - display: block !important; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .avatar-container { - margin: 0 auto; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - li.active:not(.fly-out-top-item) - > a { - background-color: rgba(41, 41, 97, 0.08); - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { - height: 60px; - width: 56px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { - padding: 10px 4px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .sidebar-context-title { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header { - height: auto; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .context-header a { - padding: 0.25rem; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .sidebar-top-level-items - > li - .sidebar-sub-level-items:not(.flyout-list) { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .nav-icon-container { - margin-right: 0; - } - .nav-sidebar:not(.sidebar-expanded-mobile) .toggle-sidebar-button { - width: 55px; - padding: 0 21px; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .toggle-sidebar-button - .collapse-text { - display: none; - } - .nav-sidebar:not(.sidebar-expanded-mobile) - .toggle-sidebar-button - .icon-chevron-double-lg-left { - transform: rotate(180deg); - margin: 0; - } -} -.nav-sidebar-inner-scroll { - height: 100%; - width: 100%; - overflow-x: hidden; - overflow-y: auto; -} -.nav-sidebar-inner-scroll > div.context-header { - margin-top: 0.25rem; -} -.nav-sidebar-inner-scroll > div.context-header a { - height: 2rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - display: flex; - align-items: center; - border-radius: 0.25rem; - width: auto; - margin: 1px 8px; - padding: 0.25rem; - margin-bottom: 0.25rem; - margin-top: 0.125rem; - height: auto; -} -.nav-sidebar-inner-scroll > div.context-header a .avatar-container { - font-weight: 400; - flex: none; -} -.sidebar-top-level-items { - margin-bottom: 60px; -} -.sidebar-top-level-items .context-header a { - padding: 0.25rem; - margin-bottom: 0.25rem; - margin-top: 0.125rem; - height: auto; -} -.sidebar-top-level-items .context-header a .avatar-container { - font-weight: 400; - flex: none; -} -.sidebar-top-level-items - > li.active - .sidebar-sub-level-items:not(.is-fly-out-only) { - display: block; -} -.sidebar-top-level-items li > a.gl-link { - color: #333238; -} -.sidebar-top-level-items li > a.gl-link:active { - text-decoration: none; -} -.sidebar-sub-level-items { - padding-top: 0; - padding-bottom: 0; - display: none; -} -.sidebar-sub-level-items:not(.fly-out-list) li > a { - padding-left: 2.25rem; -} -.toggle-sidebar-button, -.close-nav-button { - height: 48px; - padding: 0 16px; - background-color: #fbfafd; - border: 0; - color: #737278; - display: flex; - align-items: center; - background-color: #fbfafd; - position: fixed; - bottom: 0; - width: 255px; -} -.toggle-sidebar-button .collapse-text, -.toggle-sidebar-button .icon-chevron-double-lg-left, -.close-nav-button .collapse-text, -.close-nav-button .icon-chevron-double-lg-left { - color: inherit; -} -.collapse-text { - white-space: nowrap; - overflow: hidden; -} -.sidebar-collapsed-desktop .context-header { - height: 60px; - width: 56px; -} -.sidebar-collapsed-desktop .context-header a { - padding: 10px 4px; -} -.sidebar-collapsed-desktop .sidebar-context-title { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.sidebar-collapsed-desktop .context-header { - height: auto; -} -.sidebar-collapsed-desktop .context-header a { - padding: 0.25rem; -} -.sidebar-collapsed-desktop - .sidebar-top-level-items - > li - .sidebar-sub-level-items:not(.flyout-list) { - display: none; -} -.sidebar-collapsed-desktop .nav-icon-container { - margin-right: 0; -} -.sidebar-collapsed-desktop .toggle-sidebar-button { - width: 55px; - padding: 0 21px; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .collapse-text { - display: none; -} -.sidebar-collapsed-desktop .toggle-sidebar-button .icon-chevron-double-lg-left { - transform: rotate(180deg); - margin: 0; -} -.close-nav-button { - display: none; -} -@media (max-width: 767.98px) { - .close-nav-button { - display: flex; - } - .toggle-sidebar-button { - display: none; - } -} -.super-sidebar { - display: flex; - flex-direction: column; - position: fixed; - top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) - ); - bottom: var(--system-footer-height); - left: 0; - background-color: var(--gray-10, #fbfafd); - border-right: 1px solid rgba(31, 30, 36, 0.08); - transform: translate3d(0, 0, 0); - width: 256px; - z-index: 600; -} -.super-sidebar.super-sidebar-loading { - transform: translate3d(-100%, 0, 0); -} -@media (min-width: 1200px) { - .super-sidebar.super-sidebar-loading { - transform: translate3d(0, 0, 0); - } -} -@media (prefers-reduced-motion: no-preference) { -} -.page-with-super-sidebar { - padding-left: 0; -} -@media (prefers-reduced-motion: no-preference) { -} -@media (min-width: 1200px) { - .page-with-super-sidebar { - padding-left: 256px; - } -} -.page-with-super-sidebar-collapsed .super-sidebar { - transform: translate3d(-100%, 0, 0); -} -@media (min-width: 1200px) { - .page-with-super-sidebar-collapsed { - padding-left: 0; - } -} -input::-moz-placeholder { - color: #89888d; - opacity: 1; -} -input::-ms-input-placeholder { - color: #89888d; -} -input:-ms-input-placeholder { - color: #89888d; -} -svg { - fill: currentColor; -} -svg.s12 { - width: 12px; - height: 12px; -} -svg.s16 { - width: 16px; - height: 16px; -} -svg.s32 { - width: 32px; - height: 32px; -} -svg.s12 { - vertical-align: -1px; -} -svg.s16 { - vertical-align: -3px; -} -.avatar, -.avatar-container { - float: left; - margin-right: 16px; - border-radius: 50%; -} -.avatar.s16, -.avatar-container.s16 { - width: 16px; - height: 16px; - margin-right: 8px; -} -.avatar.s32, -.avatar-container.s32 { - width: 32px; - height: 32px; - margin-right: 8px; -} -.avatar { - transition-property: none; - width: 40px; - height: 40px; - padding: 0; - background: #fefefe; - overflow: hidden; - box-shadow: inset 0 0 0 1px rgba(31, 30, 36, 0.1); -} -.avatar.avatar-tile { - border-radius: 0; - border: 0; -} -.identicon { - text-align: center; - vertical-align: top; - color: #333238; - background-color: #ececef; -} -.identicon.s16 { - font-size: 10px; - line-height: 16px; -} -.identicon.s32 { - font-size: 14px; - line-height: 32px; -} -.identicon.bg1 { - background-color: #fcf1ef; -} -.identicon.bg2 { - background-color: #f4f0ff; -} -.identicon.bg3 { - background-color: #f1f1ff; -} -.identicon.bg4 { - background-color: #e9f3fc; -} -.identicon.bg5 { - background-color: #ecf4ee; -} -.identicon.bg6 { - background-color: #fdf1dd; -} -.identicon.bg7 { - background-color: #ececef; -} -.avatar-container { - overflow: hidden; - display: flex; -} -.avatar-container a { - width: 100%; - height: 100%; - display: flex; - text-decoration: none; -} -.avatar-container .avatar { - border-radius: 0; - border: 0; - height: auto; - width: 100%; - margin: 0; - align-self: center; -} -.rect-avatar { - border-radius: 2px; -} -.rect-avatar.s16 { - border-radius: 2px; -} -.rect-avatar.s16 .avatar { - border-radius: 2px; -} -.rect-avatar.s32 { - border-radius: 4px; -} -.rect-avatar.s32 .avatar { - border-radius: 4px; -} - -.tab-width-8 { - tab-size: 8; -} -.gl-sr-only { - border: 0; - clip: rect(0, 0, 0, 0); - height: 1px; - margin: -1px; - overflow: hidden; - padding: 0; - position: absolute; - white-space: nowrap; - width: 1px; -} -.gl-border-none\! { - border-style: none !important; -} -.gl-display-none { - display: none; -} -.gl-display-flex { - display: flex; -} -@media (min-width: 992px) { - .gl-lg-display-flex { - display: flex; - } -} -@media (min-width: 576px) { - .gl-sm-display-block { - display: block; - } -} -@media (min-width: 992px) { - .gl-lg-display-block { - display: block; - } -} -.gl-align-items-center { - align-items: center; -} -.gl-align-items-stretch { - align-items: stretch; -} -.gl-flex-grow-0\! { - flex-grow: 0 !important; -} -.gl-flex-grow-1 { - flex-grow: 1; -} -.gl-flex-basis-half\! { - flex-basis: 50% !important; -} -.gl-justify-content-end { - justify-content: flex-end; -} -.gl-relative { - position: relative; -} -.gl-absolute { - position: absolute; -} -.gl-top-0 { - top: 0; -} -.gl-right-3 { - right: 0.5rem; -} -.gl-w-full { - width: 100%; -} -.gl-px-3 { - padding-left: 0.5rem; - padding-right: 0.5rem; -} -.gl-pr-2 { - padding-right: 0.25rem; -} -.gl-pt-0 { - padding-top: 0; -} -.gl-mr-auto { - margin-right: auto; -} -.gl-mr-3 { - margin-right: 0.5rem; -} -.gl-ml-n2 { - margin-left: -0.25rem; -} -.gl-ml-3 { - margin-left: 0.5rem; -} -.gl-mx-0\! { - margin-left: 0 !important; - margin-right: 0 !important; -} -.gl-text-right { - text-align: right; -} -.gl-white-space-nowrap { - white-space: nowrap; -} -.gl-font-sm { - font-size: 0.75rem; -} -.gl-font-weight-bold { - font-weight: 600; -} -.gl-z-index-1 { - z-index: 1; -} - -@import "startup/cloaking"; -@include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/startup/startup-signin.scss b/app/assets/stylesheets/startup/startup-signin.scss deleted file mode 100644 index 32da8e1bb6b..00000000000 --- a/app/assets/stylesheets/startup/startup-signin.scss +++ /dev/null @@ -1,852 +0,0 @@ -// DO NOT EDIT! This is auto-generated from "yarn run generate:startup_css" -// Please see the feedback issue for more details and help: -// https://gitlab.com/gitlab-org/gitlab/-/issues/331812 -@charset "UTF-8"; -:root { - --white: #fff; -} -*, -*::before, -*::after { - box-sizing: border-box; -} -html { - font-family: sans-serif; - line-height: 1.15; -} -header { - display: block; -} -body { - margin: 0; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 1rem; - font-weight: 400; - line-height: 1.5; - color: #333238; - text-align: left; - background-color: #fff; -} -hr { - box-sizing: content-box; - height: 0; - overflow: visible; -} -h1, -h3 { - margin-top: 0; - margin-bottom: 0.25rem; -} -p { - margin-top: 0; - margin-bottom: 1rem; -} -a { - color: #1f75cb; - text-decoration: none; - background-color: transparent; -} -a:not([href]):not([class]) { - color: inherit; - text-decoration: none; -} -img { - vertical-align: middle; - border-style: none; -} -svg { - overflow: hidden; - vertical-align: middle; -} -label { - display: inline-block; - margin-bottom: 0.5rem; -} -button { - border-radius: 0; -} -input, -button { - margin: 0; - font-family: inherit; - font-size: inherit; - line-height: inherit; -} -button, -input { - overflow: visible; -} -button { - text-transform: none; -} -button:not(:disabled), -[type="submit"]:not(:disabled) { - cursor: pointer; -} -button::-moz-focus-inner, -[type="submit"]::-moz-focus-inner { - padding: 0; - border-style: none; -} -input[type="checkbox"] { - box-sizing: border-box; - padding: 0; -} -fieldset { - min-width: 0; - padding: 0; - margin: 0; - border: 0; -} -[hidden] { - display: none !important; -} -h1, -h3 { - margin-bottom: 0.25rem; - font-weight: 600; - line-height: 1.2; - color: #333238; -} -h1 { - font-size: 2.1875rem; -} -h3 { - font-size: 1.53125rem; -} -hr { - margin-top: 0.5rem; - margin-bottom: 0.5rem; - border: 0; - border-top: 1px solid rgba(0, 0, 0, 0.1); -} -.container { - width: 100%; - padding-right: 15px; - padding-left: 15px; - margin-right: auto; - margin-left: auto; -} -@media (min-width: 576px) { - .container { - max-width: 540px; - } -} -@media (min-width: 768px) { - .container { - max-width: 720px; - } -} -@media (min-width: 992px) { - .container { - max-width: 960px; - } -} -@media (min-width: 1200px) { - .container { - max-width: 1140px; - } -} -.row { - display: flex; - flex-wrap: wrap; - margin-right: -15px; - margin-left: -15px; -} -.col-md-6, -.col-sm-12 { - position: relative; - width: 100%; - padding-right: 15px; - padding-left: 15px; -} -.order-1 { - order: 1; -} -.order-12 { - order: 12; -} -@media (min-width: 576px) { - .col-sm-12 { - flex: 0 0 100%; - max-width: 100%; - } - .order-sm-1 { - order: 1; - } - .order-sm-12 { - order: 12; - } -} -@media (min-width: 768px) { - .col-md-6 { - flex: 0 0 50%; - max-width: 50%; - } -} -.form-control { - display: block; - width: 100%; - height: 32px; - padding: 0.375rem 0.75rem; - font-size: 0.875rem; - font-weight: 400; - line-height: 1.5; - color: #333238; - background-color: #fff; - background-clip: padding-box; - border: 1px solid #89888d; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.form-control::placeholder { - color: #626168; - opacity: 1; -} -.form-control:disabled { - background-color: #fbfafd; - opacity: 1; -} -.form-group { - margin-bottom: 1rem; -} -.form-text { - display: block; - margin-top: 0.25rem; -} -.btn { - display: inline-block; - font-weight: 400; - color: #333238; - text-align: center; - vertical-align: middle; - user-select: none; - background-color: transparent; - border: 1px solid transparent; - padding: 0.375rem 0.75rem; - font-size: 1rem; - line-height: 20px; - border-radius: 0.25rem; -} -@media (prefers-reduced-motion: reduce) { -} -.btn:disabled { - opacity: 0.65; -} -.btn:not(:disabled):not(.disabled) { - cursor: pointer; -} -fieldset:disabled a.btn { - pointer-events: none; -} -.btn-block { - display: block; - width: 100%; -} -.btn-block + .btn-block { - margin-top: 0.5rem; -} -input.btn-block[type="submit"] { - width: 100%; -} -.custom-control { - position: relative; - z-index: 1; - display: block; - min-height: 1.5rem; - padding-left: 1.5rem; - print-color-adjust: exact; -} -.custom-control-input { - position: absolute; - left: 0; - z-index: -1; - width: 1rem; - height: 1.25rem; - opacity: 0; -} -.custom-control-input:checked ~ .custom-control-label::before { - color: #fff; - border-color: #007bff; - background-color: #007bff; -} -.custom-control-input:not(:disabled):active ~ .custom-control-label::before { - color: #fff; - background-color: #b3d7ff; - border-color: #b3d7ff; -} -.custom-control-input:disabled ~ .custom-control-label { - color: #626168; -} -.custom-control-input:disabled ~ .custom-control-label::before { - background-color: #fbfafd; -} -.custom-control-label { - position: relative; - margin-bottom: 0; - vertical-align: top; -} -.custom-control-label::before { - position: absolute; - top: 0.25rem; - left: -1.5rem; - display: block; - width: 1rem; - height: 1rem; - pointer-events: none; - content: ""; - background-color: #fff; - border: 1px solid #737278; -} -.custom-control-label::after { - position: absolute; - top: 0.25rem; - left: -1.5rem; - display: block; - width: 1rem; - height: 1rem; - content: ""; - background: 50% / 50% 50% no-repeat; -} -.custom-checkbox .custom-control-label::before { - border-radius: 0.25rem; -} -.custom-checkbox .custom-control-input:checked ~ .custom-control-label::after { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='8' height='8' viewBox='0 0 8 8'%3e%3cpath fill='%23fff' d='M6.564.75l-3.59 3.612-1.538-1.55L0 4.26l2.974 2.99L8 2.193z'/%3e%3c/svg%3e"); -} -.custom-checkbox - .custom-control-input:indeterminate - ~ .custom-control-label::before { - border-color: #007bff; - background-color: #007bff; -} -.custom-checkbox - .custom-control-input:indeterminate - ~ .custom-control-label::after { - background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' width='4' height='4' viewBox='0 0 4 4'%3e%3cpath stroke='%23fff' d='M0 2h4'/%3e%3c/svg%3e"); -} -.custom-checkbox - .custom-control-input:disabled:checked - ~ .custom-control-label::before { - background-color: rgba(0, 123, 255, 0.5); -} -.custom-checkbox - .custom-control-input:disabled:indeterminate - ~ .custom-control-label::before { - background-color: rgba(0, 123, 255, 0.5); -} -@media (prefers-reduced-motion: reduce) { -} -.tab-content > .tab-pane { - display: none; -} -.tab-content > .active { - display: block; -} -.navbar { - position: relative; - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; - padding: 0.25rem 0.5rem; -} -.navbar .container { - display: flex; - flex-wrap: wrap; - align-items: center; - justify-content: space-between; -} -.fixed-top { - position: fixed; - top: 0; - right: 0; - left: 0; - z-index: 1030; -} -.mt-3 { - margin-top: 1rem !important; -} -.mb-3 { - margin-bottom: 1rem !important; -} -.text-nowrap { - white-space: nowrap !important; -} -.font-weight-normal { - font-weight: 400 !important; -} -.gl-form-input, -.gl-form-input.form-control { - background-color: #fff; - font-family: var(--default-regular-font, "GitLab Sans"), -apple-system, - BlinkMacSystemFont, "Segoe UI", Roboto, "Noto Sans", Ubuntu, Cantarell, - "Helvetica Neue", sans-serif, "Apple Color Emoji", "Segoe UI Emoji", - "Segoe UI Symbol", "Noto Color Emoji"; - font-size: 0.875rem; - line-height: 1rem; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - height: auto; - color: #333238; - box-shadow: inset 0 0 0 1px #89888d; - border-style: none; - appearance: none; - -moz-appearance: none; -} -.gl-form-input:disabled, -.gl-form-input:not(.form-control-plaintext):not([type="color"]):read-only, -.gl-form-input.form-control:disabled, -.gl-form-input.form-control:not(.form-control-plaintext):not([type="color"]):read-only { - background-color: #fbfafd; - box-shadow: inset 0 0 0 1px #dcdcde; -} -.gl-form-input:disabled, -.gl-form-input.form-control:disabled { - cursor: not-allowed; - color: #737278; -} -.gl-form-input::placeholder, -.gl-form-input.form-control::placeholder { - color: #89888d; -} -.gl-form-checkbox { - font-size: 0.875rem; - line-height: 1rem; - color: #333238; -} -.gl-form-checkbox .custom-control-input:disabled, -.gl-form-checkbox .custom-control-input:disabled ~ .custom-control-label { - cursor: not-allowed; - color: #89888d; -} -.gl-form-checkbox.custom-control { - padding-left: 1rem; -} -.gl-form-checkbox.custom-control .custom-control-input ~ .custom-control-label { - cursor: pointer; - padding-left: 0.5rem; - margin-bottom: 0.5rem; -} -.gl-form-checkbox.custom-control - .custom-control-input - ~ .custom-control-label::before, -.gl-form-checkbox.custom-control - .custom-control-input - ~ .custom-control-label::after { - top: 0; - left: -1rem; -} -.gl-form-checkbox.custom-control - .custom-control-input - ~ .custom-control-label::before { - background-color: #fff; - border-color: #89888d; -} -.gl-form-checkbox.custom-control - .custom-control-input:checked - ~ .custom-control-label::before { - background-color: #1f75cb; - border-color: #1f75cb; -} -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:checked - ~ .custom-control-label::after, -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:indeterminate - ~ .custom-control-label::after { - background: none; - background-color: #fff; - mask-repeat: no-repeat; - mask-position: center center; -} -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:checked - ~ .custom-control-label::after { - mask-image: url('data:image/svg+xml,%3Csvg width="8" height="7" viewBox="0 0 8 7" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M1 3.05299L2.99123 5L7 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A'); -} -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:indeterminate - ~ .custom-control-label::after { - mask-image: url('data:image/svg+xml,%3Csvg width="8" height="2" viewBox="0 0 8 2" fill="none" xmlns="http://www.w3.org/2000/svg"%3E%3Cpath d="M0 1L8 1" stroke="white" stroke-width="2"/%3E%3C/svg%3E%0A'); -} -.gl-form-checkbox.custom-control.custom-checkbox - .custom-control-input:indeterminate - ~ .custom-control-label::before { - background-color: #1f75cb; - border-color: #1f75cb; -} -.gl-form-checkbox.custom-control - .custom-control-input:disabled - ~ .custom-control-label { - cursor: not-allowed; -} -.gl-form-checkbox.custom-control - .custom-control-input:disabled - ~ .custom-control-label::before { - background-color: #ececef; - border-color: #dcdcde; - pointer-events: auto; -} -.gl-form-checkbox.custom-control - .custom-control-input:checked:disabled - ~ .custom-control-label::before, -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:indeterminate:disabled - ~ .custom-control-label::before { - background-color: #dcdcde; - border-color: #dcdcde; -} -.gl-form-checkbox.custom-control - .custom-control-input:checked:disabled - ~ .custom-control-label::after, -.gl-form-checkbox.custom-control - .custom-control-input[type="checkbox"]:indeterminate:disabled - ~ .custom-control-label::after { - background-color: #737278; -} -.gl-button { - display: inline-flex; -} -.gl-button:not(.btn-link):active { - text-decoration: none; -} -.gl-button.gl-button, -.gl-button.gl-button.btn-block { - border-width: 0; - padding-top: 0.5rem; - padding-bottom: 0.5rem; - padding-left: 0.75rem; - padding-right: 0.75rem; - background-color: transparent; - line-height: 1rem; - color: #333238; - fill: currentColor; - box-shadow: inset 0 0 0 1px #bfbfc3; - justify-content: center; - align-items: center; - font-size: 0.875rem; - border-radius: 0.25rem; -} -.gl-button.gl-button .gl-button-text, -.gl-button.gl-button.btn-block .gl-button-text { - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - padding-top: 1px; - padding-bottom: 1px; - margin-top: -1px; - margin-bottom: -1px; -} -.gl-button.gl-button .gl-button-icon, -.gl-button.gl-button.btn-block .gl-button-icon { - height: 1rem; - width: 1rem; - flex-shrink: 0; - margin-right: 0.25rem; - top: auto; -} -.gl-button.gl-button.btn-default, -.gl-button.gl-button.btn-block.btn-default { - background-color: #fff; -} -.gl-button.gl-button.btn-default:active, -.gl-button.gl-button.btn-default.active, -.gl-button.gl-button.btn-block.btn-default:active, -.gl-button.gl-button.btn-block.btn-default.active { - box-shadow: inset 0 0 0 1px #626168, 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; - background-color: #dcdcde; -} -.gl-button.gl-button.btn-confirm, -.gl-button.gl-button.btn-block.btn-confirm { - color: #fff; -} -.gl-button.gl-button.btn-confirm, -.gl-button.gl-button.btn-block.btn-confirm { - background-color: #1f75cb; - box-shadow: inset 0 0 0 1px #1068bf; -} -.gl-button.gl-button.btn-confirm:active, -.gl-button.gl-button.btn-confirm.active, -.gl-button.gl-button.btn-block.btn-confirm:active, -.gl-button.gl-button.btn-block.btn-confirm.active { - box-shadow: inset 0 0 0 1px #033464, 0 0 0 1px #fff, 0 0 0 3px #428fdc; - outline: none; - background-color: #0b5cad; -} -body { - font-size: 0.875rem; -} -button, -[type="submit"] { - cursor: pointer; -} -h1, -h3 { - margin-top: 20px; - margin-bottom: 10px; -} -hr { - overflow: hidden; -} -svg { - vertical-align: baseline; -} -.form-control { - font-size: 0.875rem; -} -.hidden { - display: none !important; - visibility: hidden !important; -} -html { - overflow-y: scroll; -} -body.navless { - background-color: #fff !important; -} -.container { - padding-top: 0; - z-index: 5; -} -.container .content { - margin: 0; -} -@media (max-width: 575.98px) { - .container .content { - margin-top: 20px; - } -} -.btn { - border-radius: 4px; - font-size: 0.875rem; - font-weight: 400; - padding: 6px 10px; - background-color: #fff; - border-color: #dcdcde; - color: #333238; - color: #333238; - white-space: nowrap; -} -.btn:active { - background-color: #ececef; - box-shadow: none; -} -.btn:active, -.btn.active { - background-color: #e6e6ea; - border-color: #dedee3; - color: #333238; -} -.btn svg { - height: 15px; - width: 15px; -} -.btn svg:not(:last-child) { - margin-right: 5px; -} -.btn-block { - width: 100%; - margin: 0; -} -.btn-block.btn { - padding: 6px 0; -} -:root { - --performance-bar-height: 0px; - --system-header-height: 0px; - --top-bar-height: 0px; - --system-footer-height: 0px; - --mr-review-bar-height: 0px; - --breakpoint-xs: 0; - --breakpoint-sm: 576px; - --breakpoint-md: 768px; - --breakpoint-lg: 992px; - --breakpoint-xl: 1200px; -} -.tab-content { - overflow: visible; -} -@media (max-width: 767.98px) { - .tab-content { - isolation: isolate; - } -} -hr { - margin: 1.5rem 0; - border-top: 1px solid #ececef; -} -.flash-container { - margin: 0; - margin-bottom: 16px; - font-size: 14px; - position: relative; - z-index: 1; -} -.flash-container.sticky { - position: sticky; - top: calc( - var(--header-height, 48px) + - calc(var(--system-header-height) + var(--performance-bar-height)) + - var(--top-bar-height) - ); - z-index: 251; -} -.flash-container.flash-container-page { - margin-bottom: 0; -} -.flash-container:empty { - margin: 0; -} -input { - border-radius: 0.25rem; - color: #333238; - background-color: #fff; -} -label { - font-weight: 600; -} -label.custom-control-label { - font-weight: 400; -} -.form-control { - border-radius: 4px; - padding: 6px 10px; -} -.form-control::placeholder { - color: #89888d; -} -.gl-show-field-errors .form-control:not(textarea) { - height: 32px; -} -.navbar-empty { - justify-content: center; - height: var(--header-height, 48px); - background: #fff; - border-bottom: 1px solid #dcdcde; -} -.navbar-empty .tanuki-logo, -.navbar-empty .brand-header-logo { - max-height: 100%; -} -.tanuki-logo .tanuki { - fill: #e24329; -} -.tanuki-logo .left-cheek, -.tanuki-logo .right-cheek { - fill: #fc6d26; -} -.tanuki-logo .chin { - fill: #fca326; -} -input::-moz-placeholder { - color: #89888d; - opacity: 1; -} -input::-ms-input-placeholder { - color: #89888d; -} -input:-ms-input-placeholder { - color: #89888d; -} -svg { - fill: currentColor; -} - -.fixed-top { - top: calc(var(--system-header-height) + var(--performance-bar-height)); -} -.gl-display-flex { - display: flex; -} -.gl-align-items-center { - align-items: center; -} -.gl-flex-wrap { - flex-wrap: wrap; -} -.gl-justify-content-space-between { - justify-content: space-between; -} -.gl-align-self-end { - align-self: flex-end; -} -.gl-w-10 { - width: 3.5rem; -} -.gl-w-half { - width: 50%; -} -.gl-w-full { - width: 100%; -} -@media (max-width: 575.98px) { - .gl-xs-w-full { - width: 100%; - } -} -.gl-h-full { - height: 100%; -} -.gl-p-5 { - padding: 1rem; -} -.gl-px-5 { - padding-left: 1rem; - padding-right: 1rem; -} -.gl-py-5 { - padding-top: 1rem; - padding-bottom: 1rem; -} -.gl-m-0 { - margin: 0; -} -.gl-mt-3 { - margin-top: 0.5rem; -} -.gl-mt-5 { - margin-top: 1rem; -} -.gl-mr-auto { - margin-right: auto; -} -.gl-mb-2 { - margin-bottom: 0.25rem; -} -.gl-mb-3 { - margin-bottom: 0.5rem; -} -.gl-ml-auto { - margin-left: auto; -} -.gl-gap-5 { - gap: 1rem; -} -@media (min-width: 576px) { - .gl-sm-mt-0 { - margin-top: 0; - } -} -.gl-text-center { - text-align: center; -} -.gl-text-right { - text-align: right; -} -.gl-font-size-h2 { - font-size: 1.1875rem; -} -.gl-font-weight-bold { - font-weight: 600; -} - -@import "startup/cloaking"; -@include cloak-startup-scss(none); diff --git a/app/assets/stylesheets/themes/dark_mode_overrides.scss b/app/assets/stylesheets/themes/dark_mode_overrides.scss index 73877c04c46..c0eced48171 100644 --- a/app/assets/stylesheets/themes/dark_mode_overrides.scss +++ b/app/assets/stylesheets/themes/dark_mode_overrides.scss @@ -105,7 +105,7 @@ --svg-status-bg: #{$white}; } -body.gl-dark { +:root.gl-dark { // redefine some colors and values to prevent sourcegraph conflicts color-scheme: dark; --gray-10: #{$gray-10}; @@ -178,6 +178,10 @@ body.gl-dark { } } +.gl-label-text-light .gl-label-close.gl-button:hover { + background-color: $gray-900; +} + .gl-label-text-dark.gl-label-text-dark { &, .gl-label-close .gl-icon { @@ -194,6 +198,10 @@ body.gl-dark { } } +.gl-label-text-dark .gl-label-close.gl-button:hover { + background-color: $gray-10; +} + // duplicated class as the original .atwho-view style is added later .atwho-view.atwho-view { background-color: $white; @@ -231,7 +239,7 @@ aside.right-sidebar:not(.right-sidebar-merge-requests) { border-left-color: $gray-50; } -body.gl-dark { +:root.gl-dark { @include gitlab-theme($gray-900, $gray-400, $gray-500, $gray-900, $white); .terms { diff --git a/app/assets/stylesheets/themes/theme_blue.scss b/app/assets/stylesheets/themes/theme_blue.scss index 06f3e13e99e..749120a0ecb 100644 --- a/app/assets/stylesheets/themes/theme_blue.scss +++ b/app/assets/stylesheets/themes/theme_blue.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-blue { @include gitlab-theme( $theme-blue-200, diff --git a/app/assets/stylesheets/themes/theme_gray.scss b/app/assets/stylesheets/themes/theme_gray.scss index 3112aaef227..70611e692cd 100644 --- a/app/assets/stylesheets/themes/theme_gray.scss +++ b/app/assets/stylesheets/themes/theme_gray.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-gray { @include gitlab-theme( $gray-200, diff --git a/app/assets/stylesheets/themes/theme_green.scss b/app/assets/stylesheets/themes/theme_green.scss index c9ea1162206..ae969873692 100644 --- a/app/assets/stylesheets/themes/theme_green.scss +++ b/app/assets/stylesheets/themes/theme_green.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-green { @include gitlab-theme( $theme-green-200, diff --git a/app/assets/stylesheets/themes/theme_indigo.scss b/app/assets/stylesheets/themes/theme_indigo.scss index 78ce96667d4..d7e8ddadf46 100644 --- a/app/assets/stylesheets/themes/theme_indigo.scss +++ b/app/assets/stylesheets/themes/theme_indigo.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-indigo { @include gitlab-theme( $indigo-200, diff --git a/app/assets/stylesheets/themes/theme_light_blue.scss b/app/assets/stylesheets/themes/theme_light_blue.scss index 73fe072393f..430960f563f 100644 --- a/app/assets/stylesheets/themes/theme_light_blue.scss +++ b/app/assets/stylesheets/themes/theme_light_blue.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-blue { @include gitlab-theme( $theme-light-blue-200, diff --git a/app/assets/stylesheets/themes/theme_light_gray.scss b/app/assets/stylesheets/themes/theme_light_gray.scss index e8357647f48..f63da3f22f1 100644 --- a/app/assets/stylesheets/themes/theme_light_gray.scss +++ b/app/assets/stylesheets/themes/theme_light_gray.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-gray { @include gitlab-theme( $gray-500, diff --git a/app/assets/stylesheets/themes/theme_light_green.scss b/app/assets/stylesheets/themes/theme_light_green.scss index 6b058b2dd7b..05adc56c36a 100644 --- a/app/assets/stylesheets/themes/theme_light_green.scss +++ b/app/assets/stylesheets/themes/theme_light_green.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-green { @include gitlab-theme( $theme-green-200, diff --git a/app/assets/stylesheets/themes/theme_light_indigo.scss b/app/assets/stylesheets/themes/theme_light_indigo.scss index ff12366466a..04bcfaf8366 100644 --- a/app/assets/stylesheets/themes/theme_light_indigo.scss +++ b/app/assets/stylesheets/themes/theme_light_indigo.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-indigo { @include gitlab-theme( $indigo-200, diff --git a/app/assets/stylesheets/themes/theme_light_red.scss b/app/assets/stylesheets/themes/theme_light_red.scss index 3ae67309014..c4952b8e155 100644 --- a/app/assets/stylesheets/themes/theme_light_red.scss +++ b/app/assets/stylesheets/themes/theme_light_red.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-light-red { @include gitlab-theme( $theme-light-red-200, diff --git a/app/assets/stylesheets/themes/theme_red.scss b/app/assets/stylesheets/themes/theme_red.scss index 82de30e8b0e..536963e12ef 100644 --- a/app/assets/stylesheets/themes/theme_red.scss +++ b/app/assets/stylesheets/themes/theme_red.scss @@ -1,6 +1,6 @@ @import './theme_helper'; -body { +:root { &.ui-red { @include gitlab-theme( $theme-red-200, diff --git a/app/assets/stylesheets/tmp_utilities.scss b/app/assets/stylesheets/tmp_utilities.scss new file mode 100644 index 00000000000..96464aa5a39 --- /dev/null +++ b/app/assets/stylesheets/tmp_utilities.scss @@ -0,0 +1,32 @@ +/** + * DISCLAIMER + * This is a temporary stylesheet meant to assist in migrating away from desktop-first responsive + * CSS utilities. + * DO NOT add utils in here unless you are actively taking part in in the migration. + * We needed this new file for temporary utils to be defined _after_ the main, non-responsive + * GitLab UI util. + * This file is scheduled to be removed by the end of 2023. + */ + .gl-sm-w-25p { + @include gl-media-breakpoint-up(sm) { + width: 25%; + } +} + +.gl-sm-w-30p { + @include gl-media-breakpoint-up(sm) { + width: 30%; + } +} + +.gl-sm-w-40p { + @include gl-media-breakpoint-up(sm) { + width: 40%; + } +} + +.gl-sm-w-75p { + @include gl-media-breakpoint-up(sm) { + width: 75%; + } +} diff --git a/app/assets/stylesheets/utilities.scss b/app/assets/stylesheets/utilities.scss index 8fe45d4bb9d..347b8e20ab4 100644 --- a/app/assets/stylesheets/utilities.scss +++ b/app/assets/stylesheets/utilities.scss @@ -65,9 +65,6 @@ min-width: 0; } -// .gl-font-size-inherit will be moved to @gitlab/ui by https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1466 -.gl-font-size-inherit, -.font-size-inherit { font-size: inherit; } .gl-w-16 { width: px-to-rem($grid-size * 2); } .gl-w-64 { width: px-to-rem($grid-size * 8); } .gl-h-32 { height: px-to-rem($grid-size * 4); } diff --git a/app/components/projects/ml/models_index_component.rb b/app/components/projects/ml/models_index_component.rb index 57900165ad1..5754c2a1fa9 100644 --- a/app/components/projects/ml/models_index_component.rb +++ b/app/components/projects/ml/models_index_component.rb @@ -3,10 +3,11 @@ module Projects module Ml class ModelsIndexComponent < ViewComponent::Base - attr_reader :paginator + attr_reader :paginator, :model_count - def initialize(paginator:) + def initialize(paginator:, model_count:) @paginator = paginator + @model_count = model_count end private @@ -14,7 +15,8 @@ module Projects def view_model vm = { models: models_view_model, - page_info: page_info_view_model + page_info: page_info_view_model, + model_count: model_count } Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) @@ -26,7 +28,8 @@ module Projects name: m.name, version: m.latest_version_name, version_count: m.version_count, - path: m.latest_package_path + version_package_path: m.latest_package_path, + version_path: m.latest_version_path } end end diff --git a/app/components/projects/ml/show_ml_model_component.rb b/app/components/projects/ml/show_ml_model_component.rb index 2fe2c7e7e9d..d349c0a22e9 100644 --- a/app/components/projects/ml/show_ml_model_component.rb +++ b/app/components/projects/ml/show_ml_model_component.rb @@ -16,11 +16,22 @@ module Projects model: { id: model.id, name: model.name, - path: model.path + path: model.path, + description: "This is a placeholder for the short description", + latest_version: latest_version_view_model, + version_count: model.version_count } } - Gitlab::Json.generate(vm) + Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) + end + + def latest_version_view_model + return unless model.latest_version + + { + version: model.latest_version.version + } end end end diff --git a/app/components/projects/ml/show_ml_model_version_component.html.haml b/app/components/projects/ml/show_ml_model_version_component.html.haml new file mode 100644 index 00000000000..7410e648306 --- /dev/null +++ b/app/components/projects/ml/show_ml_model_version_component.html.haml @@ -0,0 +1 @@ +#js-mount-show-ml-model-version{ data: { view_model: view_model } } diff --git a/app/components/projects/ml/show_ml_model_version_component.rb b/app/components/projects/ml/show_ml_model_version_component.rb new file mode 100644 index 00000000000..ae81642a891 --- /dev/null +++ b/app/components/projects/ml/show_ml_model_version_component.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ShowMlModelVersionComponent < ViewComponent::Base + attr_reader :model_version, :model + + def initialize(model_version:) + @model_version = model_version.present + @model = model_version.model.present + end + + private + + def view_model + vm = { + model_version: { + id: model_version.id, + version: model_version.version, + path: model_version.path, + model: { + name: model.name, + path: model.path + } + } + } + + Gitlab::Json.generate(vm.deep_transform_keys { |k| k.to_s.camelize(:lower) }) + end + end + end +end diff --git a/app/controllers/acme_challenges_controller.rb b/app/controllers/acme_challenges_controller.rb index a187e43b3df..4a7706db94e 100644 --- a/app/controllers/acme_challenges_controller.rb +++ b/app/controllers/acme_challenges_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class AcmeChallengesController < BaseActionController +# rubocop:disable Rails/ApplicationController +class AcmeChallengesController < ActionController::Base def show if acme_order render plain: acme_order.challenge_file_content, content_type: 'text/plain' @@ -15,3 +16,4 @@ class AcmeChallengesController < BaseActionController @acme_order ||= PagesDomainAcmeOrder.find_by_domain_and_token(params[:domain], params[:token]) end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/admin/abuse_reports_controller.rb b/app/controllers/admin/abuse_reports_controller.rb index b48d6f4f7c2..d5c505ba1dd 100644 --- a/app/controllers/admin/abuse_reports_controller.rb +++ b/app/controllers/admin/abuse_reports_controller.rb @@ -7,6 +7,7 @@ class Admin::AbuseReportsController < Admin::ApplicationController before_action :find_abuse_report, only: [:show, :moderate_user, :update, :destroy] before_action only: :show do push_frontend_feature_flag(:abuse_report_labels) + push_frontend_feature_flag(:abuse_report_notes) end def index diff --git a/app/controllers/admin/application_settings_controller.rb b/app/controllers/admin/application_settings_controller.rb index be1edeb0d37..8cf0ab60fd3 100644 --- a/app/controllers/admin/application_settings_controller.rb +++ b/app/controllers/admin/application_settings_controller.rb @@ -12,10 +12,10 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController before_action :set_application_setting, except: :integrations before_action :disable_query_limiting, only: [:usage_data] + before_action :prerecorded_service_ping_data, only: [:metrics_and_profiling] # rubocop:disable Rails/LexicallyScopedActionFilter before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) - push_frontend_feature_flag(:ci_variable_drawer, current_user) end feature_category :not_owned, [ # rubocop:todo Gitlab/AvoidFeatureCategoryNotOwned @@ -30,7 +30,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController feature_category :source_code_management, [:repository, :clear_repository_check_states] feature_category :continuous_integration, [:ci_cd, :reset_registration_token] urgency :low, [:ci_cd, :reset_registration_token] - feature_category :service_ping, [:usage_data, :service_usage_data] + feature_category :service_ping, [:usage_data] feature_category :integrations, [:integrations, :slack_app_manifest_share, :slack_app_manifest_download] feature_category :pages, [:lets_encrypt_terms_of_service] feature_category :error_tracking, [:reset_error_tracking_access_token] @@ -56,18 +56,16 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController @integrations = Integration.find_or_initialize_all_non_project_specific(Integration.for_instance).sort_by(&:title) end - def service_usage_data - @service_ping_data_present = prerecorded_service_ping_data.present? - end - def update perform_update end def usage_data + return not_found unless prerecorded_service_ping_data.present? + respond_to do |format| format.html do - usage_data_json = Gitlab::Json.pretty_generate(service_ping_data) + usage_data_json = Gitlab::Json.pretty_generate(prerecorded_service_ping_data) render html: Gitlab::Highlight.highlight('payload.json', usage_data_json, language: 'json') end @@ -75,7 +73,7 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController format.json do Gitlab::UsageDataCounters::ServiceUsageDataCounter.count(:download_payload_click) - render json: Gitlab::Json.dump(service_ping_data) + render json: Gitlab::Json.dump(prerecorded_service_ping_data) end end end @@ -243,12 +241,9 @@ class Admin::ApplicationSettingsController < Admin::ApplicationController VALID_SETTING_PANELS end - def service_ping_data - prerecorded_service_ping_data || Gitlab::Usage::ServicePingReport.for(output: :all_metrics_values) - end - def prerecorded_service_ping_data - Rails.cache.fetch(Gitlab::Usage::ServicePingReport::CACHE_KEY) || ::RawUsageData.for_current_reporting_cycle.first&.payload + @service_ping_data ||= Rails.cache.fetch(Gitlab::Usage::ServicePingReport::CACHE_KEY) || + ::RawUsageData.for_current_reporting_cycle.first&.payload end end diff --git a/app/controllers/admin/dashboard_controller.rb b/app/controllers/admin/dashboard_controller.rb index dab0f3e870a..a03e0c0807f 100644 --- a/app/controllers/admin/dashboard_controller.rb +++ b/app/controllers/admin/dashboard_controller.rb @@ -13,8 +13,7 @@ class Admin::DashboardController < Admin::ApplicationController @projects = Project.order_id_desc.without_deleted.with_route.limit(10) @users = User.order_id_desc.limit(10) @groups = Group.order_id_desc.with_route.limit(10) - @notices = Gitlab::ConfigChecker::PumaRuggedChecker.check - @notices += Gitlab::ConfigChecker::ExternalDatabaseChecker.check + @notices = Gitlab::ConfigChecker::ExternalDatabaseChecker.check @redis_versions = Gitlab::Redis::ALL_CLASSES.map(&:version).uniq end diff --git a/app/controllers/admin/spam_logs_controller.rb b/app/controllers/admin/spam_logs_controller.rb index b27185a6add..d7ed6aa33ef 100644 --- a/app/controllers/admin/spam_logs_controller.rb +++ b/app/controllers/admin/spam_logs_controller.rb @@ -5,7 +5,9 @@ class Admin::SpamLogsController < Admin::ApplicationController # rubocop: disable CodeReuse/ActiveRecord def index - @spam_logs = SpamLog.includes(:user).order(id: :desc).page(params[:page]).without_count + @spam_logs = SpamLog.preload(user: [:trusted_with_spam_attribute]) + .order(id: :desc) + .page(params[:page]).without_count end # rubocop: enable CodeReuse/ActiveRecord diff --git a/app/controllers/admin/users_controller.rb b/app/controllers/admin/users_controller.rb index 1f05e4e7b21..ee78d5a8c35 100644 --- a/app/controllers/admin/users_controller.rb +++ b/app/controllers/admin/users_controller.rb @@ -164,6 +164,26 @@ class Admin::UsersController < Admin::ApplicationController end end + def trust + result = Users::TrustService.new(current_user).execute(user) + + if result[:status] == :success + redirect_back_or_admin_user(notice: _("Successfully trusted")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not updated")) + end + end + + def untrust + result = Users::UntrustService.new(current_user).execute(user) + + if result[:status] == :success + redirect_back_or_admin_user(notice: _("Successfully untrusted")) + else + redirect_back_or_admin_user(alert: _("Error occurred. User was not updated")) + end + end + def confirm if update_user(&:force_confirm) redirect_back_or_admin_user(notice: _("Successfully confirmed")) @@ -290,7 +310,7 @@ class Admin::UsersController < Admin::ApplicationController end def users_with_included_associations(users) - users.includes(:authorized_projects) # rubocop: disable CodeReuse/ActiveRecord + users.includes(:authorized_projects, :trusted_with_spam_attribute) # rubocop: disable CodeReuse/ActiveRecord end def admin_making_changes_for_another_user? @@ -342,6 +362,7 @@ class Admin::UsersController < Admin::ApplicationController :bio, :can_create_group, :color_scheme_id, + :discord, :email, :extern_uid, :external, @@ -350,6 +371,7 @@ class Admin::UsersController < Admin::ApplicationController :hide_no_ssh_key, :key_id, :linkedin, + :mastodon, :name, :password_expires_at, :projects_limit, @@ -358,7 +380,6 @@ class Admin::UsersController < Admin::ApplicationController :skype, :theme_id, :twitter, - :discord, :username, :website_url, :note, diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f60da46826a..6739fc57a1f 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -3,7 +3,7 @@ require 'gon' require 'fogbugz' -class ApplicationController < BaseActionController +class ApplicationController < ActionController::Base include Gitlab::GonHelper include Gitlab::NoCacheHeaders include GitlabRoutingHelper diff --git a/app/controllers/autocomplete_controller.rb b/app/controllers/autocomplete_controller.rb index c9cb1ca14e2..1c2bd10bc81 100644 --- a/app/controllers/autocomplete_controller.rb +++ b/app/controllers/autocomplete_controller.rb @@ -3,16 +3,18 @@ class AutocompleteController < ApplicationController include SearchRateLimitable - skip_before_action :authenticate_user!, only: [:users, :award_emojis, :merge_request_target_branches] + skip_before_action :authenticate_user!, only: [ + :users, :award_emojis, :merge_request_target_branches, :merge_request_source_branches + ] before_action :check_search_rate_limit!, only: [:users, :projects] feature_category :user_profile, [:users, :user] feature_category :groups_and_projects, [:projects] feature_category :team_planning, [:award_emojis] - feature_category :code_review_workflow, [:merge_request_target_branches] + feature_category :code_review_workflow, [:merge_request_target_branches, :merge_request_source_branches] feature_category :continuous_delivery, [:deploy_keys_with_owners] - urgency :low, [:merge_request_target_branches, :deploy_keys_with_owners, :users] + urgency :low, [:merge_request_target_branches, :merge_request_source_branches, :deploy_keys_with_owners, :users] urgency :low, [:award_emojis] urgency :medium, [:projects] @@ -62,14 +64,11 @@ class AutocompleteController < ApplicationController end def merge_request_target_branches - if target_branch_params.present? - merge_requests = MergeRequestsFinder.new(current_user, target_branch_params).execute - target_branches = merge_requests.recent_target_branches + merge_request_branches(target: true) + end - render json: target_branches.map { |target_branch| { title: target_branch } } - else - render json: { error: _('At least one of group_id or project_id must be specified') }, status: :bad_request - end + def merge_request_source_branches + merge_request_branches(source: true) end def deploy_keys_with_owners @@ -90,7 +89,7 @@ class AutocompleteController < ApplicationController .execute end - def target_branch_params + def branch_params params.permit(:group_id, :project_id).select { |_, v| v.present? } end @@ -98,6 +97,21 @@ class AutocompleteController < ApplicationController def presented_suggested_users [] end + + def merge_request_branches(source: false, target: false) + if branch_params.present? + merge_requests = MergeRequestsFinder.new(current_user, branch_params).execute + + branches = [] + + branches.concat(merge_requests.recent_source_branches) if source + branches.concat(merge_requests.recent_target_branches) if target + + render json: branches.map { |branch| { title: branch } } + else + render json: { error: _('At least one of group_id or project_id must be specified') }, status: :bad_request + end + end end AutocompleteController.prepend_mod_with('AutocompleteController') diff --git a/app/controllers/base_action_controller.rb b/app/controllers/base_action_controller.rb deleted file mode 100644 index af2c9e98778..00000000000 --- a/app/controllers/base_action_controller.rb +++ /dev/null @@ -1,31 +0,0 @@ -# frozen_string_literal: true - -# GitLab lightweight base action controller -# -# This class should be limited to content that -# is desired/required for *all* controllers in -# GitLab. -# -# Most controllers inherit from `ApplicationController`. -# Some controllers don't want or need all of that -# logic and instead inherit from `ActionController::Base`. -# This makes it difficult to set security headers and -# handle other critical logic across *all* controllers. -# -# Between this controller and `ApplicationController` -# no controller should ever inherit directly from -# `ActionController::Base` -# -# rubocop:disable Rails/ApplicationController -# rubocop:disable Gitlab/NamespacedClass -class BaseActionController < ActionController::Base - before_action :security_headers - - private - - def security_headers - headers['Cross-Origin-Opener-Policy'] = 'same-origin' if ::Feature.enabled?(:coop_header) - end -end -# rubocop:enable Gitlab/NamespacedClass -# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/chaos_controller.rb b/app/controllers/chaos_controller.rb index b61a8c5ff12..7328b793b09 100644 --- a/app/controllers/chaos_controller.rb +++ b/app/controllers/chaos_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class ChaosController < BaseActionController +# rubocop:disable Rails/ApplicationController +class ChaosController < ActionController::Base before_action :validate_chaos_secret, unless: :development_or_test? def leakmem @@ -94,3 +95,4 @@ class ChaosController < BaseActionController Rails.env.development? || Rails.env.test? end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/concerns/creates_commit.rb b/app/controllers/concerns/creates_commit.rb index 27f1d1f5528..5009bf7ff0c 100644 --- a/app/controllers/concerns/creates_commit.rb +++ b/app/controllers/concerns/creates_commit.rb @@ -3,6 +3,7 @@ module CreatesCommit extend ActiveSupport::Concern include Gitlab::Utils::StrongMemoize + include SafeFormatHelper # rubocop:disable Gitlab/ModuleWithInstanceVariables def create_commit(service, success_path:, failure_path:, failure_view: nil, success_notice: nil, target_project: nil) @@ -31,10 +32,10 @@ module CreatesCommit result = service.new(@project_to_commit_into, current_user, commit_params).execute if result[:status] == :success - update_flash_notice(success_notice) - success_path = final_success_path(success_path, target_project) + update_flash_notice(success_notice, success_path) + respond_to do |format| format.html { redirect_to success_path } format.json { render json: { message: _("success"), filePath: success_path } } @@ -65,8 +66,13 @@ module CreatesCommit private - def update_flash_notice(success_notice) - flash[:notice] = success_notice || _("Your changes have been successfully committed.") + def update_flash_notice(success_notice, success_path) + changes_link = ActionController::Base.helpers.link_to _('changes'), success_path, class: 'gl-link' + + default_message = safe_format(_("Your %{changes_link} have been committed successfully."), + changes_link: changes_link) + + flash[:notice] = success_notice || default_message if create_merge_request? flash[:notice] = diff --git a/app/controllers/concerns/issuable_actions.rb b/app/controllers/concerns/issuable_actions.rb index 28e1056092d..cd2372825ac 100644 --- a/app/controllers/concerns/issuable_actions.rb +++ b/app/controllers/concerns/issuable_actions.rb @@ -147,6 +147,8 @@ module IssuableActions finder = Issuable::DiscussionsListService.new(current_user, issuable, finder_params_for_issuable) discussion_notes = finder.execute + yield discussion_notes if block_given? + if finder.paginator.present? && finder.paginator.has_next_page? response.headers['X-Next-Page-Cursor'] = finder.paginator.cursor_for_next_page end diff --git a/app/controllers/concerns/render_access_tokens.rb b/app/controllers/concerns/render_access_tokens.rb index b0bbad7e37f..43e4686e66f 100644 --- a/app/controllers/concerns/render_access_tokens.rb +++ b/app/controllers/concerns/render_access_tokens.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module RenderAccessTokens extend ActiveSupport::Concern diff --git a/app/controllers/concerns/wiki_actions.rb b/app/controllers/concerns/wiki_actions.rb index c606ccf4a07..f8c3e125c3b 100644 --- a/app/controllers/concerns/wiki_actions.rb +++ b/app/controllers/concerns/wiki_actions.rb @@ -246,7 +246,7 @@ module WikiActions @sidebar_page = wiki.find_sidebar(params[:version_id]) unless @sidebar_page # Fallback to default sidebar - @sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries + @sidebar_wiki_entries, @sidebar_limited = wiki.sidebar_entries(load_content: Feature.enabled?(:wiki_front_matter_title, container)) end rescue ::Gitlab::Git::CommandTimedOut => e @sidebar_error = e @@ -326,7 +326,9 @@ module WikiActions end def load_content? - return false if %w[history destroy diff show].include?(params[:action]) + skip_actions = Feature.enabled?(:wiki_front_matter_title, container) ? %w[history destroy diff] : %w[history destroy diff show] + + return false if skip_actions.include?(params[:action]) true end diff --git a/app/controllers/dashboard_controller.rb b/app/controllers/dashboard_controller.rb index 188a8540a58..a0997484c58 100644 --- a/app/controllers/dashboard_controller.rb +++ b/app/controllers/dashboard_controller.rb @@ -14,6 +14,7 @@ class DashboardController < Dashboard::ApplicationController before_action only: :issues do push_frontend_feature_flag(:frontend_caching) + push_frontend_feature_flag(:group_multi_select_tokens) end before_action only: :merge_requests do diff --git a/app/controllers/explore/catalog_controller.rb b/app/controllers/explore/catalog_controller.rb new file mode 100644 index 00000000000..3cd3771129e --- /dev/null +++ b/app/controllers/explore/catalog_controller.rb @@ -0,0 +1,20 @@ +# frozen_string_literal: true + +module Explore + class CatalogController < Explore::ApplicationController + feature_category :pipeline_composition + before_action :check_feature_flag + + def show; end + + def index + render 'show' + end + + private + + def check_feature_flag + render_404 unless Feature.enabled?(:global_ci_catalog, current_user) + end + end +end diff --git a/app/controllers/external_redirect/external_redirect_controller.rb b/app/controllers/external_redirect/external_redirect_controller.rb new file mode 100644 index 00000000000..532196157b7 --- /dev/null +++ b/app/controllers/external_redirect/external_redirect_controller.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module ExternalRedirect + class ExternalRedirectController < ApplicationController + feature_category :navigation + skip_before_action :authenticate_user! + before_action :check_url_param + + def index + if known_url? + redirect_to url_param + else + render layout: 'fullscreen', locals: { + minimal: true, + url: url_param + } + end + end + + private + + def url_param + params['url']&.strip + end + + def known_url? + uri_data = Addressable::URI.parse(url_param) + + uri_data.site == Gitlab.config.gitlab.url + end + + def check_url_param + render_404 unless ::Gitlab::UrlSanitizer.valid_web?(url_param) + end + end +end diff --git a/app/controllers/groups/settings/applications_controller.rb b/app/controllers/groups/settings/applications_controller.rb index 3ae1ae824a0..5aea078db17 100644 --- a/app/controllers/groups/settings/applications_controller.rb +++ b/app/controllers/groups/settings/applications_controller.rb @@ -5,7 +5,7 @@ module Groups class ApplicationsController < Groups::ApplicationController include OauthApplications - prepend_before_action :authorize_admin_group! + before_action :authorize_admin_group! before_action :set_application, only: [:show, :edit, :update, :renew, :destroy] before_action :load_scopes, only: [:index, :create, :edit, :update] diff --git a/app/controllers/groups/settings/ci_cd_controller.rb b/app/controllers/groups/settings/ci_cd_controller.rb index f50cdd2b1de..371db7b30b6 100644 --- a/app/controllers/groups/settings/ci_cd_controller.rb +++ b/app/controllers/groups/settings/ci_cd_controller.rb @@ -15,7 +15,6 @@ module Groups before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) - push_frontend_feature_flag(:ci_variable_drawer, current_user) end urgency :low diff --git a/app/controllers/groups/work_items_controller.rb b/app/controllers/groups/work_items_controller.rb index bd85f12119b..ece279da778 100644 --- a/app/controllers/groups/work_items_controller.rb +++ b/app/controllers/groups/work_items_controller.rb @@ -4,6 +4,13 @@ module Groups class WorkItemsController < Groups::ApplicationController feature_category :team_planning + before_action do + push_force_frontend_feature_flag(:work_items, group&.work_items_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc, group&.work_items_mvc_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc_2, group&.work_items_mvc_2_feature_flag_enabled?) + push_force_frontend_feature_flag(:linked_work_items, group&.linked_work_items_feature_flag_enabled?) + end + def index not_found unless Feature.enabled?(:namespace_level_work_items, group) end diff --git a/app/controllers/groups_controller.rb b/app/controllers/groups_controller.rb index edc590e1370..5b9b3b7de11 100644 --- a/app/controllers/groups_controller.rb +++ b/app/controllers/groups_controller.rb @@ -36,7 +36,11 @@ class GroupsController < Groups::ApplicationController push_frontend_feature_flag(:or_issuable_queries, group) push_frontend_feature_flag(:frontend_caching, group) push_force_frontend_feature_flag(:work_items, group.work_items_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc, group.work_items_mvc_feature_flag_enabled?) + push_force_frontend_feature_flag(:work_items_mvc_2, group.work_items_mvc_2_feature_flag_enabled?) + push_force_frontend_feature_flag(:linked_work_items, group.linked_work_items_feature_flag_enabled?) push_frontend_feature_flag(:issues_grid_view) + push_frontend_feature_flag(:group_multi_select_tokens, group) end before_action only: :merge_requests do @@ -275,6 +279,7 @@ class GroupsController < Groups::ApplicationController :avatar, :description, :emails_disabled, + :emails_enabled, :show_diff_preview_in_email, :mentions_disabled, :lfs_enabled, diff --git a/app/controllers/health_controller.rb b/app/controllers/health_controller.rb index 2b2db2f950c..1381999ab4c 100644 --- a/app/controllers/health_controller.rb +++ b/app/controllers/health_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class HealthController < BaseActionController +# rubocop:disable Rails/ApplicationController +class HealthController < ActionController::Base protect_from_forgery with: :exception, prepend: true include RequiresAllowlistedMonitoringClient @@ -39,3 +40,4 @@ class HealthController < BaseActionController render json: result.json, status: result.http_status end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/import/bulk_imports_controller.rb b/app/controllers/import/bulk_imports_controller.rb index a8ec738caf4..bc425323d6f 100644 --- a/app/controllers/import/bulk_imports_controller.rb +++ b/app/controllers/import/bulk_imports_controller.rb @@ -6,6 +6,10 @@ class Import::BulkImportsController < ApplicationController before_action :ensure_bulk_import_enabled before_action :verify_blocked_uri, only: :status + before_action only: [:history] do + push_frontend_feature_flag(:bulk_import_details_page) + end + feature_category :importers urgency :low @@ -49,6 +53,10 @@ class Import::BulkImportsController < ApplicationController end end + def details + render_404 unless Feature.enabled?(:bulk_import_details_page) + end + def create return render json: { success: false }, status: :too_many_requests if throttled_request? return render json: { success: false }, status: :unprocessable_entity unless valid_create_params? diff --git a/app/controllers/jira_connect/subscriptions_controller.rb b/app/controllers/jira_connect/subscriptions_controller.rb index 773ef2bddca..17a79f83a78 100644 --- a/app/controllers/jira_connect/subscriptions_controller.rb +++ b/app/controllers/jira_connect/subscriptions_controller.rb @@ -48,7 +48,7 @@ class JiraConnect::SubscriptionsController < JiraConnect::ApplicationController def destroy subscription = current_jira_installation.subscriptions.find(params[:id]) - if !jira_user&.site_admin? + if !jira_user&.jira_admin? render json: { error: 'forbidden' }, status: :forbidden elsif subscription.destroy render json: { success: true } diff --git a/app/controllers/jwt_controller.rb b/app/controllers/jwt_controller.rb index 84ccfbc603a..83409c7e096 100644 --- a/app/controllers/jwt_controller.rb +++ b/app/controllers/jwt_controller.rb @@ -33,7 +33,7 @@ class JwtController < ApplicationController @authentication_result = Gitlab::Auth::Result.new(nil, nil, :none, Gitlab::Auth.read_only_authentication_abilities) authenticate_with_http_basic do |login, password| - @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, ip: request.ip) + @authentication_result = Gitlab::Auth.find_for_git_client(login, password, project: nil, request: request) if @authentication_result.failed? log_authentication_failed(login, @authentication_result) @@ -98,11 +98,7 @@ class JwtController < ApplicationController return unless params[:scope].present? scopes = Array(Rack::Utils.parse_query(request.query_string)['scope']) - if Feature.enabled?(:jwt_auth_space_delimited_scopes, Feature.current_request) - scopes.flat_map(&:split) - else - scopes - end + scopes.flat_map(&:split) end def auth_user diff --git a/app/controllers/metrics_controller.rb b/app/controllers/metrics_controller.rb index 61851fd1c60..9f41c092fa0 100644 --- a/app/controllers/metrics_controller.rb +++ b/app/controllers/metrics_controller.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true -class MetricsController < BaseActionController +# rubocop:disable Rails/ApplicationController +class MetricsController < ActionController::Base include RequiresAllowlistedMonitoringClient protect_from_forgery with: :exception, prepend: true @@ -35,3 +36,4 @@ class MetricsController < BaseActionController ) end end +# rubocop:enable Rails/ApplicationController diff --git a/app/controllers/oauth/jira_dvcs/authorizations_controller.rb b/app/controllers/oauth/jira_dvcs/authorizations_controller.rb deleted file mode 100644 index ba587944a36..00000000000 --- a/app/controllers/oauth/jira_dvcs/authorizations_controller.rb +++ /dev/null @@ -1,86 +0,0 @@ -# frozen_string_literal: true - -# This controller's role is to mimic and rewire the GitLab OAuth -# flow routes for Jira DVCS integration. -# See https://gitlab.com/gitlab-org/gitlab/issues/2381 -# -class Oauth::JiraDvcs::AuthorizationsController < ApplicationController - skip_before_action :authenticate_user! - skip_before_action :verify_authenticity_token - - before_action :reversible_end_of_life! - before_action :validate_redirect_uri, only: :new - - feature_category :integrations - - # 1. Rewire Jira OAuth initial request to our stablished OAuth authorization URL. - def new - session[:redirect_uri] = params['redirect_uri'] - - redirect_to oauth_authorization_path( - client_id: params['client_id'], - response_type: 'code', - scope: normalize_scope(params['scope']), - redirect_uri: oauth_jira_dvcs_callback_url - ) - end - - # 2. Handle the callback call as we were a Github Enterprise instance client. - def callback - # Handling URI query params concatenation. - redirect_uri = URI.parse(session['redirect_uri']) - new_query = URI.decode_www_form(String(redirect_uri.query)) << ['code', params[:code]] - redirect_uri.query = URI.encode_www_form(new_query) - - redirect_to redirect_uri.to_s - end - - # 3. Rewire and adjust access_token request accordingly. - def access_token - # We have to modify request.parameters because Doorkeeper::Server reads params from there - request.parameters[:redirect_uri] = oauth_jira_dvcs_callback_url - - strategy = Doorkeeper::Server.new(self).token_request('authorization_code') - response = strategy.authorize - - if response.status == :ok - access_token, scope, token_type = response.body.values_at('access_token', 'scope', 'token_type') - - render body: "access_token=#{access_token}&scope=#{scope}&token_type=#{token_type}" - else - render status: response.status, body: response.body - end - rescue Doorkeeper::Errors::DoorkeeperError => e - render status: :unauthorized, body: e.type - end - - private - - # The endpoints in this controller have been deprecated since 15.1. - # - # Due to uncertainty about the impact of a full removal in 16.0, all endpoints return `404` - # by default but we allow customers to toggle a flag to reverse this breaking change. - # See https://gitlab.com/gitlab-org/gitlab/-/issues/362168#note_1347692683. - # - # TODO Make the breaking change irreversible https://gitlab.com/gitlab-org/gitlab/-/issues/408148. - def reversible_end_of_life! - render_404 unless Feature.enabled?(:jira_dvcs_end_of_life_amnesty) - end - - # When using the GitHub Enterprise connector in Jira we receive the "repo" scope, - # this doesn't exist in GitLab but we can map it to our "api" scope. - def normalize_scope(scope) - scope == 'repo' ? 'api' : scope - end - - def validate_redirect_uri - client = Doorkeeper::OAuth::Client.find(params[:client_id]) - return render_404 unless client - - return true if Doorkeeper::OAuth::Helpers::URIChecker.valid_for_authorization?( - params['redirect_uri'], client.redirect_uri - ) - - render_403 - end -end diff --git a/app/controllers/organizations/organizations_controller.rb b/app/controllers/organizations/organizations_controller.rb index 88c6c9b3cef..3085f0c07d1 100644 --- a/app/controllers/organizations/organizations_controller.rb +++ b/app/controllers/organizations/organizations_controller.rb @@ -19,5 +19,9 @@ module Organizations def groups_and_projects authorize_read_organization! end + + def users + authorize_read_organization! + end end end diff --git a/app/controllers/profiles/comment_templates_controller.rb b/app/controllers/profiles/comment_templates_controller.rb index d6725c27f76..f7c1f8733de 100644 --- a/app/controllers/profiles/comment_templates_controller.rb +++ b/app/controllers/profiles/comment_templates_controller.rb @@ -5,8 +5,6 @@ module Profiles feature_category :user_profile before_action do - render_404 unless Feature.enabled?(:saved_replies, current_user) - @hide_search_settings = true end end diff --git a/app/controllers/profiles/preferences_controller.rb b/app/controllers/profiles/preferences_controller.rb index 931070ecdd4..7059e2a0371 100644 --- a/app/controllers/profiles/preferences_controller.rb +++ b/app/controllers/profiles/preferences_controller.rb @@ -48,6 +48,7 @@ class Profiles::PreferencesController < Profiles::ApplicationController :first_day_of_week, :preferred_language, :time_display_relative, + :time_display_format, :show_whitespace_in_diffs, :view_diffs_file_by_file, :tab_width, diff --git a/app/controllers/profiles_controller.rb b/app/controllers/profiles_controller.rb index da15b393e6c..cb29f0f3539 100644 --- a/app/controllers/profiles_controller.rb +++ b/app/controllers/profiles_controller.rb @@ -111,6 +111,7 @@ class ProfilesController < Profiles::ApplicationController [ :avatar, :bio, + :discord, :email, :role, :gitpod_enabled, @@ -119,12 +120,12 @@ class ProfilesController < Profiles::ApplicationController :hide_project_limit, :linkedin, :location, + :mastodon, :name, :public_email, :commit_email, :skype, :twitter, - :discord, :username, :website_url, :organization, diff --git a/app/controllers/projects/application_controller.rb b/app/controllers/projects/application_controller.rb index 30c6f4d865a..4bfee0c9c82 100644 --- a/app/controllers/projects/application_controller.rb +++ b/app/controllers/projects/application_controller.rb @@ -91,6 +91,19 @@ class Projects::ApplicationController < ApplicationController def check_issues_available! return render_404 unless @project.feature_available?(:issues, current_user) end + + def set_is_ambiguous_ref + return @is_ambiguous_ref if defined? @is_ambiguous_ref + + @is_ambiguous_ref = if Feature.enabled?(:ambiguous_ref_modal, @project) + ExtractsRef::RequestedRef + .new(@project.repository, ref_type: ref_type, ref: @ref) + .find + .fetch(:ambiguous, false) + else + false + end + end end Projects::ApplicationController.prepend_mod_with('Projects::ApplicationController') diff --git a/app/controllers/projects/artifacts_controller.rb b/app/controllers/projects/artifacts_controller.rb index 2828d17c36f..85bdeb07b00 100644 --- a/app/controllers/projects/artifacts_controller.rb +++ b/app/controllers/projects/artifacts_controller.rb @@ -62,7 +62,11 @@ class Projects::ArtifactsController < Projects::ApplicationController conditionally_expand_blob(blob) if blob.external_link?(build) - redirect_to external_file_project_job_artifacts_path(@project, @build, path: params[:path]) + if Gitlab::CurrentSettings.enable_artifact_external_redirect_warning_page + redirect_to external_file_project_job_artifacts_path(@project, @build, path: params[:path]) + else + redirect_to blob.external_url(build) + end else respond_to do |format| format.html do diff --git a/app/controllers/projects/blob_controller.rb b/app/controllers/projects/blob_controller.rb index 015e56db012..7371902a6bd 100644 --- a/app/controllers/projects/blob_controller.rb +++ b/app/controllers/projects/blob_controller.rb @@ -31,6 +31,7 @@ class Projects::BlobController < Projects::ApplicationController before_action :authorize_edit_tree!, only: [:new, :create, :update, :destroy] before_action :commit, except: [:new, :create] + before_action :set_is_ambiguous_ref, only: [:show] before_action :check_for_ambiguous_ref, only: [:show] before_action :blob, except: [:new, :create] before_action :require_branch_head, only: [:edit, :update] @@ -48,6 +49,7 @@ class Projects::BlobController < Projects::ApplicationController urgency :low, [:create, :show, :edit, :update, :diff] before_action do + push_frontend_feature_flag(:blob_blame_info, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) diff --git a/app/controllers/projects/environments_controller.rb b/app/controllers/projects/environments_controller.rb index aabea122fb6..4b2749dc716 100644 --- a/app/controllers/projects/environments_controller.rb +++ b/app/controllers/projects/environments_controller.rb @@ -2,12 +2,18 @@ class Projects::EnvironmentsController < Projects::ApplicationController MIN_SEARCH_LENGTH = 3 + ACTIVE_STATES = %i[available stopping].freeze + SCOPES_TO_STATES = { "active" => ACTIVE_STATES, "stopped" => %i[stopped] }.freeze include ProductAnalyticsTracking include KasCookie layout 'project' + before_action only: [:index] do + push_frontend_feature_flag(:k8s_watch_api, project) + end + before_action :authorize_read_environment! before_action :authorize_create_environment!, only: [:new, :create] before_action :authorize_stop_environment!, only: [:stop] @@ -31,7 +37,9 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do - @environments = search_environments.with_state(params[:scope] || :available) + states = SCOPES_TO_STATES.fetch(params[:scope], ACTIVE_STATES) + @environments = search_environments.with_state(states) + environments_count_by_state = search_environments.count_by_state Gitlab::PollingInterval.set_header(response, interval: 3_000) @@ -40,6 +48,7 @@ class Projects::EnvironmentsController < Projects::ApplicationController review_app: serialize_review_app, can_stop_stale_environments: can?(current_user, :stop_environment, @project), available_count: environments_count_by_state[:available], + active_count: environments_count_by_state[:available] + environments_count_by_state[:stopping], stopped_count: environments_count_by_state[:stopped] } end @@ -54,14 +63,16 @@ class Projects::EnvironmentsController < Projects::ApplicationController respond_to do |format| format.html format.json do + states = SCOPES_TO_STATES.fetch(params[:scope], ACTIVE_STATES) folder_environments = search_environments(type: params[:id]) - @environments = folder_environments.with_state(params[:scope] || :available) + @environments = folder_environments.with_state(states) .order(:name) render json: { environments: serialize_environments(request, response), available_count: folder_environments.available.count, + active_count: folder_environments.active.count, stopped_count: folder_environments.stopped.count } end diff --git a/app/controllers/projects/group_links_controller.rb b/app/controllers/projects/group_links_controller.rb index 60300f78bbb..5f8bf423219 100644 --- a/app/controllers/projects/group_links_controller.rb +++ b/app/controllers/projects/group_links_controller.rb @@ -9,30 +9,47 @@ class Projects::GroupLinksController < Projects::ApplicationController feature_category :groups_and_projects def update - Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params) + result = Projects::GroupLinks::UpdateService.new(group_link, current_user).execute(group_link_params) - if group_link.expires? - render json: { - expires_in: helpers.time_ago_with_tooltip(group_link.expires_at), - expires_soon: group_link.expires_soon? - } - else - render json: {} + if result.success? + if group_link.expires? + render json: { + expires_in: helpers.time_ago_with_tooltip(group_link.expires_at), + expires_soon: group_link.expires_soon? + } + else + render json: {} + end + elsif result.reason == :not_found + render json: { message: result.message }, status: :not_found end end def destroy - ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link) - - respond_to do |format| - format.html do - if can?(current_user, :admin_group, group_link.group) - redirect_to group_path(group_link.group), status: :found - elsif can?(current_user, :admin_project, group_link.project) - redirect_to project_project_members_path(project), status: :found + result = ::Projects::GroupLinks::DestroyService.new(project, current_user).execute(group_link) + + if result.success? + respond_to do |format| + format.html do + if can?(current_user, :admin_group, group_link.group) + redirect_to group_path(group_link.group), status: :found + elsif can?(current_user, :admin_project, group_link.project) + redirect_to project_project_members_path(project), status: :found + end + end + format.js { head :ok } + end + else + respond_to do |format| + format.html do + redirect_to project_project_members_path(project, tab: :groups), status: :found, + alert: _('The project-group link could not be removed.') + end + + format.js do + render json: { message: result.message }, status: :not_found if result.reason == :not_found end end - format.js { head :ok } end end diff --git a/app/controllers/projects/incidents_controller.rb b/app/controllers/projects/incidents_controller.rb index bacf3192ee6..a3c1fd64a9d 100644 --- a/app/controllers/projects/incidents_controller.rb +++ b/app/controllers/projects/incidents_controller.rb @@ -12,7 +12,7 @@ class Projects::IncidentsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items_mvc_2, @project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:moved_mr_sidebar, project) push_force_frontend_feature_flag(:linked_work_items, @project&.linked_work_items_feature_flag_enabled?) - push_frontend_feature_flag(:notifications_todos_buttons, project) + push_frontend_feature_flag(:notifications_todos_buttons, current_user) end feature_category :incident_management diff --git a/app/controllers/projects/issues_controller.rb b/app/controllers/projects/issues_controller.rb index 4849cccac52..a6444dc038c 100644 --- a/app/controllers/projects/issues_controller.rb +++ b/app/controllers/projects/issues_controller.rb @@ -45,8 +45,6 @@ class Projects::IssuesController < Projects::ApplicationController before_action do push_frontend_feature_flag(:preserve_unchanged_markdown, project) - push_frontend_feature_flag(:service_desk_new_note_email_native_attachments, project) - push_frontend_feature_flag(:saved_replies, current_user) push_frontend_feature_flag(:issues_grid_view) push_frontend_feature_flag(:service_desk_ticket) push_frontend_feature_flag(:issues_list_drawer, project) @@ -60,17 +58,17 @@ class Projects::IssuesController < Projects::ApplicationController before_action only: [:index, :service_desk] do push_frontend_feature_flag(:or_issuable_queries, project) push_frontend_feature_flag(:frontend_caching, project&.group) + push_frontend_feature_flag(:group_multi_select_tokens, project) end before_action only: :show do - push_frontend_feature_flag(:issue_assignees_widget, project) push_frontend_feature_flag(:work_items_mvc, project&.group) push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) push_frontend_feature_flag(:epic_widget_edit_confirmation, project) push_frontend_feature_flag(:moved_mr_sidebar, project) push_force_frontend_feature_flag(:linked_work_items, project.linked_work_items_feature_flag_enabled?) - push_frontend_feature_flag(:notifications_todos_buttons, project) + push_frontend_feature_flag(:notifications_todos_buttons, current_user) end around_action :allow_gitaly_ref_name_caching, only: [:discussions] diff --git a/app/controllers/projects/jobs_controller.rb b/app/controllers/projects/jobs_controller.rb index 802ffd99e41..d5a7f25d4ce 100644 --- a/app/controllers/projects/jobs_controller.rb +++ b/app/controllers/projects/jobs_controller.rb @@ -6,14 +6,16 @@ class Projects::JobsController < Projects::ApplicationController include ContinueParams include ProjectStatsRefreshConflictsGuard - urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw] + urgency :low, [:index, :show, :trace, :retry, :play, :cancel, :unschedule, :erase, :raw, :test_report_summary] before_action :find_job_as_build, except: [:index, :play, :retry, :show] before_action :find_job_as_processable, only: [:play, :retry, :show] before_action :authorize_read_build_trace!, only: [:trace, :raw] - before_action :authorize_read_build! + before_action :authorize_read_build!, except: [:test_report_summary] + before_action :authorize_read_build_report_results!, only: [:test_report_summary] before_action :authorize_update_build!, - except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule] + except: [:index, :show, :raw, :trace, :erase, :cancel, :unschedule, :test_report_summary] + before_action :authorize_cancel_build!, only: [:cancel] before_action :authorize_erase_build!, only: [:erase] before_action :authorize_use_build_terminal!, only: [:terminal, :terminal_websocket_authorize] before_action :verify_api_request!, only: :terminal_websocket_authorize @@ -153,6 +155,20 @@ class Projects::JobsController < Projects::ApplicationController end end + def test_report_summary + return not_found unless @build.report_results.present? + + summary = Gitlab::Ci::Reports::TestReportSummary.new(@build.report_results) + + respond_to do |format| + format.json do + render json: TestReportSummarySerializer + .new(project: project, current_user: @current_user) + .represent(summary) + end + end + end + def terminal end @@ -170,10 +186,18 @@ class Projects::JobsController < Projects::ApplicationController attr_reader :build + def authorize_read_build_report_results! + return access_denied! unless can?(current_user, :read_build_report_results, build) + end + def authorize_update_build! return access_denied! unless can?(current_user, :update_build, @build) end + def authorize_cancel_build! + return access_denied! unless can?(current_user, :cancel_build, @build) + end + def authorize_erase_build! return access_denied! unless can?(current_user, :erase_build, @build) end diff --git a/app/controllers/projects/merge_requests/drafts_controller.rb b/app/controllers/projects/merge_requests/drafts_controller.rb index 74c495261a3..fb0073e0ad4 100644 --- a/app/controllers/projects/merge_requests/drafts_controller.rb +++ b/app/controllers/projects/merge_requests/drafts_controller.rb @@ -61,7 +61,9 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli merge_request_activity_counter.track_submit_review_comment(user: current_user) end - if Gitlab::Utils.to_boolean(approve_params[:approve]) + if Feature.enabled?(:mr_request_changes, current_user) && reviewer_state_params[:reviewer_state] + update_reviewer_state + elsif Gitlab::Utils.to_boolean(approve_params[:approve]) unless merge_request.approved_by?(current_user) success = ::MergeRequests::ApprovalService .new(project: @project, current_user: current_user, params: approve_params) @@ -144,6 +146,10 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli params.permit(:approve) end + def reviewer_state_params + params.permit(:reviewer_state) + end + def prepare_notes_for_rendering(notes) return [] unless notes @@ -180,6 +186,18 @@ class Projects::MergeRequests::DraftsController < Projects::MergeRequests::Appli def merge_request_activity_counter Gitlab::UsageDataCounters::MergeRequestActivityUniqueCounter end + + def update_reviewer_state + if reviewer_state_params[:reviewer_state] === 'approved' + ::MergeRequests::ApprovalService + .new(project: @project, current_user: current_user, params: approve_params) + .execute(merge_request) + else + ::MergeRequests::UpdateReviewerStateService + .new(project: @project, current_user: current_user) + .execute(merge_request, reviewer_state_params[:reviewer_state]) + end + end end Projects::MergeRequests::DraftsController.prepend_mod diff --git a/app/controllers/projects/merge_requests_controller.rb b/app/controllers/projects/merge_requests_controller.rb index ad7b7221e44..eb7505bd81f 100644 --- a/app/controllers/projects/merge_requests_controller.rb +++ b/app/controllers/projects/merge_requests_controller.rb @@ -11,6 +11,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo include SourcegraphDecorator include DiffHelper include Gitlab::Cache::Helpers + include MergeRequestsHelper prepend_before_action(only: [:index]) { authenticate_sessionless_user!(:rss) } skip_before_action :merge_request, only: [:index, :bulk_update, :export_csv] @@ -37,15 +38,15 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo before_action only: [:show, :diffs] do push_frontend_feature_flag(:core_security_mr_widget_counts, project) - push_frontend_feature_flag(:issue_assignees_widget, @project) push_frontend_feature_flag(:moved_mr_sidebar, project) push_frontend_feature_flag(:sast_reports_in_inline_diff, project) push_frontend_feature_flag(:mr_experience_survey, project) - push_frontend_feature_flag(:saved_replies, current_user) push_force_frontend_feature_flag(:summarize_my_code_review, summarize_my_code_review_enabled?) push_frontend_feature_flag(:ci_job_failures_in_mr, project) push_frontend_feature_flag(:mr_pipelines_graphql, project) - push_frontend_feature_flag(:notifications_todos_buttons, project) + push_frontend_feature_flag(:notifications_todos_buttons, current_user) + push_frontend_feature_flag(:widget_pipeline_pass_subscription_update, project) + push_frontend_feature_flag(:mr_request_changes, current_user) end before_action only: [:edit] do @@ -159,7 +160,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo .represent( @pipelines, preload: true, - disable_failed_builds: ::Feature.enabled?(:ci_fix_performance_pipelines_json_endpoint, @project) + disable_failed_builds: true ), count: { all: @pipelines.count @@ -344,9 +345,16 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def discussions - merge_request.discussions_diffs.load_highlight + if Feature.enabled?(:only_highlight_discussions_requested, project) + super do |discussion_notes| + note_ids = discussion_notes.flat_map { |x| x.notes.collect(&:id) } + merge_request.discussions_diffs.load_highlight(diff_note_ids: note_ids) + end + else + merge_request.discussions_diffs.load_highlight - super + super + end end def export_csv @@ -617,7 +625,7 @@ class Projects::MergeRequestsController < Projects::MergeRequests::ApplicationCo end def endpoint_diff_batch_url(project, merge_request) - per_page = current_user&.view_diffs_file_by_file ? '1' : '5' + per_page = current_user&.view_diffs_file_by_file ? '1' : DIFF_BATCH_ENDPOINT_PER_PAGE.to_s params = request .query_parameters .merge(view: 'inline', diff_head: true, w: show_whitespace, page: '0', per_page: per_page) diff --git a/app/controllers/projects/ml/model_versions_controller.rb b/app/controllers/projects/ml/model_versions_controller.rb new file mode 100644 index 00000000000..bc69f5bf144 --- /dev/null +++ b/app/controllers/projects/ml/model_versions_controller.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Projects + module Ml + class ModelVersionsController < ::Projects::ApplicationController + before_action :authorize_read_model_registry! + feature_category :mlops + + def show + @model_version = ::Ml::ModelVersion.by_project_id_and_id(@project, params[:model_version_id]) + + return render_404 unless @model_version + + @model = @model_version.model + end + + private + + def authorize_read_model_registry! + render_404 unless can?(current_user, :read_model_registry, @project) + end + end + end +end diff --git a/app/controllers/projects/ml/models_controller.rb b/app/controllers/projects/ml/models_controller.rb index 4ff7d014723..68a8b7a1686 100644 --- a/app/controllers/projects/ml/models_controller.rb +++ b/app/controllers/projects/ml/models_controller.rb @@ -3,26 +3,45 @@ module Projects module Ml class ModelsController < ::Projects::ApplicationController - before_action :check_feature_enabled - before_action :set_model, only: [:show] + before_action :authorize_read_model_registry! + before_action :authorize_write_model_registry!, only: [:destroy] + before_action :set_model, only: [:show, :destroy] feature_category :mlops MAX_MODELS_PER_PAGE = 20 def index - @paginator = ::Projects::Ml::ModelFinder.new(@project) - .execute - .keyset_paginate(cursor: params[:cursor], per_page: MAX_MODELS_PER_PAGE) + find_params = params + .transform_keys(&:underscore) + .permit(:name, :order_by, :sort) + + finder = ::Projects::Ml::ModelFinder.new(@project, find_params) + + @paginator = finder.execute.keyset_paginate(cursor: params[:cursor], per_page: MAX_MODELS_PER_PAGE) + + @model_count = finder.count end def show; end + def destroy + @model.destroy! + + redirect_to project_ml_models_path(@project), + status: :found, + notice: s_("MlExperimentTracking|Model removed") + end + private - def check_feature_enabled + def authorize_read_model_registry! render_404 unless can?(current_user, :read_model_registry, @project) end + def authorize_write_model_registry! + render_404 unless can?(current_user, :write_model_registry, @project) + end + def set_model @model = ::Ml::Model.by_project_id_and_id(@project, params[:model_id]) diff --git a/app/controllers/projects/pipelines_controller.rb b/app/controllers/projects/pipelines_controller.rb index 036ea45cc78..cd2db2dad2c 100644 --- a/app/controllers/projects/pipelines_controller.rb +++ b/app/controllers/projects/pipelines_controller.rb @@ -18,7 +18,8 @@ class Projects::PipelinesController < Projects::ApplicationController before_action :authorize_read_build!, only: [:index, :show] before_action :authorize_read_ci_cd_analytics!, only: [:charts] before_action :authorize_create_pipeline!, only: [:new, :create] - before_action :authorize_update_pipeline!, only: [:retry, :cancel] + before_action :authorize_update_pipeline!, only: [:retry] + before_action :authorize_cancel_pipeline!, only: [:cancel] before_action :ensure_pipeline, only: [:show, :downloadable_artifacts] before_action :reject_if_build_artifacts_size_refreshing!, only: [:destroy] @@ -303,6 +304,10 @@ class Projects::PipelinesController < Projects::ApplicationController return access_denied! unless can?(current_user, :update_pipeline, @pipeline) end + def authorize_cancel_pipeline! + return access_denied! unless can?(current_user, :cancel_pipeline, @pipeline) + end + def limited_pipelines_count(project, scope = nil) finder = Ci::PipelinesFinder.new(project, current_user, index_params.merge(scope: scope)) diff --git a/app/controllers/projects/raw_controller.rb b/app/controllers/projects/raw_controller.rb index 79b5990abba..d0a80c6aa07 100644 --- a/app/controllers/projects/raw_controller.rb +++ b/app/controllers/projects/raw_controller.rb @@ -19,7 +19,8 @@ class Projects::RawController < Projects::ApplicationController def show @blob = @repository.blob_at(@ref, @path, limit: Gitlab::Git::Blob::LFS_POINTER_MAX_SIZE) - send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: Guest.can?(:read_code, @project)) + send_blob(@repository, @blob, inline: (params[:inline] != 'false'), allow_caching: +::Users::Anonymous.can?(:read_code, @project)) end private diff --git a/app/controllers/projects/repositories_controller.rb b/app/controllers/projects/repositories_controller.rb index 4a9282432fd..406e3bd62c2 100644 --- a/app/controllers/projects/repositories_controller.rb +++ b/app/controllers/projects/repositories_controller.rb @@ -48,7 +48,7 @@ class Projects::RepositoriesController < Projects::ApplicationController expires_in( cache_max_age(commit_id), - public: Guest.can?(:download_code, project), + public: ::Users::Anonymous.can?(:download_code, project), must_revalidate: true, stale_if_error: 5.minutes, stale_while_revalidate: 1.minute, diff --git a/app/controllers/projects/service_desk_controller.rb b/app/controllers/projects/service_desk_controller.rb index ca3cecf5949..70cb439c4f3 100644 --- a/app/controllers/projects/service_desk_controller.rb +++ b/app/controllers/projects/service_desk_controller.rb @@ -29,7 +29,7 @@ class Projects::ServiceDeskController < Projects::ApplicationController end def allowed_update_attributes - %i[issue_template_key outgoing_name project_key] + %i[issue_template_key outgoing_name project_key add_external_participants_from_cc] end def service_desk_attributes @@ -41,7 +41,8 @@ class Projects::ServiceDeskController < Projects::ApplicationController issue_template_key: service_desk_settings&.issue_template_key, template_file_missing: service_desk_settings&.issue_template_missing?, outgoing_name: service_desk_settings&.outgoing_name, - project_key: service_desk_settings&.project_key + project_key: service_desk_settings&.project_key, + add_external_participants_from_cc: service_desk_settings&.add_external_participants_from_cc } end diff --git a/app/controllers/projects/settings/ci_cd_controller.rb b/app/controllers/projects/settings/ci_cd_controller.rb index 0845fbc9713..9a128adb926 100644 --- a/app/controllers/projects/settings/ci_cd_controller.rb +++ b/app/controllers/projects/settings/ci_cd_controller.rb @@ -14,7 +14,6 @@ module Projects before_action do push_frontend_feature_flag(:ci_variables_pages, current_user) - push_frontend_feature_flag(:ci_variable_drawer, current_user) end helper_method :highlight_badge diff --git a/app/controllers/projects/tree_controller.rb b/app/controllers/projects/tree_controller.rb index 0371fb21ac8..cfcc27edf3e 100644 --- a/app/controllers/projects/tree_controller.rb +++ b/app/controllers/projects/tree_controller.rb @@ -12,12 +12,14 @@ class Projects::TreeController < Projects::ApplicationController before_action :require_non_empty_project, except: [:new, :create] before_action :assign_ref_vars + before_action :set_is_ambiguous_ref, only: [:show] before_action :find_requested_ref, only: [:show] before_action :assign_dir_vars, only: [:create_dir] before_action :authorize_read_code! before_action :authorize_edit_tree!, only: [:create_dir] before_action do + push_frontend_feature_flag(:blob_blame_info, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_licensed_feature(:file_locks) if @project.licensed_feature_available?(:file_locks) diff --git a/app/controllers/projects/work_items_controller.rb b/app/controllers/projects/work_items_controller.rb index c3986be31b0..84cc1b16136 100644 --- a/app/controllers/projects/work_items_controller.rb +++ b/app/controllers/projects/work_items_controller.rb @@ -11,7 +11,6 @@ class Projects::WorkItemsController < Projects::ApplicationController push_force_frontend_feature_flag(:work_items, project&.work_items_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc, project&.work_items_mvc_feature_flag_enabled?) push_force_frontend_feature_flag(:work_items_mvc_2, project&.work_items_mvc_2_feature_flag_enabled?) - push_force_frontend_feature_flag(:saved_replies, current_user) push_force_frontend_feature_flag(:linked_work_items, project&.linked_work_items_feature_flag_enabled?) end diff --git a/app/controllers/projects_controller.rb b/app/controllers/projects_controller.rb index fa26601204a..cee56dca538 100644 --- a/app/controllers/projects_controller.rb +++ b/app/controllers/projects_controller.rb @@ -29,7 +29,8 @@ class ProjectsController < Projects::ApplicationController before_action :authorize_read_code!, only: [:refs] # Authorize - before_action :authorize_admin_project!, only: [:edit, :update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] + before_action :authorize_admin_project_or_custom_permissions!, only: :edit + before_action :authorize_admin_project!, only: [:update, :housekeeping, :download_export, :export, :remove_export, :generate_new_export] before_action :authorize_archive_project!, only: [:archive, :unarchive] before_action :event_filter, only: [:show, :activity] @@ -37,11 +38,14 @@ class ProjectsController < Projects::ApplicationController before_action :check_export_rate_limit!, only: [:export, :download_export, :generate_new_export] before_action do + push_frontend_feature_flag(:blob_blame_info, @project) push_frontend_feature_flag(:highlight_js_worker, @project) push_frontend_feature_flag(:remove_monitor_metrics, @project) push_frontend_feature_flag(:explain_code_chat, current_user) push_frontend_feature_flag(:service_desk_custom_email, @project) push_frontend_feature_flag(:issue_email_participants, @project) + # TODO: We need to remove the FF eventually when we rollout page_specific_styles + push_frontend_feature_flag(:page_specific_styles, current_user) push_licensed_feature(:file_locks) if @project.present? && @project.licensed_feature_available?(:file_locks) push_licensed_feature(:security_orchestration_policies) if @project.present? && @project.licensed_feature_available?(:security_orchestration_policies) push_force_frontend_feature_flag(:work_items, @project&.work_items_feature_flag_enabled?) @@ -595,6 +599,11 @@ class ProjectsController < Projects::ApplicationController def render_edit render 'edit' end + + # Overridden in EE + def authorize_admin_project_or_custom_permissions! + authorize_admin_project! + end end ProjectsController.prepend_mod_with('ProjectsController') diff --git a/app/controllers/repositories/git_http_client_controller.rb b/app/controllers/repositories/git_http_client_controller.rb index a5ca17db113..e8da6ee986a 100644 --- a/app/controllers/repositories/git_http_client_controller.rb +++ b/app/controllers/repositories/git_http_client_controller.rb @@ -129,7 +129,7 @@ module Repositories def handle_basic_authentication(login, password) @authentication_result = Gitlab::Auth.find_for_git_client( - login, password, project: project, ip: request.ip) + login, password, project: project, request: request) @authentication_result.success? end @@ -142,7 +142,7 @@ module Repositories Gitlab::ProtocolAccess.allowed?('http') && download_request? && container && - Guest.can?(repo_type.guest_read_ability, container) + ::Users::Anonymous.can?(repo_type.guest_read_ability, container) end def bypass_admin_mode!(&block) diff --git a/app/controllers/repositories/git_http_controller.rb b/app/controllers/repositories/git_http_controller.rb index 4f228ced542..48edda13904 100644 --- a/app/controllers/repositories/git_http_controller.rb +++ b/app/controllers/repositories/git_http_controller.rb @@ -106,7 +106,8 @@ module Repositories def access_actor return user if user - return :ci if ci? + + :ci if ci? end def access_check @@ -124,6 +125,13 @@ module Repositories def log_user_activity Users::ActivityService.new(author: user, project: project, namespace: project&.namespace).execute end + + def append_info_to_payload(payload) + super + + payload[:metadata] ||= {} + payload[:metadata][:repository_storage] = project&.repository_storage + end end end diff --git a/app/controllers/repositories/lfs_api_controller.rb b/app/controllers/repositories/lfs_api_controller.rb index d9ca216b168..d9d3753a2ff 100644 --- a/app/controllers/repositories/lfs_api_controller.rb +++ b/app/controllers/repositories/lfs_api_controller.rb @@ -60,7 +60,7 @@ module Repositories .for_oids(objects_oids) .index_by(&:oid) - guest_can_download = Guest.can?(:download_code, project) + guest_can_download = ::Users::Anonymous.can?(:download_code, project) objects.each do |object| if lfs_object = existing_oids[object[:oid]] @@ -87,7 +87,7 @@ module Repositories if existing_oids.include?(object[:oid]) object[:actions] = proxy_download_actions(object) - if Guest.can?(:download_code, project) + if ::Users::Anonymous.can?(:download_code, project) object[:authenticated] = true end else diff --git a/app/controllers/search_controller.rb b/app/controllers/search_controller.rb index 7fff31c767f..b639a9dda3f 100644 --- a/app/controllers/search_controller.rb +++ b/app/controllers/search_controller.rb @@ -4,7 +4,6 @@ class SearchController < ApplicationController include ControllerWithCrossProjectAccessCheck include SearchHelper include ProductAnalyticsTracking - include ProductAnalyticsTracking include SearchRateLimitable RESCUE_FROM_TIMEOUT_ACTIONS = [:count, :show, :autocomplete, :aggregations].freeze @@ -16,6 +15,12 @@ class SearchController < ApplicationController action: 'executed', destinations: [:redis_hll, :snowplow] + track_event :autocomplete, + name: 'i_search_total', + label: 'redis_hll_counters.search.search_total_unique_counts_monthly', + action: 'autocomplete', + destinations: [:redis_hll, :snowplow] + def self.search_rate_limited_endpoints %i[show count autocomplete] end @@ -35,18 +40,6 @@ class SearchController < ApplicationController update_scope_for_code_search end - before_action only: :show do - push_frontend_feature_flag(:search_notes_hide_archived_projects, current_user) - end - - before_action only: :show do - push_frontend_feature_flag(:search_issues_hide_archived_projects, current_user) - end - - before_action only: :show do - push_frontend_feature_flag(:search_merge_requests_hide_archived_projects, current_user) - end - rescue_from ActiveRecord::QueryCanceled, with: :render_timeout layout 'search' diff --git a/app/experiments/ios_specific_templates_experiment.rb b/app/experiments/ios_specific_templates_experiment.rb deleted file mode 100644 index 5bd4a3d0287..00000000000 --- a/app/experiments/ios_specific_templates_experiment.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -class IosSpecificTemplatesExperiment < ApplicationExperiment - control - - before_run(if: :skip_experiment) { throw(:abort) } # rubocop:disable Cop/BanCatchThrow - - private - - def skip_experiment - actor_not_able_to_create_pipelines? || - project_targets_non_ios_platforms? || - project_has_gitlab_ci? || - project_has_pipelines? - end - - def actor_not_able_to_create_pipelines? - !context.actor.is_a?(User) || !context.actor.can?(:create_pipeline, context.project) - end - - def project_targets_non_ios_platforms? - context.project.project_setting.target_platforms.exclude?('ios') - end - - def project_has_gitlab_ci? - context.project.has_ci? && context.project.builds_enabled? - end - - def project_has_pipelines? - context.project.all_pipelines.count > 0 - end -end diff --git a/app/finders/ci/catalog/resources/versions_finder.rb b/app/finders/ci/catalog/resources/versions_finder.rb new file mode 100644 index 00000000000..b37d4f0377a --- /dev/null +++ b/app/finders/ci/catalog/resources/versions_finder.rb @@ -0,0 +1,58 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class VersionsFinder + include Gitlab::Utils::StrongMemoize + + def initialize(catalog_resources, current_user, params = {}) + # The catalog resources should already have their project association preloaded + @catalog_resources = Array.wrap(catalog_resources) + @current_user = current_user + @params = params + end + + def execute + return Ci::Catalog::Resources::Version.none if authorized_catalog_resources.empty? + + versions = params[:latest] ? get_latest_versions : get_versions + versions = versions.preloaded + sort(versions) + end + + private + + DEFAULT_SORT = :released_at_desc + + attr_reader :catalog_resources, :current_user, :params + + def get_versions + Ci::Catalog::Resources::Version.for_catalog_resources(authorized_catalog_resources) + end + + def get_latest_versions + Ci::Catalog::Resources::Version.latest_for_catalog_resources(authorized_catalog_resources) + end + + def authorized_catalog_resources + # Preload project authorizations to avoid N+1 queries + projects = catalog_resources.map(&:project) + ActiveRecord::Associations::Preloader.new(records: projects, associations: :project_feature).call + Preloaders::UserMaxAccessLevelInProjectsPreloader.new(projects, current_user).execute + + catalog_resources.select { |resource| authorized?(resource.project) } + end + strong_memoize_attr :authorized_catalog_resources + + def sort(versions) + versions.order_by(params[:sort] || DEFAULT_SORT) + end + + def authorized?(project) + Ability.allowed?(current_user, :read_release, project) + end + end + end + end +end diff --git a/app/finders/ci/runners_finder.rb b/app/finders/ci/runners_finder.rb index 331f732bff7..945d332ff47 100644 --- a/app/finders/ci/runners_finder.rb +++ b/app/finders/ci/runners_finder.rb @@ -20,6 +20,8 @@ module Ci filter_by_upgrade_status! filter_by_runner_type! filter_by_tag_list! + filter_by_creator_id! + filter_by_version_prefix! sort! request_tag_list! @@ -113,6 +115,21 @@ module Ci end end + def filter_by_creator_id! + creator_id = @params[:creator_id] + @runners = @runners.with_creator_id(creator_id) if creator_id.present? + end + + def filter_by_version_prefix! + return @runners unless @params[:version_prefix] + + sanitized_prefix = @params[:version_prefix][/^[\d+.]+/] + + return @runners unless sanitized_prefix + + @runners = @runners.with_version_prefix(sanitized_prefix) + end + def sort! @runners = @runners.order_by(sort_key) end diff --git a/app/finders/data_transfer/mocked_transfer_finder.rb b/app/finders/data_transfer/mocked_transfer_finder.rb deleted file mode 100644 index 9c5551005ea..00000000000 --- a/app/finders/data_transfer/mocked_transfer_finder.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -# Mocked data for data transfer -# Follow this epic for recent progress: https://gitlab.com/groups/gitlab-org/-/epics/9330 -module DataTransfer - class MockedTransferFinder - def execute - start_date = Date.new(2023, 0o1, 0o1) - date_for_index = ->(i) { (start_date + i.months).strftime('%Y-%m-%d') } - - 0.upto(11).map do |i| - { - date: date_for_index.call(i), - repository_egress: rand(70000..550000), - artifacts_egress: rand(70000..550000), - packages_egress: rand(70000..550000), - registry_egress: rand(70000..550000) - }.tap do |hash| - hash[:total_egress] = hash - .slice(:repository_egress, :artifacts_egress, :packages_egress, :registry_egress) - .values - .sum - end - end - end - end -end diff --git a/app/finders/merge_requests_finder.rb b/app/finders/merge_requests_finder.rb index 95b5b267089..b7de1c08f86 100644 --- a/app/finders/merge_requests_finder.rb +++ b/app/finders/merge_requests_finder.rb @@ -46,6 +46,7 @@ class MergeRequestsFinder < IssuableFinder :merged_before, :reviewer_id, :reviewer_username, + :source_branch, :target_branch, :wip ] @@ -73,7 +74,6 @@ class MergeRequestsFinder < IssuableFinder items = by_deployments(items) items = by_reviewer(items) items = by_source_project_id(items) - items = items.allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417462") by_approved(items) end @@ -82,7 +82,8 @@ class MergeRequestsFinder < IssuableFinder items = super(items) items = by_negated_reviewer(items) items = by_negated_approved_by(items) - by_negated_target_branch(items) + items = by_negated_target_branch(items) + by_negated_source_branch(items) end private @@ -133,6 +134,12 @@ class MergeRequestsFinder < IssuableFinder items.where.not(target_branch: not_params[:target_branch]) end + + def by_negated_source_branch(items) + return items unless not_params[:source_branch] + + items.where.not(source_branch: not_params[:source_branch]) + end # rubocop: enable CodeReuse/ActiveRecord def by_negated_approved_by(items) diff --git a/app/finders/organizations/user_organizations_finder.rb b/app/finders/organizations/user_organizations_finder.rb new file mode 100644 index 00000000000..739940c44ca --- /dev/null +++ b/app/finders/organizations/user_organizations_finder.rb @@ -0,0 +1,26 @@ +# frozen_string_literal: true + +module Organizations + class UserOrganizationsFinder + def initialize(current_user, target_user, params = {}) + @current_user = current_user + @target_user = target_user + @params = params + end + + def execute + return Organizations::Organization.none unless can_read_user_organizations? + return Organizations::Organization.none if target_user.blank? + + target_user.organizations + end + + private + + attr_reader :current_user, :target_user, :params + + def can_read_user_organizations? + current_user&.can?(:read_user_organizations, target_user) + end + end +end diff --git a/app/finders/packages/packages_finder.rb b/app/finders/packages/packages_finder.rb index 31fbbfb7937..8fe1a73a030 100644 --- a/app/finders/packages/packages_finder.rb +++ b/app/finders/packages/packages_finder.rb @@ -22,6 +22,7 @@ module Packages packages = filter_by_package_type(packages) packages = filter_by_package_name(packages) packages = filter_by_status(packages) + packages = filter_by_package_version(packages) order_packages(packages) end diff --git a/app/finders/packages/pypi/packages_finder.rb b/app/finders/packages/pypi/packages_finder.rb index 17138134eb3..944824bee6e 100644 --- a/app/finders/packages/pypi/packages_finder.rb +++ b/app/finders/packages/pypi/packages_finder.rb @@ -3,6 +3,8 @@ module Packages module Pypi class PackagesFinder < ::Packages::GroupOrProjectPackageFinder + extend ::Gitlab::Utils::Override + def execute return packages unless @params[:package_name] @@ -14,6 +16,15 @@ module Packages def packages base.pypi.has_version end + + override :group_packages + def group_packages + packages_visible_to_user( + @current_user, + within_group: @project_or_group, + with_package_registry_enabled: true + ) + end end end end diff --git a/app/finders/projects/ml/model_finder.rb b/app/finders/projects/ml/model_finder.rb index 1e407ba4aa4..57e0620c7a7 100644 --- a/app/finders/projects/ml/model_finder.rb +++ b/app/finders/projects/ml/model_finder.rb @@ -3,16 +3,58 @@ module Projects module Ml class ModelFinder - def initialize(project) + include Gitlab::Utils::StrongMemoize + + VALID_ORDER_BY = %w[name created_at id].freeze + VALID_SORT = %w[asc desc].freeze + + def initialize(project, params = {}) @project = project + @params = params end def execute - ::Ml::Model - .by_project(@project) - .including_latest_version - .with_version_count + relation + end + + def count + relation.length + end + + private + + def relation + @models = ::Ml::Model + .by_project(project) + .including_latest_version + .including_project + .with_version_count + + @models = by_name + ordered + end + strong_memoize_attr :relation + + def by_name + return models unless params[:name].present? + + models.by_name(params[:name]) + end + + def ordered + order_by = valid_or_default(params[:order_by]&.downcase, VALID_ORDER_BY, 'created_at') + sort = valid_or_default(params[:sort]&.downcase, VALID_SORT, 'desc') + + models.order_by("#{order_by}_#{sort}").with_order_id_desc end + + def valid_or_default(value, valid_values, default) + return value if valid_values.include?(value) + + default + end + + attr_reader :params, :project, :models end end end diff --git a/app/finders/projects_finder.rb b/app/finders/projects_finder.rb index 87edf36d1ce..1aa5245590e 100644 --- a/app/finders/projects_finder.rb +++ b/app/finders/projects_finder.rb @@ -28,6 +28,7 @@ # last_activity_before: datetime # repository_storage: string # not_aimed_for_deletion: boolean +# full_paths: string[] # class ProjectsFinder < UnionFinder include CustomAttributesFilter @@ -76,8 +77,9 @@ class ProjectsFinder < UnionFinder # EE would override this to add more filters def filter_projects(collection) - collection = collection.without_deleted + collection = by_deleted_status(collection) collection = by_ids(collection) + collection = by_full_paths(collection) collection = by_personal(collection) collection = by_starred(collection) collection = by_trending(collection) @@ -153,6 +155,12 @@ class ProjectsFinder < UnionFinder params[:min_access_level].present? end + def by_deleted_status(items) + return items.without_deleted unless current_user&.can?(:admin_all_resources) + + params[:include_pending_delete].present? ? items : items.without_deleted + end + # rubocop: disable CodeReuse/ActiveRecord def by_ids(items) items = items.where(id: project_ids_relation) if project_ids_relation @@ -162,6 +170,10 @@ class ProjectsFinder < UnionFinder end # rubocop: enable CodeReuse/ActiveRecord + def by_full_paths(items) + params[:full_paths].present? ? items.where_full_path_in(params[:full_paths], use_includes: false) : items + end + def union(items) find_union(items, Project).with_route end diff --git a/app/finders/user_group_notification_settings_finder.rb b/app/finders/user_group_notification_settings_finder.rb index c6a1a6b36d1..8d06d3d18ca 100644 --- a/app/finders/user_group_notification_settings_finder.rb +++ b/app/finders/user_group_notification_settings_finder.rb @@ -11,11 +11,16 @@ class UserGroupNotificationSettingsFinder @loaded_groups_with_ancestors = groups_with_ancestors.index_by(&:id) @loaded_notification_settings = user.notification_settings_for_groups(groups_with_ancestors).preload_source_route.index_by(&:source_id) - preload_emails_disabled + preload_emails_enabled - groups.map do |group| + group_notifications = groups.map do |group| find_notification_setting_for(group) end + + group_sources = group_notifications.map(&:source) + ActiveRecord::Associations::Preloader.new(records: group_sources, associations: :namespace_settings).call + + group_notifications end private @@ -45,18 +50,18 @@ class UserGroupNotificationSettingsFinder parent_setting.level != NotificationSetting.levels[:global] || parent_setting.notification_email.present? end - # This method preloads the `emails_disabled` strong memoized method for the given groups. + # This method preloads the `emails_enabled` strong memoized method for the given groups. # - # For each group, look up the ancestor hierarchy and look for any group where emails_disabled is true. + # For each group, look up the ancestor hierarchy and look for any group where emails_enabled is false. # The lookup is implemented with an EXISTS subquery, so we can look up the ancestor chain for each group individually. # The query will return groups where at least one ancestor has the `emails_disabled` set to true. # # After the query, we set the instance variable. - def preload_emails_disabled + def preload_emails_enabled group_ids_with_disabled_email = Group.ids_with_disabled_email(groups.to_a) groups.each do |group| - group.emails_disabled_memoized = group_ids_with_disabled_email.include?(group.id) if group.parent_id + group.emails_enabled_memoized = group_ids_with_disabled_email.exclude?(group.id) if group.parent_id end end end diff --git a/app/graphql/mutations/base_mutation.rb b/app/graphql/mutations/base_mutation.rb index 994668b5f8f..8419f7d5eae 100644 --- a/app/graphql/mutations/base_mutation.rb +++ b/app/graphql/mutations/base_mutation.rb @@ -30,12 +30,6 @@ module Mutations def ready?(**args) raise_resource_not_available_error!(ERROR_MESSAGE) if read_only? - missing_args = self.class.arguments.values - .reject { |arg| arg.accepts?(args.fetch(arg.keyword, :not_given)) } - .map(&:graphql_name) - - raise ArgumentError, "Arguments must be provided: #{missing_args.join(", ")}" if missing_args.any? - true end diff --git a/app/graphql/mutations/ci/catalog/resources/create.rb b/app/graphql/mutations/ci/catalog/resources/create.rb new file mode 100644 index 00000000000..7f934e101c8 --- /dev/null +++ b/app/graphql/mutations/ci/catalog/resources/create.rb @@ -0,0 +1,36 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Catalog + module Resources + class Create < BaseMutation + graphql_name 'CatalogResourcesCreate' + + argument :project_path, GraphQL::Types::ID, + required: true, + description: 'Project to convert to a catalog resource.' + + authorize :add_catalog_resource + + def resolve(project_path:) + project = authorized_find!(project_path: project_path) + response = ::Ci::Catalog::Resources::CreateService.new(project, current_user).execute + + errors = response.success? ? [] : [response.message] + + { + errors: errors + } + end + + private + + def find_object(project_path:) + Project.find_by_full_path(project_path) + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/catalog/resources/unpublish.rb b/app/graphql/mutations/ci/catalog/resources/unpublish.rb new file mode 100644 index 00000000000..e45e9646147 --- /dev/null +++ b/app/graphql/mutations/ci/catalog/resources/unpublish.rb @@ -0,0 +1,30 @@ +# frozen_string_literal: true + +module Mutations + module Ci + module Catalog + module Resources + class Unpublish < BaseMutation + graphql_name 'CatalogResourceUnpublish' + + authorize :add_catalog_resource + + argument :id, ::Types::GlobalIDType[::Ci::Catalog::Resource], + required: true, + description: 'Global ID of the catalog resource to unpublish.' + + def resolve(id:) + catalog_resource = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id)) + authorize!(catalog_resource&.project) + + catalog_resource.unpublish! + + { + errors: [] + } + end + end + end + end + end +end diff --git a/app/graphql/mutations/ci/job/cancel.rb b/app/graphql/mutations/ci/job/cancel.rb index dc9f4d19779..44a7772019d 100644 --- a/app/graphql/mutations/ci/job/cancel.rb +++ b/app/graphql/mutations/ci/job/cancel.rb @@ -11,7 +11,7 @@ module Mutations null: true, description: 'Job after the mutation.' - authorize :update_build + authorize :cancel_build def resolve(id:) job = authorized_find!(id: id) diff --git a/app/graphql/mutations/ci/pipeline/cancel.rb b/app/graphql/mutations/ci/pipeline/cancel.rb index 810f458fd75..1014462d0b1 100644 --- a/app/graphql/mutations/ci/pipeline/cancel.rb +++ b/app/graphql/mutations/ci/pipeline/cancel.rb @@ -6,7 +6,7 @@ module Mutations class Cancel < Base graphql_name 'PipelineCancel' - authorize :update_pipeline + authorize :cancel_pipeline def resolve(id:) pipeline = authorized_find!(id: id) diff --git a/app/graphql/mutations/commits/create.rb b/app/graphql/mutations/commits/create.rb index 02e1e4c78bf..cbe2c49e950 100644 --- a/app/graphql/mutations/commits/create.rb +++ b/app/graphql/mutations/commits/create.rb @@ -64,7 +64,7 @@ module Mutations result = ::Files::MultiService.new(project, current_user, attributes).execute { - content: actions.pluck(:content), # rubocop:disable CodeReuse/ActiveRecord because actions is an Array, not a Relation + content: actions.pluck(:content), # rubocop:disable CodeReuse/ActiveRecord -- Array#pluck commit: (project.repository.commit(result[:result]) if result[:status] == :success), commit_pipeline_path: UrlHelpers.new.graphql_etag_pipeline_sha_path(result[:result]), errors: Array.wrap(result[:message]) diff --git a/app/graphql/mutations/container_registry/protection/rule/create.rb b/app/graphql/mutations/container_registry/protection/rule/create.rb new file mode 100644 index 00000000000..cf8416480a2 --- /dev/null +++ b/app/graphql/mutations/container_registry/protection/rule/create.rb @@ -0,0 +1,63 @@ +# frozen_string_literal: true + +module Mutations + module ContainerRegistry + module Protection + module Rule + class Create < ::Mutations::BaseMutation + graphql_name 'CreateContainerRegistryProtectionRule' + description 'Creates a protection rule to restrict access to a project\'s container registry. ' \ + 'Available only when feature flag `container_registry_protected_containers` is enabled.' + + include FindsProject + + authorize :admin_container_image + + argument :project_path, + GraphQL::Types::ID, + required: true, + description: 'Full path of the project where a protection rule is located.' + + argument :container_path_pattern, + GraphQL::Types::String, + required: true, + description: + 'ContainerRegistryname protected by the protection rule. For example `@my-scope/my-container-*`. ' \ + 'Wildcard character `*` allowed.' + + argument :push_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + required: true, + description: + 'Max GitLab access level to prevent from pushing container images to the container registry. ' \ + 'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + argument :delete_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + required: true, + description: + 'Max GitLab access level to prevent from deleting container images in the container registry. ' \ + 'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :container_registry_protection_rule, + Types::ContainerRegistry::Protection::RuleType, + null: true, + description: 'Container registry protection rule after mutation.' + + def resolve(project_path:, **kwargs) + project = authorized_find!(project_path) + + if Feature.disabled?(:container_registry_protected_containers, project) + raise_resource_not_available_error!("'container_registry_protected_containers' feature flag is disabled") + end + + response = ::ContainerRegistry::Protection::CreateRuleService.new(project, current_user, kwargs).execute + + { container_registry_protection_rule: response.payload[:container_registry_protection_rule], + errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/merge_requests/accept.rb b/app/graphql/mutations/merge_requests/accept.rb index 220ebea22c7..604fdd49f45 100644 --- a/app/graphql/mutations/merge_requests/accept.rb +++ b/app/graphql/mutations/merge_requests/accept.rb @@ -9,6 +9,10 @@ module Mutations Accepts a merge request. When accepted, the source branch will be scheduled to merge into the target branch, either immediately if possible, or using one of the automatic merge strategies. + + [In GitLab 16.5](https://gitlab.com/gitlab-org/gitlab/-/issues/421510), the merging happens asynchronously. + This results in `mergeRequest` and `state` not updating after a mutation request, + because the merging may not have happened yet. DESC NOT_MERGEABLE = 'This branch cannot be merged' diff --git a/app/graphql/mutations/namespace/package_settings/update.rb b/app/graphql/mutations/namespace/package_settings/update.rb index 4e71bed52c6..97c16ee79fe 100644 --- a/app/graphql/mutations/namespace/package_settings/update.rb +++ b/app/graphql/mutations/namespace/package_settings/update.rb @@ -8,8 +8,6 @@ module Mutations include Mutations::ResolvesNamespace - NUGET_DUPLICATES_FF_ERROR = '`nuget_duplicates_option` feature flag is disabled.' - description <<~DESC These settings can be adjusted by the group Owner or Maintainer. [Issue 370471](https://gitlab.com/gitlab-org/gitlab/-/issues/370471) proposes limiting @@ -91,10 +89,6 @@ module Mutations def resolve(namespace_path:, **args) namespace = authorized_find!(namespace_path: namespace_path) - if nuget_duplicate_settings_present?(args) && Feature.disabled?(:nuget_duplicates_option, namespace) - raise_resource_not_available_error! NUGET_DUPLICATES_FF_ERROR - end - result = ::Namespaces::PackageSettings::UpdateService .new(container: namespace, current_user: current_user, params: args) .execute @@ -110,10 +104,6 @@ module Mutations def find_object(namespace_path:) resolve_namespace(full_path: namespace_path) end - - def nuget_duplicate_settings_present?(args) - args.key?(:nuget_duplicates_allowed) || args.key?(:nuget_duplicate_exception_regex) - end end end end diff --git a/app/graphql/mutations/organizations/create.rb b/app/graphql/mutations/organizations/create.rb new file mode 100644 index 00000000000..0d1b204a4c1 --- /dev/null +++ b/app/graphql/mutations/organizations/create.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +module Mutations + module Organizations + class Create < BaseMutation + graphql_name 'OrganizationCreate' + + authorize :create_organization + + field :organization, + ::Types::Organizations::OrganizationType, + null: true, + description: 'Organization created.' + + argument :name, GraphQL::Types::String, + required: true, + description: 'Name for the organization.' + + argument :path, GraphQL::Types::String, + required: true, + description: 'Path for the organization.' + + def resolve(args) + authorize!(:global) + + result = ::Organizations::CreateService.new( + current_user: current_user, + params: args + ).execute + + { organization: result.payload, errors: result.errors } + end + end + end +end diff --git a/app/graphql/mutations/packages/protection/rule/delete.rb b/app/graphql/mutations/packages/protection/rule/delete.rb new file mode 100644 index 00000000000..bd0159d3c23 --- /dev/null +++ b/app/graphql/mutations/packages/protection/rule/delete.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module Mutations + module Packages + module Protection + module Rule + class Delete < ::Mutations::BaseMutation + graphql_name 'DeletePackagesProtectionRule' + description 'Deletes a protection rule for packages. ' \ + 'Available only when feature flag `packages_protected_packages` is enabled.' + + authorize :admin_package + + argument :id, + ::Types::GlobalIDType[::Packages::Protection::Rule], + required: true, + description: 'Global ID of the package protection rule to delete.' + + field :package_protection_rule, + Types::Packages::Protection::RuleType, + null: true, + description: 'Packages protection rule that was deleted successfully.' + + def resolve(id:, **_kwargs) + if Feature.disabled?(:packages_protected_packages) + raise_resource_not_available_error!("'packages_protected_packages' feature flag is disabled") + end + + package_protection_rule = authorized_find!(id: id) + + response = ::Packages::Protection::DeleteRuleService.new(package_protection_rule, + current_user: current_user).execute + + { package_protection_rule: response.payload[:package_protection_rule], errors: response.errors } + end + end + end + end + end +end diff --git a/app/graphql/mutations/saved_replies/base.rb b/app/graphql/mutations/saved_replies/base.rb index 4923fcb7851..79761645eb7 100644 --- a/app/graphql/mutations/saved_replies/base.rb +++ b/app/graphql/mutations/saved_replies/base.rb @@ -23,10 +23,6 @@ module Mutations end end - def feature_enabled? - Feature.enabled?(:saved_replies, current_user) - end - def find_object(id) GitlabSchema.find_by_gid(id) end diff --git a/app/graphql/mutations/saved_replies/create.rb b/app/graphql/mutations/saved_replies/create.rb index d97461a1c2a..25c02b79cb8 100644 --- a/app/graphql/mutations/saved_replies/create.rb +++ b/app/graphql/mutations/saved_replies/create.rb @@ -16,8 +16,6 @@ module Mutations description: copy_field_description(Types::SavedReplyType, :content) def resolve(name:, content:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? - result = ::Users::SavedReplies::CreateService.new(current_user: current_user, name: name, content: content).execute present_result(result) end diff --git a/app/graphql/mutations/saved_replies/destroy.rb b/app/graphql/mutations/saved_replies/destroy.rb index 7cd0f21ad45..655ed9cb798 100644 --- a/app/graphql/mutations/saved_replies/destroy.rb +++ b/app/graphql/mutations/saved_replies/destroy.rb @@ -12,8 +12,6 @@ module Mutations description: copy_field_description(Types::SavedReplyType, :id) def resolve(id:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? - saved_reply = authorized_find!(id) result = ::Users::SavedReplies::DestroyService.new(saved_reply: saved_reply).execute present_result(result) diff --git a/app/graphql/mutations/saved_replies/update.rb b/app/graphql/mutations/saved_replies/update.rb index d9368de7547..f5dc81614d2 100644 --- a/app/graphql/mutations/saved_replies/update.rb +++ b/app/graphql/mutations/saved_replies/update.rb @@ -20,8 +20,6 @@ module Mutations description: copy_field_description(Types::SavedReplyType, :content) def resolve(id:, name:, content:) - raise Gitlab::Graphql::Errors::ResourceNotAvailable, 'Feature disabled' unless feature_enabled? - saved_reply = authorized_find!(id) result = ::Users::SavedReplies::UpdateService.new(saved_reply: saved_reply, name: name, content: content).execute present_result(result) diff --git a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb index 51a1afdd5ab..2d722b02bf1 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/deployment_count_resolver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseCountResolver) +# rubocop:disable Graphql/ResolverType -- inherited from Resolvers::Analytics::CycleAnalytics::BaseCountResolver module Resolvers module Analytics module CycleAnalytics diff --git a/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb index fd20800ee16..32b884df84f 100644 --- a/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb +++ b/app/graphql/resolvers/analytics/cycle_analytics/issue_count_resolver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from Resolvers::Analytics::CycleAnalytics::BaseIssueResolver) +# rubocop:disable Graphql/ResolverType -- inherited from Resolvers::Analytics::CycleAnalytics::BaseIssueResolver module Resolvers module Analytics module CycleAnalytics diff --git a/app/graphql/resolvers/ci/catalog/resource_resolver.rb b/app/graphql/resolvers/ci/catalog/resource_resolver.rb new file mode 100644 index 00000000000..4b722bd3ec7 --- /dev/null +++ b/app/graphql/resolvers/ci/catalog/resource_resolver.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + module Catalog + class ResourceResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + authorize :read_code + + type ::Types::Ci::Catalog::ResourceType, null: true + + argument :id, ::Types::GlobalIDType[::Ci::Catalog::Resource], + required: false, + description: 'CI/CD Catalog resource global ID.' + + argument :full_path, GraphQL::Types::ID, + required: false, + description: 'CI/CD Catalog resource full path.' + + def ready?(**args) + unless args[:id].present? ^ args[:full_path].present? + raise Gitlab::Graphql::Errors::ArgumentError, + "Exactly one of 'id' or 'full_path' arguments is required." + end + + super + end + + def resolve(id: nil, full_path: nil) + if full_path.present? + project = Project.find_by_full_path(full_path) + authorize!(project) + + raise_resource_not_available_error! unless project.catalog_resource + + project.catalog_resource + else + catalog_resource = ::Gitlab::Graphql::Lazy.force(GitlabSchema.find_by_gid(id)) + authorize!(catalog_resource&.project) + + catalog_resource + end + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/catalog/resources_resolver.rb b/app/graphql/resolvers/ci/catalog/resources_resolver.rb new file mode 100644 index 00000000000..c6904dcd7f6 --- /dev/null +++ b/app/graphql/resolvers/ci/catalog/resources_resolver.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + module Catalog + class ResourcesResolver < BaseResolver + include LooksAhead + + type ::Types::Ci::Catalog::ResourceType.connection_type, null: true + + argument :scope, ::Types::Ci::Catalog::ResourceScopeEnum, + required: false, + default_value: :all, + description: 'Scope of the returned catalog resources.' + + argument :search, GraphQL::Types::String, + required: false, + description: 'Search term to filter the catalog resources by name or description.' + + argument :sort, ::Types::Ci::Catalog::ResourceSortEnum, + required: false, + description: 'Sort catalog resources by given criteria.' + + # TODO: https://gitlab.com/gitlab-org/gitlab/-/issues/429636 + argument :project_path, GraphQL::Types::ID, + required: false, + description: 'Project with the namespace catalog.' + + def resolve_with_lookahead(scope:, project_path: nil, search: nil, sort: nil) + if project_path.present? + project = Project.find_by_full_path(project_path) + + apply_lookahead( + ::Ci::Catalog::Listing + .new(context[:current_user]) + .resources(namespace: project.root_namespace, sort: sort, search: search) + ) + elsif scope == :all + apply_lookahead(::Ci::Catalog::Listing.new(context[:current_user]).resources(sort: sort, search: search)) + end + end + + private + + def preloads + { + web_path: { project: { namespace: :route } }, + readme_html: { project: :route } + } + end + end + end + end +end diff --git a/app/graphql/resolvers/ci/catalog/versions_resolver.rb b/app/graphql/resolvers/ci/catalog/versions_resolver.rb new file mode 100644 index 00000000000..046adeb7a67 --- /dev/null +++ b/app/graphql/resolvers/ci/catalog/versions_resolver.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +module Resolvers + module Ci + module Catalog + class VersionsResolver < ::Resolvers::ReleasesResolver + type Types::ReleaseType.connection_type, null: true + + # This allows a maximum of 1 call to the field that uses this resolver. If the + # field is evaluated on more than one node, it causes performance degradation. + extension ::Gitlab::Graphql::Limit::FieldCallCount, limit: 1 + + private + + def get_project + object.respond_to?(:project) ? object.project : object + end + + # Override the aliased method in ReleasesResolver + alias_method :project, :get_project + end + end + end +end diff --git a/app/graphql/resolvers/ci/runners_resolver.rb b/app/graphql/resolvers/ci/runners_resolver.rb index 3289f1d0056..9121c413b1f 100644 --- a/app/graphql/resolvers/ci/runners_resolver.rb +++ b/app/graphql/resolvers/ci/runners_resolver.rb @@ -41,6 +41,17 @@ module Resolvers required: false, description: 'Filter by upgrade status.' + argument :creator_id, ::Types::GlobalIDType[::User].as('UserID'), + required: false, + description: 'Filter runners by creator ID.' + + argument :version_prefix, GraphQL::Types::String, + required: false, + description: "Filter runners by version. Runners that contain runner managers with the version at " \ + "the start of the search term are returned. For example, the search term '14.' returns " \ + "runner managers with versions '14.11.1' and '14.2.3'.", + alpha: { milestone: '16.6' } + def resolve_with_lookahead(**args) apply_lookahead( ::Ci::RunnersFinder @@ -68,6 +79,9 @@ module Resolvers upgrade_status: params[:upgrade_status], search: params[:search], sort: params[:sort]&.to_s, + creator_id: + params[:creator_id] ? ::GitlabSchema.parse_gid(params[:creator_id], expected_type: ::User).model_id : nil, + version_prefix: params[:version_prefix], preload: false # we'll handle preloading ourselves }.compact .merge(parent_param) diff --git a/app/graphql/resolvers/concerns/caching_array_resolver.rb b/app/graphql/resolvers/concerns/caching_array_resolver.rb index 15bf9a90e46..f678e02533d 100644 --- a/app/graphql/resolvers/concerns/caching_array_resolver.rb +++ b/app/graphql/resolvers/concerns/caching_array_resolver.rb @@ -132,7 +132,7 @@ module CachingArrayResolver model_class.arel_table[Arel.star] end - # rubocop: disable Graphql/Descriptions (false positive!) + # rubocop: disable Graphql/Descriptions -- false positive def query_limit field&.max_page_size.presence || context.schema.default_max_page_size end diff --git a/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb index ecb105a64d0..1982b458143 100644 --- a/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb +++ b/app/graphql/resolvers/concerns/work_items/shared_filter_arguments.rb @@ -17,7 +17,12 @@ module WorkItems argument :state, Types::IssuableStateEnum, required: false, - description: 'Current state of the work item.' + description: 'Current state of the work item.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } argument :types, [Types::IssueTypeEnum], as: :issue_types, diff --git a/app/graphql/resolvers/container_repository_tags_resolver.rb b/app/graphql/resolvers/container_repository_tags_resolver.rb index 55a83dd49da..bc5006ae06c 100644 --- a/app/graphql/resolvers/container_repository_tags_resolver.rb +++ b/app/graphql/resolvers/container_repository_tags_resolver.rb @@ -14,21 +14,61 @@ module Resolvers required: false, default_value: nil + alias_method :container_repository, :object + def resolve(sort:, **filters) - result = tags + if container_repository.migrated? && Feature.enabled?(:use_repository_list_tags_on_graphql, container_repository.project) + page_size = [filters[:first], filters[:last]].map(&:to_i).max + + result = container_repository.tags_page( + before: filters[:before], + last: filters[:after], + sort: map_sort_field(sort), + name: filters[:name], + page_size: page_size + ) - if filters[:name] - result = tags.filter do |tag| - tag.name.include?(filters[:name]) + Gitlab::Graphql::ExternallyPaginatedArray.new( + parse_pagination_cursor(result, :previous), + parse_pagination_cursor(result, :next), + *result[:tags] + ) + else + result = tags + + if filters[:name] + result = tags.filter do |tag| + tag.name.include?(filters[:name]) + end end - end - result = sort_tags(result, sort) if sort - result + result = sort_tags(result, sort) if sort + result + end end private + def parse_pagination_cursor(result, direction) + pagination_uri = result.dig(:pagination, direction, :uri) + + return unless pagination_uri + + query_params = CGI.parse(pagination_uri.query) + key = direction == :previous ? 'before' : 'last' + + query_params[key]&.first + end + + def map_sort_field(sort) + return unless sort + + sort_field, direction = sort.to_s.split('_') + return sort_field if direction == 'asc' + + "-#{sort_field}" + end + def sort_tags(to_be_sorted, sort) raise StandardError unless Types::ContainerRepositoryTagsSortEnum.enum.include?(sort) @@ -41,7 +81,7 @@ module Resolvers end def tags - object.tags + container_repository.tags rescue Faraday::Error raise ::Gitlab::Graphql::Errors::ResourceNotAvailable, "Can't connect to the Container Registry. If this error persists, please review the troubleshooting documentation." end diff --git a/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb index 83bb144017c..133b86623f1 100644 --- a/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb +++ b/app/graphql/resolvers/data_transfer/group_data_transfer_resolver.rb @@ -16,16 +16,12 @@ module Resolvers def resolve(**args) return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, group) - results = if Feature.enabled?(:data_transfer_monitoring_mock_data, group) - ::DataTransfer::MockedTransferFinder.new.execute - else - ::DataTransfer::GroupDataTransferFinder.new( - group: group, - from: args[:from], - to: args[:to], - user: current_user - ).execute.map(&:attributes) - end + results = ::DataTransfer::GroupDataTransferFinder.new( + group: group, + from: args[:from], + to: args[:to], + user: current_user + ).execute.map(&:attributes) { egress_nodes: results.to_a } end diff --git a/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb index c3296f7d4c3..d711f837251 100644 --- a/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb +++ b/app/graphql/resolvers/data_transfer/project_data_transfer_resolver.rb @@ -16,16 +16,12 @@ module Resolvers def resolve(**args) return { egress_nodes: [] } unless Feature.enabled?(:data_transfer_monitoring, project.group) - results = if Feature.enabled?(:data_transfer_monitoring_mock_data, project.group) - ::DataTransfer::MockedTransferFinder.new.execute - else - ::DataTransfer::ProjectDataTransferFinder.new( - project: project, - from: args[:from], - to: args[:to], - user: current_user - ).execute - end + results = ::DataTransfer::ProjectDataTransferFinder.new( + project: project, + from: args[:from], + to: args[:to], + user: current_user + ).execute { egress_nodes: results } end diff --git a/app/graphql/resolvers/group_issues_resolver.rb b/app/graphql/resolvers/group_issues_resolver.rb index 5e0fb27bafa..5a6a3d678b9 100644 --- a/app/graphql/resolvers/group_issues_resolver.rb +++ b/app/graphql/resolvers/group_issues_resolver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver) +# rubocop:disable Graphql/ResolverType -- inherited from Issues::BaseParentResolver module Resolvers class GroupIssuesResolver < Issues::BaseParentResolver def self.issuable_collection_name diff --git a/app/graphql/resolvers/issues/base_parent_resolver.rb b/app/graphql/resolvers/issues/base_parent_resolver.rb index 6308e56f049..78ef4132baf 100644 --- a/app/graphql/resolvers/issues/base_parent_resolver.rb +++ b/app/graphql/resolvers/issues/base_parent_resolver.rb @@ -7,8 +7,13 @@ module Resolvers include ::Issues::SortArguments argument :state, Types::IssuableStateEnum, - required: false, - description: 'Current state of this issue.' + required: false, + description: 'Current state of this issue.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } # see app/graphql/types/issue_connection.rb type 'Types::IssueConnection', null: true diff --git a/app/graphql/resolvers/issues_resolver.rb b/app/graphql/resolvers/issues_resolver.rb index 34f14eee0e5..bc0e7334303 100644 --- a/app/graphql/resolvers/issues_resolver.rb +++ b/app/graphql/resolvers/issues_resolver.rb @@ -14,7 +14,12 @@ module Resolvers description: 'Whether to include issues from archived projects. Defaults to `false`.' argument :state, Types::IssuableStateEnum, required: false, - description: 'Current state of this issue.' + description: 'Current state of this issue.', + prepare: ->(state, _ctx) { + return state unless state == 'locked' + + raise Gitlab::Graphql::Errors::ArgumentError, Types::IssuableStateEnum::INVALID_LOCKED_MESSAGE + } # see app/graphql/types/issue_connection.rb type 'Types::IssueConnection', null: true diff --git a/app/graphql/resolvers/namespaces/work_items_resolver.rb b/app/graphql/resolvers/namespaces/work_items_resolver.rb index 6985a7a898a..671788668b1 100644 --- a/app/graphql/resolvers/namespaces/work_items_resolver.rb +++ b/app/graphql/resolvers/namespaces/work_items_resolver.rb @@ -2,7 +2,7 @@ module Resolvers module Namespaces - # rubocop:disable Graphql/ResolverType (inherited from Resolvers::WorkItemsResolver) + # rubocop:disable Graphql/ResolverType -- inherited from Resolvers::WorkItemsResolver class WorkItemsResolver < ::Resolvers::WorkItemsResolver def ready?(**args) return false if Feature.disabled?(:namespace_level_work_items, resource_parent) diff --git a/app/graphql/resolvers/packages_base_resolver.rb b/app/graphql/resolvers/packages_base_resolver.rb index 7d153d16910..7e5d89a7897 100644 --- a/app/graphql/resolvers/packages_base_resolver.rb +++ b/app/graphql/resolvers/packages_base_resolver.rb @@ -19,6 +19,12 @@ module Resolvers required: false, default_value: nil + argument :package_version, GraphQL::Types::String, + description: 'Filter a package by version. If used in combination with `include_versionless`, + then no versionless packages are returned.', + required: false, + default_value: nil + argument :status, Types::Packages::PackageStatusEnum, description: 'Filter a package by status.', required: false, diff --git a/app/graphql/resolvers/project_issues_resolver.rb b/app/graphql/resolvers/project_issues_resolver.rb index f869d8f11c6..2bc610e8266 100644 --- a/app/graphql/resolvers/project_issues_resolver.rb +++ b/app/graphql/resolvers/project_issues_resolver.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from Issues::BaseParentResolver) +# rubocop:disable Graphql/ResolverType -- inherited from Issues::BaseParentResolver module Resolvers class ProjectIssuesResolver < Issues::BaseParentResolver accept_release_tag diff --git a/app/graphql/resolvers/project_members_resolver.rb b/app/graphql/resolvers/project_members_resolver.rb index e889b47c000..a27183438cd 100644 --- a/app/graphql/resolvers/project_members_resolver.rb +++ b/app/graphql/resolvers/project_members_resolver.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from MembersResolver) + +# rubocop:disable Graphql/ResolverType -- inherited from MembersResolver module Resolvers class ProjectMembersResolver < MembersResolver @@ -17,3 +18,4 @@ module Resolvers end end end +# rubocop:enable Graphql/ResolverType diff --git a/app/graphql/resolvers/project_milestones_resolver.rb b/app/graphql/resolvers/project_milestones_resolver.rb index 567a55aa09b..cb4e9a5cdf7 100644 --- a/app/graphql/resolvers/project_milestones_resolver.rb +++ b/app/graphql/resolvers/project_milestones_resolver.rb @@ -1,5 +1,4 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from MilestonesResolver) module Resolvers class ProjectMilestonesResolver < MilestonesResolver diff --git a/app/graphql/resolvers/projects/snippets_resolver.rb b/app/graphql/resolvers/projects/snippets_resolver.rb index 448918be2f5..9ab9db21e89 100644 --- a/app/graphql/resolvers/projects/snippets_resolver.rb +++ b/app/graphql/resolvers/projects/snippets_resolver.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets) + +# rubocop:disable Graphql/ResolverType -- inherited from ResolvesSnippets module Resolvers module Projects @@ -27,3 +28,4 @@ module Resolvers end end end +# rubocop:enable Graphql/ResolverType diff --git a/app/graphql/resolvers/projects_resolver.rb b/app/graphql/resolvers/projects_resolver.rb index 8dd409a8173..450caa9aff6 100644 --- a/app/graphql/resolvers/projects_resolver.rb +++ b/app/graphql/resolvers/projects_resolver.rb @@ -3,6 +3,7 @@ module Resolvers class ProjectsResolver < BaseResolver include ProjectSearchArguments + include LooksAhead type Types::ProjectType.connection_type, null: true @@ -10,6 +11,10 @@ module Resolvers required: false, description: 'Filter projects by IDs.' + argument :full_paths, [GraphQL::Types::String], + required: false, + description: 'Filter projects by full paths. You cannot provide more than 50 full paths.' + argument :sort, GraphQL::Types::String, required: false, description: "Sort order of results. Format: `<field_name>_<sort_direction>`, " \ @@ -23,19 +28,48 @@ module Resolvers required: false, description: "Return only projects with merge requests enabled." - def resolve(**args) - ProjectsFinder + def resolve_with_lookahead(**args) + validate_args!(args) + + projects = ProjectsFinder .new(current_user: current_user, params: finder_params(args), project_ids_relation: parse_gids(args[:ids])) .execute + + apply_lookahead(projects) end private + def validate_args!(args) + return unless args[:full_paths].present? && args[:full_paths].length > 50 + + raise Gitlab::Graphql::Errors::ArgumentError, 'You cannot provide more than 50 full_paths' + end + + def unconditional_includes + [:creator, :group, :invited_groups, :project_setting] + end + + def preloads + { + full_path: [:route], + topics: [:topics], + import_status: [:import_state], + service_desk_address: [:project_feature, :service_desk_setting], + jira_import_status: [:jira_imports], + container_repositories: [:container_repositories], + container_repositories_count: [:container_repositories], + web_url: { namespace: [:route] }, + is_catalog_resource: [:catalog_resource] + } + end + def finder_params(args) { **project_finder_params(args), with_issues_enabled: args[:with_issues_enabled], - with_merge_requests_enabled: args[:with_merge_requests_enabled] + with_merge_requests_enabled: args[:with_merge_requests_enabled], + full_paths: args[:full_paths] } end @@ -44,3 +78,5 @@ module Resolvers end end end + +Resolvers::ProjectsResolver.prepend_mod_with('Resolvers::ProjectsResolver') diff --git a/app/graphql/resolvers/saved_reply_resolver.rb b/app/graphql/resolvers/saved_reply_resolver.rb index 96bbc139c96..1a5f2c9be78 100644 --- a/app/graphql/resolvers/saved_reply_resolver.rb +++ b/app/graphql/resolvers/saved_reply_resolver.rb @@ -11,8 +11,6 @@ module Resolvers description: 'ID of a saved reply.' def resolve(id:) - return unless Feature.enabled?(:saved_replies, current_user) - saved_reply = ::Users::SavedReply.find_saved_reply(user_id: current_user.id, id: id.model_id) return unless saved_reply diff --git a/app/graphql/resolvers/snippets_resolver.rb b/app/graphql/resolvers/snippets_resolver.rb index 90f5f2cb534..759cc61a8a7 100644 --- a/app/graphql/resolvers/snippets_resolver.rb +++ b/app/graphql/resolvers/snippets_resolver.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets) + +# rubocop:disable Graphql/ResolverType -- inherited from ResolvesSnippets module Resolvers class SnippetsResolver < BaseResolver @@ -45,3 +46,4 @@ module Resolvers end end end +# rubocop:enable Graphql/ResolverType diff --git a/app/graphql/resolvers/users/frecent_groups_resolver.rb b/app/graphql/resolvers/users/frecent_groups_resolver.rb new file mode 100644 index 00000000000..2fc757e31ab --- /dev/null +++ b/app/graphql/resolvers/users/frecent_groups_resolver.rb @@ -0,0 +1,23 @@ +# frozen_string_literal: true + +module Resolvers + module Users + class FrecentGroupsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type [Types::GroupType], null: true + + def resolve + return unless current_user.present? + + if Feature.disabled?(:frecent_namespaces_suggestions, current_user) + raise_resource_not_available_error!("'frecent_namespaces_suggestions' feature flag is disabled") + end + + return unless Feature.enabled?(:frecent_namespaces_suggestions, current_user) + + ::Users::GroupVisit.frecent_groups(user_id: current_user.id) + end + end + end +end diff --git a/app/graphql/resolvers/users/frecent_projects_resolver.rb b/app/graphql/resolvers/users/frecent_projects_resolver.rb new file mode 100644 index 00000000000..397d4ca0cfd --- /dev/null +++ b/app/graphql/resolvers/users/frecent_projects_resolver.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Resolvers + module Users + class FrecentProjectsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type [Types::ProjectType], null: true + + def resolve + return unless current_user.present? + + if Feature.disabled?(:frecent_namespaces_suggestions, current_user) + raise_resource_not_available_error!("'frecent_namespaces_suggestions' feature flag is disabled") + end + + ::Users::ProjectVisit.frecent_projects(user_id: current_user.id) + end + end + end +end diff --git a/app/graphql/resolvers/users/organizations_resolver.rb b/app/graphql/resolvers/users/organizations_resolver.rb new file mode 100644 index 00000000000..ffc1a141eb6 --- /dev/null +++ b/app/graphql/resolvers/users/organizations_resolver.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Resolvers + module Users + class OrganizationsResolver < BaseResolver + include Gitlab::Graphql::Authorize::AuthorizeResource + + type Types::Organizations::OrganizationType.connection_type, null: true + + authorize :read_user_organizations + authorizes_object! + + def resolve(**args) + ::Organizations::UserOrganizationsFinder.new(current_user, object, args).execute + end + end + end +end diff --git a/app/graphql/resolvers/users/snippets_resolver.rb b/app/graphql/resolvers/users/snippets_resolver.rb index 75bba8debab..ea5f6b7b8c9 100644 --- a/app/graphql/resolvers/users/snippets_resolver.rb +++ b/app/graphql/resolvers/users/snippets_resolver.rb @@ -1,5 +1,6 @@ # frozen_string_literal: true -# rubocop:disable Graphql/ResolverType (inherited from ResolvesSnippets) + +# rubocop:disable Graphql/ResolverType -- inherited from ResolvesSnippets module Resolvers module Users @@ -27,3 +28,4 @@ module Resolvers end end end +# rubocop:enable Graphql/ResolverType diff --git a/app/graphql/resolvers/work_items/linked_items_resolver.rb b/app/graphql/resolvers/work_items/linked_items_resolver.rb index 108d5d41b62..f2ff1205d3a 100644 --- a/app/graphql/resolvers/work_items/linked_items_resolver.rb +++ b/app/graphql/resolvers/work_items/linked_items_resolver.rb @@ -3,6 +3,8 @@ module Resolvers module WorkItems class LinkedItemsResolver < BaseResolver + prepend ::WorkItems::LookAheadPreloads + alias_method :linked_items_widget, :object argument :filter, Types::WorkItems::RelatedLinkTypeEnum, @@ -13,30 +15,28 @@ module Resolvers type Types::WorkItems::LinkedItemType.connection_type, null: true - def resolve(filter: nil) - related_work_items(filter).map do |related_work_item| - { - link_id: related_work_item.issue_link_id, - link_type: related_work_item.issue_link_type, - link_created_at: related_work_item.issue_link_created_at, - link_updated_at: related_work_item.issue_link_updated_at, - work_item: related_work_item - } - end + def resolve_with_lookahead(**args) + apply_lookahead(related_work_items(args)) end private - def related_work_items(type) - return [] unless work_item.resource_parent.linked_work_items_feature_flag_enabled? + def related_work_items(args) + return WorkItem.none unless work_item.resource_parent.linked_work_items_feature_flag_enabled? - work_item.linked_work_items(current_user, preload: { project: [:project_feature, :group] }, link_type: type) + offset_pagination( + work_item.linked_work_items(authorize: false, link_type: args[:filter]) + ) end def work_item linked_items_widget.work_item end strong_memoize_attr :work_item + + def node_selection(selection = lookahead) + super.selection(:work_item) + end end end end diff --git a/app/graphql/types/abuse_report_type.rb b/app/graphql/types/abuse_report_type.rb index 012e709cdb5..2532530cfa9 100644 --- a/app/graphql/types/abuse_report_type.rb +++ b/app/graphql/types/abuse_report_type.rb @@ -3,9 +3,18 @@ module Types class AbuseReportType < BaseObject graphql_name 'AbuseReport' + + implements Types::Notes::NoteableInterface + description 'An abuse report' + authorize :read_abuse_report + expose_permissions Types::PermissionTypes::AbuseReport + + field :id, Types::GlobalIDType[::AbuseReport], + null: false, description: 'Global ID of the abuse report.' + field :labels, ::Types::LabelType.connection_type, null: true, description: 'Labels of the abuse report.' end diff --git a/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb b/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb new file mode 100644 index 00000000000..16ce9b82718 --- /dev/null +++ b/app/graphql/types/analytics/cycle_analytics/value_stream_type.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module Types + module Analytics + module CycleAnalytics + class ValueStreamType < BaseObject + graphql_name 'ValueStream' + + authorize :read_cycle_analytics + + field :id, + type: ::Types::GlobalIDType[::Analytics::CycleAnalytics::ValueStream], + null: false, + description: "ID of the value stream." + + field :name, + GraphQL::Types::String, + null: false, + description: 'Name of the value stream.' + + field :namespace, Types::NamespaceType, + null: false, + description: 'Namespace the value stream belongs to.' + + field :project, Types::ProjectType, + null: true, + description: 'Project the value stream belongs to, returns empty if it belongs to a group.', + alpha: { milestone: '15.6' } + end + end + end +end diff --git a/app/graphql/types/base_argument.rb b/app/graphql/types/base_argument.rb index cda7fa4a5df..3b4223c3ba1 100644 --- a/app/graphql/types/base_argument.rb +++ b/app/graphql/types/base_argument.rb @@ -9,29 +9,7 @@ module Types def initialize(*args, **kwargs, &block) @doc_reference = kwargs.delete(:see) - # our custom addition `nullable` which allows us to declare - # an argument that must be provided, even if its value is null. - # When `required: true` then required arguments must not be null. - @gl_required = !!kwargs[:required] - @gl_nullable = kwargs[:required] == :nullable - - # Only valid if an argument is also required. - if @gl_nullable - # Since the framework asserts that "required" means "cannot be null" - # we have to switch off "required" but still do the check in `ready?` behind the scenes - kwargs[:required] = false - end - super(*args, **kwargs, &block) end - - def accepts?(value) - # if the argument is declared as required, it must be included - return false if @gl_required && value == :not_given - # if the argument is declared as required, the value can only be null IF it is also nullable. - return false if @gl_required && value.nil? && !@gl_nullable - - true - end end end diff --git a/app/graphql/types/base_input_object.rb b/app/graphql/types/base_input_object.rb index 90a29b0cfb8..d14da9ac878 100644 --- a/app/graphql/types/base_input_object.rb +++ b/app/graphql/types/base_input_object.rb @@ -3,5 +3,7 @@ module Types class BaseInputObject < GraphQL::Schema::InputObject prepend Gitlab::Graphql::CopyFieldDescription + + argument_class ::Types::BaseArgument end end diff --git a/app/graphql/types/ci/catalog/resource_scope_enum.rb b/app/graphql/types/ci/catalog/resource_scope_enum.rb new file mode 100644 index 00000000000..b825c3a7925 --- /dev/null +++ b/app/graphql/types/ci/catalog/resource_scope_enum.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + class ResourceScopeEnum < BaseEnum + graphql_name 'CiCatalogResourceScope' + description 'Values for scoping catalog resources' + + value 'ALL', 'All catalog resources visible to the current user.', value: :all + end + end + end +end diff --git a/app/graphql/types/ci/catalog/resource_sort_enum.rb b/app/graphql/types/ci/catalog/resource_sort_enum.rb new file mode 100644 index 00000000000..bb0b5a6e0eb --- /dev/null +++ b/app/graphql/types/ci/catalog/resource_sort_enum.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + class ResourceSortEnum < BaseEnum + graphql_name 'CiCatalogResourceSort' + description 'Values for sorting catalog resources' + + value 'NAME_ASC', 'Name by ascending order.', value: :name_asc + value 'NAME_DESC', 'Name by descending order.', value: :name_desc + value 'LATEST_RELEASED_AT_ASC', 'Latest release date by ascending order.', value: :latest_released_at_asc + value 'LATEST_RELEASED_AT_DESC', 'Latest release date by descending order.', value: :latest_released_at_desc + value 'CREATED_ASC', 'Created date by ascending order.', value: :created_at_asc + value 'CREATED_DESC', 'Created date by descending order.', value: :created_at_desc + end + end + end +end diff --git a/app/graphql/types/ci/catalog/resource_type.rb b/app/graphql/types/ci/catalog/resource_type.rb new file mode 100644 index 00000000000..119313ae52b --- /dev/null +++ b/app/graphql/types/ci/catalog/resource_type.rb @@ -0,0 +1,120 @@ +# frozen_string_literal: true + +module Types + module Ci + module Catalog + # rubocop: disable Graphql/AuthorizeTypes + class ResourceType < BaseObject + graphql_name 'CiCatalogResource' + + connection_type_class Types::CountableConnectionType + + field :open_issues_count, GraphQL::Types::Int, null: false, + description: 'Count of open issues that belong to the the catalog resource.', + alpha: { milestone: '16.3' } + + field :open_merge_requests_count, GraphQL::Types::Int, null: false, + description: 'Count of open merge requests that belong to the the catalog resource.', + alpha: { milestone: '16.3' } + + field :id, GraphQL::Types::ID, null: false, description: 'ID of the catalog resource.', + alpha: { milestone: '15.11' } + + field :name, GraphQL::Types::String, null: true, description: 'Name of the catalog resource.', + alpha: { milestone: '15.11' } + + field :description, GraphQL::Types::String, null: true, description: 'Description of the catalog resource.', + alpha: { milestone: '15.11' } + + field :icon, GraphQL::Types::String, null: true, description: 'Icon for the catalog resource.', + method: :avatar_path, alpha: { milestone: '15.11' } + + field :web_path, GraphQL::Types::String, null: true, description: 'Web path of the catalog resource.', + alpha: { milestone: '16.1' } + + field :versions, Types::ReleaseType.connection_type, null: true, + description: 'Versions of the catalog resource. This field can only be ' \ + 'resolved for one catalog resource in any single request.', + resolver: Resolvers::Ci::Catalog::VersionsResolver, + alpha: { milestone: '16.2' } + + field :latest_version, Types::ReleaseType, null: true, description: 'Latest version of the catalog resource.', + alpha: { milestone: '16.1' } + + field :latest_released_at, Types::TimeType, null: true, + description: "Release date of the catalog resource's latest version.", + alpha: { milestone: '16.5' } + + field :star_count, GraphQL::Types::Int, null: false, + description: 'Number of times the catalog resource has been starred.', + alpha: { milestone: '16.1' } + + field :forks_count, GraphQL::Types::Int, null: false, calls_gitaly: true, + description: 'Number of times the catalog resource has been forked.', + alpha: { milestone: '16.1' } + + field :root_namespace, Types::NamespaceType, null: true, + description: 'Root namespace of the catalog resource.', + alpha: { milestone: '16.1' } + + markdown_field :readme_html, null: false, + alpha: { milestone: '16.1' } + + def open_issues_count + BatchLoader::GraphQL.wrap(object.project.open_issues_count) + end + + def open_merge_requests_count + BatchLoader::GraphQL.wrap(object.project.open_merge_requests_count) + end + + def web_path + ::Gitlab::Routing.url_helpers.project_path(object.project) + end + + def latest_version + BatchLoader::GraphQL.for(object.project).batch do |projects, loader| + latest_releases = ReleasesFinder.new(projects, current_user, latest: true).execute + + latest_releases.index_by(&:project).each do |project, latest_release| + loader.call(project, latest_release) + end + end + end + + def forks_count + BatchLoader::GraphQL.wrap(object.forks_count) + end + + def root_namespace + BatchLoader::GraphQL.for(object.project_id).batch do |project_ids, loader| + projects = Project.id_in(project_ids) + + # This preloader uses traversal_ids to obtain Group-type root namespaces. + # It also preloads each project's immediate parent namespace, which effectively + # preloads the User-type root namespaces since they cannot be nested (parent == root). + Preloaders::ProjectRootAncestorPreloader.new(projects, :group).execute + root_namespaces = projects.map(&:root_ancestor) + + # NamespaceType requires the `:read_namespace` ability. We must preload the policy for + # Group-type namespaces to avoid N+1 queries caused by the authorization requests. + group_root_namespaces = root_namespaces.select { |n| n.type == ::Group.sti_name } + Preloaders::GroupPolicyPreloader.new(group_root_namespaces, current_user).execute + + # For User-type namespaces, the authorization request requires preloading the owner objects. + user_root_namespaces = root_namespaces.select { |n| n.type == ::Namespaces::UserNamespace.sti_name } + ActiveRecord::Associations::Preloader.new(records: user_root_namespaces, associations: :owner).call + + projects.each { |project| loader.call(project.id, project.root_ancestor) } + end + end + + def readme_html_resolver + markdown_context = context.to_h.dup.merge(project: object.project) + ::MarkupHelper.markdown(object.project.repository.readme&.data, markdown_context) + end + end + # rubocop: enable Graphql/AuthorizeTypes + end + end +end diff --git a/app/graphql/types/ci/pipeline_status_enum.rb b/app/graphql/types/ci/pipeline_status_enum.rb index c8e031e18ea..17cf48bb5cf 100644 --- a/app/graphql/types/ci/pipeline_status_enum.rb +++ b/app/graphql/types/ci/pipeline_status_enum.rb @@ -7,6 +7,7 @@ module Types created: 'Pipeline has been created.', waiting_for_resource: 'A resource (for example, a runner) that the pipeline requires to run is unavailable.', preparing: 'Pipeline is preparing to run.', + waiting_for_callback: 'Pipeline is waiting for an external action.', pending: 'Pipeline has not started running yet.', running: 'Pipeline is running.', failed: 'At least one stage of the pipeline failed.', diff --git a/app/graphql/types/container_registry/protection/rule_access_level_enum.rb b/app/graphql/types/container_registry/protection/rule_access_level_enum.rb new file mode 100644 index 00000000000..31e8cbe2e49 --- /dev/null +++ b/app/graphql/types/container_registry/protection/rule_access_level_enum.rb @@ -0,0 +1,17 @@ +# frozen_string_literal: true + +module Types + module ContainerRegistry + module Protection + class RuleAccessLevelEnum < BaseEnum + graphql_name 'ContainerRegistryProtectionRuleAccessLevel' + description 'Access level of a container registry protection rule resource' + + ::ContainerRegistry::Protection::Rule.push_protected_up_to_access_levels.each_key do |access_level_key| + value access_level_key.upcase, value: access_level_key.to_s, + description: "#{access_level_key.capitalize} access." + end + end + end + end +end diff --git a/app/graphql/types/container_registry/protection/rule_type.rb b/app/graphql/types/container_registry/protection/rule_type.rb new file mode 100644 index 00000000000..387f0202d2d --- /dev/null +++ b/app/graphql/types/container_registry/protection/rule_type.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module Types + module ContainerRegistry + module Protection + class RuleType < ::Types::BaseObject + graphql_name 'ContainerRegistryProtectionRule' + description 'A container registry protection rule designed to prevent users with a certain ' \ + 'access level or lower from altering the container registry.' + + authorize :admin_container_image + + field :id, + ::Types::GlobalIDType[::ContainerRegistry::Protection::Rule], + null: false, + description: 'ID of the container registry protection rule.' + + field :container_path_pattern, + GraphQL::Types::String, + null: false, + description: + 'Container repository path pattern protected by the protection rule. ' \ + 'For example `@my-scope/my-container-*`. Wildcard character `*` allowed.' + + field :push_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + null: false, + description: + 'Max GitLab access level to prevent from pushing container images to the container registry. ' \ + 'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + + field :delete_protected_up_to_access_level, + Types::ContainerRegistry::Protection::RuleAccessLevelEnum, + null: false, + description: + 'Max GitLab access level to prevent from pushing container images to the container registry. ' \ + 'For example `DEVELOPER`, `MAINTAINER`, `OWNER`.' + end + end + end +end diff --git a/app/graphql/types/container_repository_details_type.rb b/app/graphql/types/container_repository_details_type.rb index 1ee9e76a1c8..b043a7c9d8d 100644 --- a/app/graphql/types/container_repository_details_type.rb +++ b/app/graphql/types/container_repository_details_type.rb @@ -13,7 +13,8 @@ module Types null: true, description: 'Tags of the container repository.', max_page_size: 20, - resolver: Resolvers::ContainerRepositoryTagsResolver + resolver: Resolvers::ContainerRepositoryTagsResolver, + connection_extension: Gitlab::Graphql::Extensions::ExternallyPaginatedArrayExtension field :size, GraphQL::Types::Float, diff --git a/app/graphql/types/data_transfer/project_data_transfer_type.rb b/app/graphql/types/data_transfer/project_data_transfer_type.rb index 36afa20194e..363b675209d 100644 --- a/app/graphql/types/data_transfer/project_data_transfer_type.rb +++ b/app/graphql/types/data_transfer/project_data_transfer_type.rb @@ -13,7 +13,6 @@ module Types def total_egress(parent:) return unless Feature.enabled?(:data_transfer_monitoring, parent.group) - return 40_000_000 if Feature.enabled?(:data_transfer_monitoring_mock_data, parent.group) object[:egress_nodes].sum('repository_egress + artifacts_egress + packages_egress + registry_egress') end diff --git a/app/graphql/types/group_member_type.rb b/app/graphql/types/group_member_type.rb index 2745853c9bb..d494c55369d 100644 --- a/app/graphql/types/group_member_type.rb +++ b/app/graphql/types/group_member_type.rb @@ -11,11 +11,11 @@ module Types implements MemberInterface field :group, Types::GroupType, null: true, - description: 'Group that a User is a member of.' + description: 'Group that a user is a member of.' field :notification_email, resolver: Resolvers::GroupMembers::NotificationEmailResolver, - description: "Group notification email for User. Only available for admins." + description: "Group notification email for user. Only available for admins." def group Gitlab::Graphql::Loaders::BatchModelLoader.new(Group, object.source_id).find diff --git a/app/graphql/types/issuable_state_enum.rb b/app/graphql/types/issuable_state_enum.rb index 5a1b11b3bdc..8e3ed1d4bc8 100644 --- a/app/graphql/types/issuable_state_enum.rb +++ b/app/graphql/types/issuable_state_enum.rb @@ -1,10 +1,15 @@ # frozen_string_literal: true +# DO NOT use this ENUM with issues. We need to define a new enum in places where we +# need to filter by state. locked is not a valid state filter for issues. More info in +# https://gitlab.com/gitlab-org/gitlab/-/issues/420667#note_1605900474 module Types class IssuableStateEnum < BaseEnum graphql_name 'IssuableState' description 'State of a GitLab issue or merge request' + INVALID_LOCKED_MESSAGE = 'locked is not a valid state filter for issues.' + value 'opened', description: 'In open state.' value 'closed', description: 'In closed state.' value 'locked', description: 'Discussion has been locked.' diff --git a/app/graphql/types/merge_request_review_state_enum.rb b/app/graphql/types/merge_request_review_state_enum.rb index 45f97758425..c7c82de2906 100644 --- a/app/graphql/types/merge_request_review_state_enum.rb +++ b/app/graphql/types/merge_request_review_state_enum.rb @@ -5,7 +5,11 @@ module Types graphql_name 'MergeRequestReviewState' description 'State of a review of a GitLab merge request.' - from_rails_enum(::MergeRequestReviewer.states, - description: "The merge request is %{name}.") + value 'UNREVIEWED', value: 'unreviewed', + description: 'Awaiting review from merge request reviewer.' + value 'REVIEWED', value: 'reviewed', + description: 'Merge request reviewer has reviewed.' + value 'REQUESTED_CHANGES', value: 'requested_changes', + description: 'Merge request reviewer has requested changes.' end end diff --git a/app/graphql/types/merge_request_type.rb b/app/graphql/types/merge_request_type.rb index e6625e44508..9dca82f1750 100644 --- a/app/graphql/types/merge_request_type.rb +++ b/app/graphql/types/merge_request_type.rb @@ -106,7 +106,8 @@ module Types null: false, description: 'Status of all mergeability checks of the merge request.', method: :all_mergeability_checks_results, - alpha: { milestone: '16.5' } + alpha: { milestone: '16.5' }, + calls_gitaly: true field :mergeable_discussions_state, GraphQL::Types::Boolean, null: true, calls_gitaly: true, diff --git a/app/graphql/types/mutation_type.rb b/app/graphql/types/mutation_type.rb index 3af7140aed3..e1bd1f603ad 100644 --- a/app/graphql/types/mutation_type.rb +++ b/app/graphql/types/mutation_type.rb @@ -106,6 +106,7 @@ module Types mount_mutation Mutations::Notes::Update::ImageDiffNote mount_mutation Mutations::Notes::RepositionImageDiffNote mount_mutation Mutations::Notes::Destroy + mount_mutation Mutations::Organizations::Create, alpha: { milestone: '16.6' } mount_mutation Mutations::Projects::SyncFork, calls_gitaly: true, alpha: { milestone: '15.9' } mount_mutation Mutations::Releases::Create mount_mutation Mutations::Releases::Update @@ -134,33 +135,36 @@ module Types mount_mutation Mutations::DesignManagement::Move mount_mutation Mutations::DesignManagement::Update mount_mutation Mutations::ContainerExpirationPolicies::Update + mount_mutation Mutations::ContainerRegistry::Protection::Rule::Create, alpha: { milestone: '16.6' } mount_mutation Mutations::ContainerRepositories::Destroy mount_mutation Mutations::ContainerRepositories::DestroyTags + mount_mutation Mutations::Ci::Catalog::Resources::Create, alpha: { milestone: '15.11' } + mount_mutation Mutations::Ci::Catalog::Resources::Unpublish, alpha: { milestone: '16.6' } + mount_mutation Mutations::Ci::Job::Cancel + mount_mutation Mutations::Ci::Job::Play + mount_mutation Mutations::Ci::Job::Retry + mount_mutation Mutations::Ci::Job::ArtifactsDestroy + mount_mutation Mutations::Ci::Job::Unschedule + mount_mutation Mutations::Ci::JobTokenScope::AddProject + mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, alpha: { milestone: '15.10' } + mount_mutation Mutations::Ci::JobArtifact::Destroy + mount_mutation Mutations::Ci::JobTokenScope::RemoveProject mount_mutation Mutations::Ci::Pipeline::Cancel mount_mutation Mutations::Ci::Pipeline::Destroy mount_mutation Mutations::Ci::Pipeline::Retry + mount_mutation Mutations::Ci::PipelineSchedule::Create mount_mutation Mutations::Ci::PipelineSchedule::Delete - mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership mount_mutation Mutations::Ci::PipelineSchedule::Play - mount_mutation Mutations::Ci::PipelineSchedule::Create + mount_mutation Mutations::Ci::PipelineSchedule::TakeOwnership mount_mutation Mutations::Ci::PipelineSchedule::Update mount_mutation Mutations::Ci::PipelineTrigger::Create, alpha: { milestone: '16.3' } - mount_mutation Mutations::Ci::PipelineTrigger::Update, alpha: { milestone: '16.3' } mount_mutation Mutations::Ci::PipelineTrigger::Delete, alpha: { milestone: '16.3' } + mount_mutation Mutations::Ci::PipelineTrigger::Update, alpha: { milestone: '16.3' } mount_mutation Mutations::Ci::ProjectCiCdSettingsUpdate - mount_mutation Mutations::Ci::Job::ArtifactsDestroy - mount_mutation Mutations::Ci::Job::Play - mount_mutation Mutations::Ci::Job::Retry - mount_mutation Mutations::Ci::Job::Cancel - mount_mutation Mutations::Ci::Job::Unschedule - mount_mutation Mutations::Ci::JobArtifact::Destroy - mount_mutation Mutations::Ci::JobArtifact::BulkDestroy, alpha: { milestone: '15.10' } - mount_mutation Mutations::Ci::JobTokenScope::AddProject - mount_mutation Mutations::Ci::JobTokenScope::RemoveProject + mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' } mount_mutation Mutations::Ci::Runner::Create, alpha: { milestone: '15.10' } - mount_mutation Mutations::Ci::Runner::Update mount_mutation Mutations::Ci::Runner::Delete - mount_mutation Mutations::Ci::Runner::BulkDelete, alpha: { milestone: '15.3' } + mount_mutation Mutations::Ci::Runner::Update mount_mutation Mutations::Ci::RunnersRegistrationToken::Reset mount_mutation Mutations::Namespace::PackageSettings::Update mount_mutation Mutations::Groups::Update @@ -171,6 +175,7 @@ module Types extensions: [::Gitlab::Graphql::Limit::FieldCallCount => { limit: 1 }] mount_mutation Mutations::Packages::DestroyFile mount_mutation Mutations::Packages::Protection::Rule::Create, alpha: { milestone: '16.5' } + mount_mutation Mutations::Packages::Protection::Rule::Delete, alpha: { milestone: '16.6' } mount_mutation Mutations::Packages::DestroyFiles mount_mutation Mutations::Packages::Cleanup::Policy::Update mount_mutation Mutations::Echo diff --git a/app/graphql/types/namespace/package_settings_type.rb b/app/graphql/types/namespace/package_settings_type.rb index 61240243b1f..6c6144f2357 100644 --- a/app/graphql/types/namespace/package_settings_type.rb +++ b/app/graphql/types/namespace/package_settings_type.rb @@ -20,21 +20,18 @@ module Types field :maven_duplicates_allowed, GraphQL::Types::Boolean, null: false, description: 'Indicates whether duplicate Maven packages are allowed for this namespace.' - field :nuget_duplicate_exception_regex, Types::UntrustedRegexp, - null: true, - description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. ' \ - 'Error is raised if `nuget_duplicates_option` feature flag is disabled.' - field :nuget_duplicates_allowed, GraphQL::Types::Boolean, - null: false, - description: 'Indicates whether duplicate NuGet packages are allowed for this namespace. ' \ - 'Error is raised if `nuget_duplicates_option` feature flag is disabled.' - field :maven_package_requests_forwarding, GraphQL::Types::Boolean, null: true, description: 'Indicates whether Maven package forwarding is allowed for this namespace.' field :npm_package_requests_forwarding, GraphQL::Types::Boolean, null: true, description: 'Indicates whether npm package forwarding is allowed for this namespace.' + field :nuget_duplicate_exception_regex, Types::UntrustedRegexp, + null: true, + description: 'When nuget_duplicates_allowed is false, you can publish duplicate packages with names that match this regex. Otherwise, this setting has no effect. ' + field :nuget_duplicates_allowed, GraphQL::Types::Boolean, + null: false, + description: 'Indicates whether duplicate NuGet packages are allowed for this namespace. ' field :pypi_package_requests_forwarding, GraphQL::Types::Boolean, null: true, description: 'Indicates whether PyPI package forwarding is allowed for this namespace.' diff --git a/app/graphql/types/notes/noteable_interface.rb b/app/graphql/types/notes/noteable_interface.rb index 9971511d6ce..7c75f213e24 100644 --- a/app/graphql/types/notes/noteable_interface.rb +++ b/app/graphql/types/notes/noteable_interface.rb @@ -21,6 +21,8 @@ module Types Types::DesignManagement::DesignType when ::AlertManagement::Alert Types::AlertManagement::AlertType + when AbuseReport + Types::AbuseReportType else raise "Unknown GraphQL type for #{object}" end diff --git a/app/graphql/types/organizations/organization_type.rb b/app/graphql/types/organizations/organization_type.rb index cae0ef2232e..e7ba8de527c 100644 --- a/app/graphql/types/organizations/organization_type.rb +++ b/app/graphql/types/organizations/organization_type.rb @@ -33,6 +33,10 @@ module Types null: false, description: 'Path of the organization.', alpha: { milestone: '16.4' } + field :web_url, GraphQL::Types::String, + null: false, + description: 'Web URL of the organization.', + alpha: { milestone: '16.6' } end end end diff --git a/app/graphql/types/organizations/organization_user_badge_type.rb b/app/graphql/types/organizations/organization_user_badge_type.rb new file mode 100644 index 00000000000..f4e18676dd1 --- /dev/null +++ b/app/graphql/types/organizations/organization_user_badge_type.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Types + module Organizations + # rubocop: disable Graphql/AuthorizeTypes -- Already authorized in parent OrganizationUserType. + class OrganizationUserBadgeType < BaseObject + graphql_name 'OrganizationUserBadge' + description 'An organization user badge.' + + field :text, + GraphQL::Types::String, + null: false, + description: 'Badge text.' + + field :variant, + GraphQL::Types::String, + null: false, + description: 'Badge variant.' + end + # rubocop: enable Graphql/AuthorizeTypes + end +end diff --git a/app/graphql/types/organizations/organization_user_type.rb b/app/graphql/types/organizations/organization_user_type.rb index 41924586f38..ce036c7dd4a 100644 --- a/app/graphql/types/organizations/organization_user_type.rb +++ b/app/graphql/types/organizations/organization_user_type.rb @@ -13,7 +13,7 @@ module Types alias_method :organization_user, :object field :badges, - [GraphQL::Types::String], + [::Types::Organizations::OrganizationUserBadgeType], null: true, description: 'Badges describing the user within the organization.', alpha: { milestone: '16.4' } @@ -29,7 +29,7 @@ module Types alpha: { milestone: '16.4' } def badges - user_badges_in_admin_section(organization_user.user).pluck(:text) # rubocop:disable CodeReuse/ActiveRecord + user_badges_in_admin_section(organization_user.user) end end end diff --git a/app/graphql/types/packages/package_base_type.rb b/app/graphql/types/packages/package_base_type.rb index aa580d48709..5102e4ebcd5 100644 --- a/app/graphql/types/packages/package_base_type.rb +++ b/app/graphql/types/packages/package_base_type.rb @@ -10,11 +10,19 @@ module Types authorize :read_package + expose_permissions Types::PermissionTypes::Package + field :id, ::Types::GlobalIDType[::Packages::Package], null: false, description: 'ID of the package.' field :_links, Types::Packages::PackageLinksType, null: false, method: :itself, description: 'Map of links to perform actions on the package.' - field :can_destroy, GraphQL::Types::Boolean, null: false, description: 'Whether the user can destroy the package.' + field :can_destroy, GraphQL::Types::Boolean, + null: false, + deprecated: { + reason: 'Superseded by `user_permissions` field. See `Types::PermissionTypes::Package` type', + milestone: '16.6' + }, + description: 'Whether the user can destroy the package.' field :created_at, Types::TimeType, null: false, description: 'Date of creation.' field :metadata, Types::Packages::MetadataType, null: true, diff --git a/app/graphql/types/packages/protection/rule_type.rb b/app/graphql/types/packages/protection/rule_type.rb index 1e969d39ce2..e2ea2d89d2d 100644 --- a/app/graphql/types/packages/protection/rule_type.rb +++ b/app/graphql/types/packages/protection/rule_type.rb @@ -10,6 +10,11 @@ module Types authorize :admin_package + field :id, + ::Types::GlobalIDType[::Packages::Protection::Rule], + null: false, + description: 'ID of the package protection rule.' + field :package_name_pattern, GraphQL::Types::String, null: false, diff --git a/app/graphql/types/packages/pypi/metadatum_type.rb b/app/graphql/types/packages/pypi/metadatum_type.rb index 63452d8ab6e..8ccdb592c52 100644 --- a/app/graphql/types/packages/pypi/metadatum_type.rb +++ b/app/graphql/types/packages/pypi/metadatum_type.rb @@ -9,8 +9,17 @@ module Types authorize :read_package + field :author_email, GraphQL::Types::String, null: true, + description: 'Author email address(es) in RFC-822 format.' + field :description, GraphQL::Types::String, null: true, + description: 'Longer description that can run to several paragraphs.' + field :description_content_type, GraphQL::Types::String, null: true, + description: 'Markup syntax used in the description field.' field :id, ::Types::GlobalIDType[::Packages::Pypi::Metadatum], null: false, description: 'ID of the metadatum.' + field :keywords, GraphQL::Types::String, null: true, description: 'List of keywords, separated by commas.' + field :metadata_version, GraphQL::Types::String, null: true, description: 'Metadata version.' field :required_python, GraphQL::Types::String, null: true, description: 'Required Python version of the Pypi package.' + field :summary, GraphQL::Types::String, null: true, description: 'One-line summary of the description.' end end end diff --git a/app/graphql/types/permission_types/abuse_report.rb b/app/graphql/types/permission_types/abuse_report.rb new file mode 100644 index 00000000000..abd5d545d02 --- /dev/null +++ b/app/graphql/types/permission_types/abuse_report.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class AbuseReport < BasePermissionType + graphql_name 'AbuseReportPermissions' + + abilities :read_abuse_report, :create_note + end + end +end diff --git a/app/graphql/types/permission_types/base_permission_type.rb b/app/graphql/types/permission_types/base_permission_type.rb index d45c61f489b..3c0e68bdaf2 100644 --- a/app/graphql/types/permission_types/base_permission_type.rb +++ b/app/graphql/types/permission_types/base_permission_type.rb @@ -21,7 +21,7 @@ module Types kword_args = kword_args.reverse_merge( name: name, type: GraphQL::Types::Boolean, - description: "Indicates the user can perform `#{name}` on this resource", + description: "If `true`, the user can perform `#{name}` on this resource", null: false) field(**kword_args, &block) # rubocop:disable Graphql/Descriptions diff --git a/app/graphql/types/permission_types/ci/job.rb b/app/graphql/types/permission_types/ci/job.rb index c9a85317e67..35904fb1fc3 100644 --- a/app/graphql/types/permission_types/ci/job.rb +++ b/app/graphql/types/permission_types/ci/job.rb @@ -8,6 +8,7 @@ module Types abilities :read_job_artifacts, :read_build ability_field :update_build, calls_gitaly: true + ability_field :cancel_build, calls_gitaly: true end end end diff --git a/app/graphql/types/permission_types/ci/pipeline.rb b/app/graphql/types/permission_types/ci/pipeline.rb index cfd68380005..94adbf7c59b 100644 --- a/app/graphql/types/permission_types/ci/pipeline.rb +++ b/app/graphql/types/permission_types/ci/pipeline.rb @@ -8,6 +8,7 @@ module Types abilities :admin_pipeline, :destroy_pipeline ability_field :update_pipeline, calls_gitaly: true + ability_field :cancel_pipeline, calls_gitaly: true end end end diff --git a/app/graphql/types/permission_types/package.rb b/app/graphql/types/permission_types/package.rb new file mode 100644 index 00000000000..debde3a1a8e --- /dev/null +++ b/app/graphql/types/permission_types/package.rb @@ -0,0 +1,12 @@ +# frozen_string_literal: true + +module Types + module PermissionTypes + class Package < BasePermissionType + graphql_name 'PackagePermissions' + + ability_field :destroy_package, + description: 'If `true`, the user can perform `destroy_package` on this resource' + end + end +end diff --git a/app/graphql/types/project_type.rb b/app/graphql/types/project_type.rb index 95caefc3825..ec87f133843 100644 --- a/app/graphql/types/project_type.rb +++ b/app/graphql/types/project_type.rb @@ -641,6 +641,12 @@ module Types resolver: Resolvers::AutocompleteUsersResolver, description: 'Search users for autocompletion' + field :detailed_import_status, + ::Types::Projects::DetailedImportStatusType, + null: true, + description: 'Detailed import status of the project.', + method: :import_state + def timelog_categories object.project_namespace.timelog_categories if Feature.enabled?(:timelog_categories) end diff --git a/app/graphql/types/projects/detailed_import_status_type.rb b/app/graphql/types/projects/detailed_import_status_type.rb new file mode 100644 index 00000000000..9cba176e097 --- /dev/null +++ b/app/graphql/types/projects/detailed_import_status_type.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Types + module Projects + class DetailedImportStatusType < BaseObject + graphql_name 'DetailedImportStatus' + description 'Details of the import status of a project.' + + authorize :read_project + + field :id, ::Types::GlobalIDType[::ProjectImportState], + description: 'ID of the import state.' + + field :status, GraphQL::Types::String, + description: 'Current status of the import.' + + field :url, GraphQL::Types::String, + description: 'Import url.' + + field :last_error, GraphQL::Types::String, + description: 'Last error of the import.', + null: true, + authorize: :read_import_error + + field :last_update_at, Types::TimeType, + description: 'Time of the last update.' + + field :last_update_started_at, Types::TimeType, + description: 'Time of the start of the last update.' + + field :last_successful_update_at, Types::TimeType, + description: 'Time of the last successful update.' + + def url + object.project.safe_import_url + end + end + end +end diff --git a/app/graphql/types/query_type.rb b/app/graphql/types/query_type.rb index d185007f05b..173e877d86c 100644 --- a/app/graphql/types/query_type.rb +++ b/app/graphql/types/query_type.rb @@ -21,6 +21,20 @@ module Types required: true, description: 'Global ID of the CI stage.' end + field :ci_catalog_resources, + ::Types::Ci::Catalog::ResourceType.connection_type, + null: true, + alpha: { milestone: '15.11' }, + description: 'All CI/CD Catalog resources under a common namespace, visible to an authorized user', + resolver: ::Resolvers::Ci::Catalog::ResourcesResolver + + field :ci_catalog_resource, + ::Types::Ci::Catalog::ResourceType, + null: true, + alpha: { milestone: '16.1' }, + description: 'A single CI/CD Catalog resource visible to an authorized user', + resolver: ::Resolvers::Ci::Catalog::ResourceResolver + field :ci_variables, Types::Ci::InstanceVariableType.connection_type, null: true, @@ -41,6 +55,14 @@ module Types null: false, description: 'Fields related to design management.' field :echo, resolver: Resolvers::EchoResolver + field :frecent_groups, [Types::GroupType], + resolver: Resolvers::Users::FrecentGroupsResolver, + description: "A user's frecently visited groups. Requires the `frecent_namespaces_suggestions` feature flag to be enabled.", + alpha: { milestone: '16.6' } + field :frecent_projects, [Types::ProjectType], + resolver: Resolvers::Users::FrecentProjectsResolver, + description: "A user's frecently visited projects. Requires the `frecent_namespaces_suggestions` feature flag to be enabled.", + alpha: { milestone: '16.6' } field :gitpod_enabled, GraphQL::Types::Boolean, null: true, description: "Whether Gitpod is enabled in application settings." diff --git a/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb index fb7d722069f..7dd47611a2e 100644 --- a/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer/degradation_type.rb @@ -3,10 +3,9 @@ module Types module Security module CodequalityReportsComparer - # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + # rubocop: disable Graphql/AuthorizeTypes -- The resolver authorizes the request class DegradationType < BaseObject graphql_name 'CodequalityReportsComparerReportDegradation' - description 'Represents a degradation on the compared codequality report.' field :description, GraphQL::Types::String, diff --git a/app/graphql/types/security/codequality_reports_comparer/report_generation_status_enum.rb b/app/graphql/types/security/codequality_reports_comparer/report_generation_status_enum.rb new file mode 100644 index 00000000000..dace3aec97c --- /dev/null +++ b/app/graphql/types/security/codequality_reports_comparer/report_generation_status_enum.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Types + module Security + module CodequalityReportsComparer + class ReportGenerationStatusEnum < BaseEnum + graphql_name 'CodequalityReportsComparerReportGenerationStatus' + description 'Represents the generation status of the compared codequality report.' + + value 'PARSED', value: :parsed, description: 'Report was generated.' + value 'PARSING', value: :parsing, description: 'Report is being generated.' + value 'ERROR', value: :error, description: 'An error happened while generating the report.' + end + end + end +end diff --git a/app/graphql/types/security/codequality_reports_comparer/report_type.rb b/app/graphql/types/security/codequality_reports_comparer/report_type.rb index 8a41160141a..d20c9dd9ab6 100644 --- a/app/graphql/types/security/codequality_reports_comparer/report_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer/report_type.rb @@ -3,7 +3,7 @@ module Types module Security module CodequalityReportsComparer - # rubocop: disable Graphql/AuthorizeTypes (Parent node applies authorization) + # rubocop: disable Graphql/AuthorizeTypes -- Parent node applies authorization class ReportType < BaseObject graphql_name 'CodequalityReportsComparerReport' diff --git a/app/graphql/types/security/codequality_reports_comparer/status_enum.rb b/app/graphql/types/security/codequality_reports_comparer/status_enum.rb index 9cab2664db8..fdccfdc7e44 100644 --- a/app/graphql/types/security/codequality_reports_comparer/status_enum.rb +++ b/app/graphql/types/security/codequality_reports_comparer/status_enum.rb @@ -4,11 +4,11 @@ module Types module Security module CodequalityReportsComparer class StatusEnum < BaseEnum - graphql_name 'CodequalityReportsComparerReportStatus' - description 'Report comparison status' + graphql_name 'CodequalityReportsComparerStatus' + description 'Represents the state of the code quality report.' - value 'SUCCESS', value: 'success', description: 'Report successfully generated.' - value 'FAILED', value: 'failed', description: 'Report failed to generate.' + value 'SUCCESS', value: 'success', description: 'No degradations found in the head pipeline report.' + value 'FAILED', value: 'failed', description: 'Report generated and there are new code quality degradations.' value 'NOT_FOUND', value: 'not_found', description: 'Head report or base report not found.' end end diff --git a/app/graphql/types/security/codequality_reports_comparer/summary_type.rb b/app/graphql/types/security/codequality_reports_comparer/summary_type.rb index cd4a594c193..43037be5245 100644 --- a/app/graphql/types/security/codequality_reports_comparer/summary_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer/summary_type.rb @@ -3,7 +3,7 @@ module Types module Security module CodequalityReportsComparer - # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + # rubocop: disable Graphql/AuthorizeTypes -- The resolver authorizes the request class SummaryType < BaseObject graphql_name 'CodequalityReportsComparerReportSummary' diff --git a/app/graphql/types/security/codequality_reports_comparer_type.rb b/app/graphql/types/security/codequality_reports_comparer_type.rb index 8088bf84627..32fe8c12330 100644 --- a/app/graphql/types/security/codequality_reports_comparer_type.rb +++ b/app/graphql/types/security/codequality_reports_comparer_type.rb @@ -2,12 +2,17 @@ module Types module Security - # rubocop: disable Graphql/AuthorizeTypes (The resolver authorizes the request) + # rubocop: disable Graphql/AuthorizeTypes -- The resolver authorizes the request class CodequalityReportsComparerType < BaseObject graphql_name 'CodequalityReportsComparer' description 'Represents reports comparison for code quality.' + field :status, + type: CodequalityReportsComparer::ReportGenerationStatusEnum, + null: true, + description: 'Compared codequality report generation status.' + field :report, type: CodequalityReportsComparer::ReportType, null: true, diff --git a/app/graphql/types/user_interface.rb b/app/graphql/types/user_interface.rb index 47d486265b0..040711b5f58 100644 --- a/app/graphql/types/user_interface.rb +++ b/app/graphql/types/user_interface.rb @@ -71,6 +71,11 @@ module Types type: GraphQL::Types::String, null: false, description: 'Web path of the user.' + field :organizations, + resolver: Resolvers::Users::OrganizationsResolver, + null: true, + alpha: { milestone: '16.6' }, + description: 'Organizations where the user has access.' field :group_memberships, type: Types::GroupMemberType.connection_type, null: true, @@ -134,13 +139,11 @@ module Types field :saved_replies, Types::SavedReplyType.connection_type, null: true, - description: 'Saved replies authored by the user. ' \ - 'Will not return saved replies if `saved_replies` feature flag is disabled.' + description: 'Saved replies authored by the user.' field :saved_reply, resolver: Resolvers::SavedReplyResolver, - description: 'Saved reply authored by the user. ' \ - 'Will not return saved reply if `saved_replies` feature flag is disabled.' + description: 'Saved reply authored by the user.' field :gitpod_enabled, GraphQL::Types::Boolean, null: true, description: 'Whether Gitpod is enabled at the user level.' @@ -197,6 +200,11 @@ module Types null: true, description: 'Timestamp of when the user was created.' + field :last_activity_on, + type: Types::DateType, + null: true, + description: 'Date the user last performed any actions.' + field :pronouns, type: ::GraphQL::Types::String, null: true, diff --git a/app/graphql/types/work_items/linked_item_type.rb b/app/graphql/types/work_items/linked_item_type.rb index a4dbeed7480..1b989d78091 100644 --- a/app/graphql/types/work_items/linked_item_type.rb +++ b/app/graphql/types/work_items/linked_item_type.rb @@ -2,21 +2,29 @@ module Types module WorkItems - # rubocop:disable Graphql/AuthorizeTypes class LinkedItemType < BaseObject graphql_name 'LinkedWorkItemType' + authorize :read_work_item + field :link_created_at, Types::TimeType, - description: 'Timestamp the link was created.', null: false + description: 'Timestamp the link was created.', null: false, + method: :issue_link_created_at field :link_id, ::Types::GlobalIDType[::WorkItems::RelatedWorkItemLink], - description: 'Global ID of the link.', null: false + description: 'Global ID of the link.', null: false, + method: :issue_link_id field :link_type, GraphQL::Types::String, - description: 'Type of link.', null: false + description: 'Type of link.', null: false, + method: :issue_link_type field :link_updated_at, Types::TimeType, - description: 'Timestamp the link was updated.', null: false + description: 'Timestamp the link was updated.', null: false, + method: :issue_link_updated_at field :work_item, Types::WorkItemType, - description: 'Linked work item.', null: false + description: 'Linked work item.', null: true + + def work_item + object + end end - # rubocop:enable Graphql/AuthorizeTypes end end diff --git a/app/graphql/types/work_items/widgets/linked_items_type.rb b/app/graphql/types/work_items/widgets/linked_items_type.rb index 2611c2456c5..c541a12a050 100644 --- a/app/graphql/types/work_items/widgets/linked_items_type.rb +++ b/app/graphql/types/work_items/widgets/linked_items_type.rb @@ -13,6 +13,7 @@ module Types field :linked_items, Types::WorkItems::LinkedItemType.connection_type, null: true, complexity: 5, alpha: { milestone: '16.3' }, + extras: [:lookahead], description: 'Linked items for the work item. Returns `null` ' \ 'if `linked_work_items` feature flag is disabled.', resolver: Resolvers::WorkItems::LinkedItemsResolver diff --git a/app/helpers/admin/user_actions_helper.rb b/app/helpers/admin/user_actions_helper.rb index 969c5d5a0b5..ba40b3c8a8d 100644 --- a/app/helpers/admin/user_actions_helper.rb +++ b/app/helpers/admin/user_actions_helper.rb @@ -16,6 +16,7 @@ module Admin unlock_actions delete_actions ban_actions + trust_actions @actions end @@ -66,5 +67,19 @@ module Admin @actions << 'ban' end end + + def trust_actions + return if @user.internal? || + @user.blocked_pending_approval? || + @user.banned? || + @user.blocked? || + @user.deactivated? + + @actions << if @user.trusted? + 'untrust' + else + 'trust' + end + end end end diff --git a/app/helpers/application_helper.rb b/app/helpers/application_helper.rb index 57937353955..8a0a46e6b25 100644 --- a/app/helpers/application_helper.rb +++ b/app/helpers/application_helper.rb @@ -318,7 +318,6 @@ module ApplicationHelper class_names << 'with-header' if !show_super_sidebar? || !current_user class_names << 'with-top-bar' if show_super_sidebar? && !@hide_top_bar_padding class_names << system_message_class - class_names << 'logged-out-marketing-header' if !current_user && ::Gitlab.com? && !show_super_sidebar? class_names end @@ -371,6 +370,14 @@ module ApplicationHelper "https://discord.com/users/#{user.discord}" end + def mastodon_url(user) + return '' if user.mastodon.blank? + + url = user.mastodon.match UserDetail::MASTODON_VALIDATION_REGEX + + external_redirect_path(url: "https://#{url[2]}/@#{url[1]}") + end + def collapsed_sidebar? cookies["sidebar_collapsed"] == "true" end diff --git a/app/helpers/application_settings_helper.rb b/app/helpers/application_settings_helper.rb index 58648a82487..0c6ab41004a 100644 --- a/app/helpers/application_settings_helper.rb +++ b/app/helpers/application_settings_helper.rb @@ -488,6 +488,7 @@ module ApplicationSettingsHelper :sidekiq_job_limiter_compression_threshold_bytes, :sidekiq_job_limiter_limit_bytes, :suggest_pipeline_enabled, + :enable_artifact_external_redirect_warning_page, :search_rate_limit, :search_rate_limit_unauthenticated, :search_rate_limit_allowlist_raw, diff --git a/app/helpers/auth_helper.rb b/app/helpers/auth_helper.rb index fc157df3891..e447940e2af 100644 --- a/app/helpers/auth_helper.rb +++ b/app/helpers/auth_helper.rb @@ -93,16 +93,11 @@ module AuthHelper end def saml_providers - auth_providers.select do |provider| - provider == :saml || auth_strategy_class(provider) == 'OmniAuth::Strategies::SAML' + providers = Gitlab.config.omniauth.providers.select do |provider| + provider.name == 'saml' || provider.dig('args', 'strategy_class') == 'OmniAuth::Strategies::SAML' end - end - - def auth_strategy_class(provider) - config = Gitlab::Auth::OAuth::Provider.config_for(provider) - return if config.nil? || config['args'].blank? - config.args['strategy_class'] + providers.map(&:name).map(&:to_sym) end def any_form_based_providers_enabled? diff --git a/app/helpers/blob_helper.rb b/app/helpers/blob_helper.rb index 0d5b8755a37..8c199aefd81 100644 --- a/app/helpers/blob_helper.rb +++ b/app/helpers/blob_helper.rb @@ -300,7 +300,7 @@ module BlobHelper end def show_suggest_pipeline_creation_celebration? - @blob.path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] && + Gitlab::FileDetector.type_of(@blob.path) == :gitlab_ci && @blob.auxiliary_viewer&.valid?(project: @project, sha: @commit.sha, user: current_user) && @project.uses_default_ci_config? && cookies[suggest_pipeline_commit_cookie_name].present? diff --git a/app/helpers/ci/catalog/resources_helper.rb b/app/helpers/ci/catalog/resources_helper.rb index bc77e0cd33a..8324da870d3 100644 --- a/app/helpers/ci/catalog/resources_helper.rb +++ b/app/helpers/ci/catalog/resources_helper.rb @@ -3,8 +3,8 @@ module Ci module Catalog module ResourcesHelper - def can_add_catalog_resource?(_project) - false + def can_add_catalog_resource?(project) + can?(current_user, :add_catalog_resource, project) end def can_view_namespace_catalog?(_project) diff --git a/app/helpers/ci/pipelines_helper.rb b/app/helpers/ci/pipelines_helper.rb index 510c7cd5fb6..9c4ceaccff1 100644 --- a/app/helpers/ci/pipelines_helper.rb +++ b/app/helpers/ci/pipelines_helper.rb @@ -71,7 +71,7 @@ module Ci def pipelines_list_data(project, list_url) artifacts_endpoint_placeholder = ':pipeline_artifacts_id' - data = { + { endpoint: list_url, project_id: project.id, default_branch_name: project.default_branch, @@ -89,15 +89,6 @@ module Ci full_path: project.full_path, visibility_pipeline_id_type: visibility_pipeline_id_type } - - experiment(:ios_specific_templates, actor: current_user, project: project, sticky_to: project) do |e| - e.candidate do - data[:registration_token] = project.runners_token if can?(current_user, :register_project_runners, project) - data[:ios_runners_available] = (project.shared_runners_available? && Gitlab.com?).to_s - end - end - - data end def visibility_pipeline_id_type diff --git a/app/helpers/ci/status_helper.rb b/app/helpers/ci/status_helper.rb index 86f48b51f76..21d982d42bc 100644 --- a/app/helpers/ci/status_helper.rb +++ b/app/helpers/ci/status_helper.rb @@ -15,50 +15,46 @@ module Ci end # rubocop:disable Metrics/CyclomaticComplexity - def ci_icon_for_status(status, size: 16) - if detailed_status?(status) - return sprite_icon(status.icon, size: size) - end - + def ci_icon_for_status(status, size: 24) icon_name = - case status - when 'success' - 'status_success' - when 'success-with-warnings' - 'status_warning' - when 'failed' - 'status_failed' - when 'pending' - 'status_pending' - when 'waiting_for_resource' - 'status_pending' - when 'preparing' - 'status_preparing' - when 'running' - 'status_running' - when 'play' - 'play' - when 'created' - 'status_created' - when 'skipped' - 'status_skipped' - when 'manual' - 'status_manual' - when 'scheduled' - 'status_scheduled' + if detailed_status?(status) + status.icon else - 'status_canceled' + case status + when 'success' + 'status_success' + when 'success-with-warnings' + 'status_warning' + when 'failed' + 'status_failed' + when 'pending' + 'status_pending' + when 'waiting-for-resource' + 'status_pending' + when 'preparing' + 'status_preparing' + when 'running' + 'status_running' + when 'play' + 'play' + when 'created' + 'status_created' + when 'skipped' + 'status_skipped' + when 'manual' + 'status_manual' + when 'scheduled' + 'status_scheduled' + else + 'status_canceled' + end end - sprite_icon(icon_name, size: size) - end - # rubocop:enable Metrics/CyclomaticComplexity - - def ci_icon_class_for_status(status) - group = detailed_status?(status) ? status.group : status.dasherize + icon_name = icon_name == 'play' ? icon_name : "#{icon_name}_borderless" - "ci-status-icon-#{group}" + sprite_icon(icon_name, size: size, css_class: 'gl-icon') end + # rubocop:enable Metrics/CyclomaticComplexity def pipeline_status_cache_key(pipeline_status) "pipeline-status/#{pipeline_status.sha}-#{pipeline_status.status}" @@ -68,23 +64,35 @@ module Ci project = commit.project path = pipelines_project_commit_path(project, commit, ref: ref) - render_status_with_link( + render_ci_icon( status, path, tooltip_placement: tooltip_placement, - icon_size: 16) + option_css_classes: 'gl-ml-3' + ) end - def render_status_with_link(status, path = nil, type: _('pipeline'), tooltip_placement: 'left', cssclass: '', container: 'body', icon_size: 16) + def render_ci_icon( + status, + path = nil, + tooltip_placement: 'left', + option_css_classes: '', + container: 'body', + show_status_text: false + ) variant = badge_variant(status) - klass = "ci-status-link #{ci_icon_class_for_status(status)} d-inline-flex gl-line-height-1 #{cssclass}" - title = "#{type.titleize}: #{ci_label_for_status(status)}" - data = { toggle: 'tooltip', placement: tooltip_placement, container: container, testid: 'ci-status-badge-legacy' } - badge_classes = 'gl-px-2 gl-ml-3' + badge_classes = "ci-icon ci-icon-variant-#{variant} gl-p-2 #{option_css_classes}" + title = "#{_('Pipeline')}: #{ci_label_for_status(status)}" + data = { toggle: 'tooltip', placement: tooltip_placement, container: container, testid: 'ci-icon' } + + icon_wrapper_class = "js-ci-status-badge-legacy ci-icon-gl-icon-wrapper" gl_badge_tag(variant: variant, size: :md, href: path, class: badge_classes, title: title, data: data) do - content_tag :span, ci_icon_for_status(status, size: icon_size), - class: klass + if show_status_text + content_tag(:span, ci_icon_for_status(status), { class: icon_wrapper_class }) + content_tag(:span, status.label, { class: 'gl-mx-2 gl-white-space-nowrap', data: { testid: 'ci-icon-text' } }) + else + content_tag(:span, ci_icon_for_status(status), { class: icon_wrapper_class }) + end end end @@ -124,16 +132,18 @@ module Ci case variant when 'success' :success - when 'success-with-warnings', 'pending' + when 'success-with-warnings' + :warning + when 'pending' + :warning + when 'waiting-for-resource' :warning when 'failed' :danger when 'running' :info - when 'canceled', 'manual' - :neutral else - :muted + :neutral end end end diff --git a/app/helpers/clusters_helper.rb b/app/helpers/clusters_helper.rb index 1989d6ab3d5..319cec6f140 100644 --- a/app/helpers/clusters_helper.rb +++ b/app/helpers/clusters_helper.rb @@ -38,7 +38,7 @@ module ClustersHelper environment_scope: cluster.environment_scope, base_domain: cluster.base_domain, auto_devops_help_path: help_page_path('topics/autodevops/index'), - external_endpoint_help_path: help_page_path('user/project/clusters/gitlab_managed_clusters.md', anchor: 'base-domain') + external_endpoint_help_path: help_page_path('user/project/clusters/gitlab_managed_clusters', anchor: 'base-domain') } end diff --git a/app/helpers/colors_helper.rb b/app/helpers/colors_helper.rb index 3cd7263c39e..34b18b80be4 100644 --- a/app/helpers/colors_helper.rb +++ b/app/helpers/colors_helper.rb @@ -10,16 +10,4 @@ module ColorsHelper hex_color.length == 7 ? hex_color[1, 7].scan(/.{2}/).map(&:hex) : hex_color[1, 4].scan(/./).map { |v| (v * 2).hex } end - - def rgb_array_to_hex_color(rgb_array) - raise ArgumentError, "invalid RGB array `#{rgb_array}`" unless rgb_array_valid?(rgb_array) - - "##{rgb_array.map{ "%02x" % _1 }.join}" - end - - private - - def rgb_array_valid?(rgb_array) - rgb_array.is_a?(Array) && rgb_array.length == 3 && rgb_array.all?{ _1 >= 0 && _1 <= 255 } - end end diff --git a/app/helpers/dropdowns_helper.rb b/app/helpers/dropdowns_helper.rb index cc91b70758f..b6e0b2d6b20 100644 --- a/app/helpers/dropdowns_helper.rb +++ b/app/helpers/dropdowns_helper.rb @@ -110,7 +110,7 @@ module DropdownsHelper def dropdown_filter(placeholder, search_id: nil) content_tag :div, class: "dropdown-input" do - filter_output = search_field_tag search_id, nil, data: { qa_selector: "dropdown_input_field" }, id: nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' + filter_output = search_field_tag search_id, nil, data: { testid: "dropdown-input-field" }, id: nil, class: "dropdown-input-field", placeholder: placeholder, autocomplete: 'off' filter_output << sprite_icon('search', css_class: 'dropdown-input-search') filter_output << sprite_icon('close', size: 16, css_class: 'dropdown-input-clear js-dropdown-input-clear') diff --git a/app/helpers/environment_helper.rb b/app/helpers/environment_helper.rb index 6e9379a5926..fa47a12a72c 100644 --- a/app/helpers/environment_helper.rb +++ b/app/helpers/environment_helper.rb @@ -9,15 +9,6 @@ module EnvironmentHelper end # rubocop: enable CodeReuse/ActiveRecord - def environment_link_for_build(project, build) - environment = environment_for_build(project, build) - if environment - link_to environment.name, project_environment_path(project, environment) - else - content_tag :span, build.expanded_environment_name - end - end - def deployment_path(deployment) [deployment.project, deployment.deployable] end @@ -30,45 +21,6 @@ module EnvironmentHelper link_to link_label, deployment_path(deployment) end - def last_deployment_link_for_environment_build(project, build) - environment = environment_for_build(project, build) - return unless environment - - deployment_link(environment.last_deployment) - end - - def render_deployment_status(deployment) - status = deployment.status - - status_text = - case status - when 'created' - s_('Deployment|created') - when 'running' - s_('Deployment|running') - when 'success' - s_('Deployment|success') - when 'failed' - s_('Deployment|failed') - when 'canceled' - s_('Deployment|canceled') - when 'skipped' - s_('Deployment|skipped') - when 'blocked' - s_('Deployment|blocked') - end - - ci_icon_utilities = "gl-display-inline-flex gl-align-items-center gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base" - klass = "ci-status ci-#{status.dasherize} #{ci_icon_utilities}" - text = "#{ci_icon_for_status(status)} <span class=\"gl-ml-2\">#{status_text}</span>".html_safe - - if deployment.deployable.instance_of?(::Ci::Build) - link_to(text, deployment_path(deployment), class: klass) - else - content_tag(:span, text, class: klass) - end - end - def environments_detail_data(user, project, environment) { name: environment.name, diff --git a/app/helpers/environments_helper.rb b/app/helpers/environments_helper.rb index 80a56493653..28bdd3e69b6 100644 --- a/app/helpers/environments_helper.rb +++ b/app/helpers/environments_helper.rb @@ -33,15 +33,6 @@ module EnvironmentsHelper metrics_data end - def environment_logs_data(project, environment) - { - "environment_name": environment.name, - "environments_path": api_v4_projects_environments_path(id: project.id), - "environment_id": environment.id, - "clusters_path": project_clusters_path(project, format: :json) - } - end - def can_destroy_environment?(environment) can?(current_user, :destroy_environment, environment) end @@ -85,8 +76,8 @@ module EnvironmentsHelper def static_metrics_data { - 'documentation_path' => help_page_path('administration/monitoring/prometheus/index.md'), - 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), + 'documentation_path' => help_page_path('administration/monitoring/prometheus/index'), + 'add_dashboard_documentation_path' => help_page_path('operations/metrics/dashboards/index', anchor: 'add-a-new-dashboard-to-your-project'), 'empty_getting_started_svg_path' => image_path('illustrations/monitoring/getting_started.svg'), 'empty_loading_svg_path' => image_path('illustrations/monitoring/loading.svg'), 'empty_no_data_svg_path' => image_path('illustrations/monitoring/no_data.svg'), diff --git a/app/helpers/events_helper.rb b/app/helpers/events_helper.rb index 795d35ec81f..769af0d9ef9 100644 --- a/app/helpers/events_helper.rb +++ b/app/helpers/events_helper.rb @@ -13,7 +13,10 @@ module EventsHelper 'deleted' => 'remove', 'destroyed' => 'remove', 'imported' => 'import', - 'joined' => 'users' + 'joined' => 'users', + 'approved' => 'check', + 'added' => 'upload', + 'removed' => 'remove' }.freeze def localized_action_name_map @@ -70,7 +73,7 @@ module EventsHelper if author name = self_added ? _('You') : author.name - link_to name, user_path(author.username), title: name + link_to name, user_path(author.username), title: name, data: { user_id: author.id, username: author.username }, class: 'js-user-link' else escape_once(event.author_name) end @@ -242,7 +245,7 @@ module EventsHelper def event_wiki_title_html(event) capture do - concat content_tag(:span, _('wiki page'), class: "event-target-type gl-mr-2") + concat content_tag(:span, _('wiki page'), class: "event-target-type gl-mr-2 #{user_profile_activity_classes}") concat link_to( event.target_title, event_wiki_page_target_url(event), @@ -254,7 +257,7 @@ module EventsHelper def event_design_title_html(event) capture do - concat content_tag(:span, _('design'), class: "event-target-type gl-mr-2") + concat content_tag(:span, _('design'), class: "event-target-type gl-mr-2 #{user_profile_activity_classes}") concat link_to( event.design.reference_link_text, design_url(event.design), @@ -271,7 +274,7 @@ module EventsHelper def event_note_title_html(event) if event.note_target capture do - concat content_tag(:span, event.note_target_type_name, class: "event-target-type gl-mr-2") + concat content_tag(:span, event.note_target_type_name, class: "event-target-type gl-mr-2 #{user_profile_activity_classes}") concat link_to(event.note_target_reference, event_note_target_url(event), title: event.target_title, class: 'has-tooltip event-target-link gl-mr-2') end else @@ -303,19 +306,16 @@ module EventsHelper end def icon_for_profile_event(event) - if current_path?('users#show') - content_tag :div, class: "system-note-image #{event.action_name.parameterize}-icon" do - icon_for_event(event.action_name) - end - else - content_tag :div, class: 'system-note-image user-avatar' do - author_avatar(event, size: 32) - end - end + base_class = 'system-note-image' + + classes = current_path?('users#activity') ? "#{event.action_name.parameterize}-icon gl-rounded-full gl-bg-gray-50 gl-line-height-0" : "user-avatar" + content = current_path?('users#activity') ? icon_for_event(event.action_name, size: 14) : author_avatar(event, size: 32) + + tag.div(class: "#{base_class} #{classes}") { content } end def inline_event_icon(event) - unless current_path?('users#show') + unless current_path?('users#activity') content_tag :span, class: "system-note-image-inline d-none d-sm-flex gl-mr-2 #{event.action_name.parameterize}-icon align-self-center" do next design_event_icon(event.action, size: 14) if event.design? @@ -325,13 +325,19 @@ module EventsHelper end def event_user_info(event) - content_tag(:div, class: "event-user-info") do - concat content_tag(:span, link_to_author(event), class: "author-name") - concat " ".html_safe - concat content_tag(:span, event.author.to_reference, class: "username") + return if current_path?('users#activity') + + tag.div(class: 'event-user-info') do + concat tag.span(link_to_author(event), class: 'author-name') + concat ' '.html_safe + concat tag.span(event.author.to_reference, class: 'username') end end + def user_profile_activity_classes + current_path?('users#activity') ? ' gl-font-weight-semibold gl-text-black-normal' : '' + end + private def design_url(design, opts = {}) diff --git a/app/helpers/graph_helper.rb b/app/helpers/graph_helper.rb index ab72442857b..829e72d9055 100644 --- a/app/helpers/graph_helper.rb +++ b/app/helpers/graph_helper.rb @@ -4,12 +4,6 @@ module GraphHelper def refs(repo, commit) refs = [commit.ref_names(repo).join(' ')] - # append note count - unless Feature.enabled?(:disable_network_graph_notes_count, @project, type: :experiment) - notes_count = @graph.notes[commit.id] - refs << "[#{pluralize(notes_count, 'note')}]" if notes_count > 0 - end - refs.join end @@ -18,13 +12,6 @@ module GraphHelper ids.zip(parent_spaces) end - def success_ratio(counts) - return 100 if counts[:failed] == 0 - - ratio = (counts[:success].to_f / (counts[:success] + counts[:failed])) * 100 - ratio.to_i - end - def should_render_dora_charts false end diff --git a/app/helpers/ide_helper.rb b/app/helpers/ide_helper.rb index 2582d6fcc34..f2d393f1f77 100644 --- a/app/helpers/ide_helper.rb +++ b/app/helpers/ide_helper.rb @@ -5,7 +5,7 @@ module IdeHelper def ide_data(project:, fork_info:, params:) base_data = { 'use-new-web-ide' => use_new_web_ide?.to_s, - 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md', anchor: 'vscode-reimplementation'), + 'new-web-ide-help-page-path' => help_page_path('user/project/web_ide/index', anchor: 'vscode-reimplementation'), 'sign-in-path' => new_session_path(current_user), 'user-preferences-path' => profile_preferences_path }.merge(use_new_web_ide? ? new_ide_data(project: project) : legacy_ide_data(project: project)) @@ -71,16 +71,16 @@ module IdeHelper 'switch-editor-svg-path': image_path('illustrations/rocket-launch-md.svg'), 'promotion-svg-path': image_path('illustrations/web-ide_promotion.svg'), 'ci-help-page-path' => help_page_path('ci/quick_start/index'), - 'web-ide-help-page-path' => help_page_path('user/project/web_ide/index.md'), + 'web-ide-help-page-path' => help_page_path('user/project/web_ide/index'), 'render-whitespace-in-code': current_user.render_whitespace_in_code.to_s, 'default-branch' => project && project.default_branch, 'project' => convert_to_project_entity_json(project), 'enable-environments-guidance' => enable_environments_guidance?(project).to_s, 'preview-markdown-path' => project && preview_markdown_path(project), 'web-terminal-svg-path' => image_path('illustrations/web-ide_promotion.svg'), - 'web-terminal-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'interactive-web-terminals-for-the-web-ide'), - 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'web-ide-configuration-file'), - 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index.md', anchor: 'runner-configuration') + 'web-terminal-help-path' => help_page_path('user/project/web_ide/index', anchor: 'interactive-web-terminals-for-the-web-ide'), + 'web-terminal-config-help-path' => help_page_path('user/project/web_ide/index', anchor: 'web-ide-configuration-file'), + 'web-terminal-runners-help-path' => help_page_path('user/project/web_ide/index', anchor: 'runner-configuration') } end diff --git a/app/helpers/members_helper.rb b/app/helpers/members_helper.rb index e4c1d7932aa..600e5f06c61 100644 --- a/app/helpers/members_helper.rb +++ b/app/helpers/members_helper.rb @@ -30,22 +30,11 @@ module MembersHelper "#{text} #{action} the #{member.source.human_name} #{source_text(member)}?" end - def remove_member_title(member) - action = member.request? ? 'Deny access request' : 'Remove user' - - "#{action} from #{source_text(member)}" - end - def leave_confirmation_message(member_source) "Are you sure you want to leave the " \ "\"#{member_source.human_name}\" #{member_source.model_name.to_s.humanize(capitalize: false)}?" end - def filter_group_project_member_path(options = {}) - options = params.slice(:search, :sort).merge(options).permit! - "#{request.path}?#{options.to_param}" - end - def member_path(member) if member.is_a?(GroupMember) group_group_member_path(member.source, member) diff --git a/app/helpers/merge_requests_helper.rb b/app/helpers/merge_requests_helper.rb index 131cd7cd969..1dc4c393bf2 100644 --- a/app/helpers/merge_requests_helper.rb +++ b/app/helpers/merge_requests_helper.rb @@ -3,6 +3,7 @@ module MergeRequestsHelper include Gitlab::Utils::StrongMemoize include CompareHelper + DIFF_BATCH_ENDPOINT_PER_PAGE = 5 def create_mr_button_from_event?(event) create_mr_button?(from: event.branch_name, source_project: event.project) @@ -176,7 +177,7 @@ module MergeRequestsHelper end def notifications_todos_buttons_enabled? - Feature.enabled?(:notifications_todos_buttons, @project) + Feature.enabled?(:notifications_todos_buttons, current_user) end def diffs_tab_pane_data(project, merge_request, params) @@ -187,7 +188,7 @@ module MergeRequestsHelper endpoint_batch: diffs_batch_project_json_merge_request_path(project, merge_request, 'json', params), endpoint_coverage: @coverage_path, endpoint_diff_for_path: diff_for_path_namespace_project_merge_request_path(format: 'json', id: merge_request.iid, namespace_id: project.namespace.to_param, project_id: project.path), - help_page_path: help_page_path('user/project/merge_requests/reviews/suggestions.md'), + help_page_path: help_page_path('user/project/merge_requests/reviews/suggestions'), current_user_data: @current_user_data, update_current_user_path: @update_current_user_path, project_path: project_path(merge_request.project), @@ -202,7 +203,8 @@ module MergeRequestsHelper source_project_full_path: merge_request.source_project&.full_path, is_forked: project.forked?.to_s, new_comment_template_path: profile_comment_templates_path, - iid: merge_request.iid + iid: merge_request.iid, + per_page: DIFF_BATCH_ENDPOINT_PER_PAGE } end @@ -219,7 +221,7 @@ module MergeRequestsHelper source_project_full_path: merge_request.source_project&.full_path, source_project_default_url: merge_request.source_project && default_url_to_repo(merge_request.source_project), target_branch: merge_request.target_branch, - reviewing_docs_path: help_page_path('user/project/merge_requests/reviews/index.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") + reviewing_docs_path: help_page_path('user/project/merge_requests/reviews/index', anchor: "checkout-merge-requests-locally-through-the-head-ref") } end @@ -288,6 +290,7 @@ module MergeRequestsHelper data = { iid: @merge_request.iid, projectPath: @project.full_path, + sourceProjectPath: @merge_request.source_project_path, title: markdown_field(@merge_request, :title), isFluidLayout: fluid_layout.to_s, tabs: [ diff --git a/app/helpers/nav/new_dropdown_helper.rb b/app/helpers/nav/new_dropdown_helper.rb index 5274ace3d8a..88e834b537a 100644 --- a/app/helpers/nav/new_dropdown_helper.rb +++ b/app/helpers/nav/new_dropdown_helper.rb @@ -132,6 +132,17 @@ module Nav ) end + if Feature.enabled?(:ui_for_organizations, current_user) && current_user.can?(:create_organization) + menu_items.push( + ::Gitlab::Nav::TopNavMenuItem.build( + id: 'general_new_organization', + title: s_('Organization|New organization'), + href: new_organization_path, + data: { track_action: 'click_link_new_organization_parent', track_label: 'plus_menu_dropdown', track_property: 'navigation_top', testid: 'global_new_organization_link' } + ) + ) + end + if current_user.can?(:create_snippet) menu_items.push( ::Gitlab::Nav::TopNavMenuItem.build( diff --git a/app/helpers/nav_helper.rb b/app/helpers/nav_helper.rb index d3707183964..0c61749701e 100644 --- a/app/helpers/nav_helper.rb +++ b/app/helpers/nav_helper.rb @@ -57,10 +57,6 @@ module NavHelper end end - def nav_control_class - "nav-control" if current_user - end - def user_dropdown_class class_names = [] class_names << 'header-user-dropdown-toggle' @@ -82,23 +78,11 @@ module NavHelper %w[system_info background_migrations background_jobs health_check] end - def admin_analytics_nav_links - %w[dev_ops_report usage_trends] - end - - def show_super_sidebar?(user = current_user) - # The new sidebar is not enabled for anonymous use - # Once we enable the new sidebar by default, this - # should return true - return Feature.enabled?(:super_sidebar_logged_out) unless user - - # Users who got the special `super_sidebar_nav_enrolled` enabled, - # see the new nav as long as they don't explicitly opt-out via the toggle - if user.use_new_navigation.nil? && Feature.enabled?(:super_sidebar_nav_enrolled, user) - true - else - !!user.use_new_navigation - end + def show_super_sidebar?(_user = current_user) + # The new navigation is now enabled for everyone. + # We are working on cleaning up the use of this helper and other related code. + # See https://gitlab.com/groups/gitlab-org/-/epics/11875 + true end private diff --git a/app/helpers/notes_helper.rb b/app/helpers/notes_helper.rb index af8da86b391..75e89a7d7bc 100644 --- a/app/helpers/notes_helper.rb +++ b/app/helpers/notes_helper.rb @@ -71,16 +71,20 @@ module NotesHelper def link_to_reply_discussion(discussion, line_type = nil) return unless current_user - data = { - discussion_id: discussion.reply_id, - discussion_project_id: discussion.project&.id, - line_type: line_type - } - - button_tag 'Reply...', - class: 'btn btn-text-field js-discussion-reply-button', - data: data, - title: 'Add a reply' + content_tag( + :textarea, + rows: 1, + placeholder: _('Reply...'), + 'aria-label': _('Reply to comment'), + class: 'reply-placeholder-text-field js-discussion-reply-button', + data: { + discussion_id: discussion.reply_id, + discussion_project_id: discussion.project&.id, + line_type: line_type + } + ) do + # render empty textarea + end end def note_max_access_for_user(note) diff --git a/app/helpers/operations_helper.rb b/app/helpers/operations_helper.rb index 8528f5f04f7..d8b3cc3b36e 100644 --- a/app/helpers/operations_helper.rb +++ b/app/helpers/operations_helper.rb @@ -21,7 +21,7 @@ module OperationsHelper 'prometheus_authorization_key' => @project.alerting_setting&.token, 'prometheus_api_url' => prometheus_integration.api_url, 'prometheus_url' => notify_project_prometheus_alerts_url(@project, format: :json), - 'alerts_setup_url' => help_page_path('operations/incident_management/integrations.md', anchor: 'configuration'), + 'alerts_setup_url' => help_page_path('operations/incident_management/integrations', anchor: 'configuration'), 'alerts_usage_url' => project_alert_management_index_path(@project), 'disabled' => disabled.to_s, 'project_path' => @project.full_path, diff --git a/app/helpers/organizations/organization_helper.rb b/app/helpers/organizations/organization_helper.rb index 5d89bb93000..61eb9b5c35f 100644 --- a/app/helpers/organizations/organization_helper.rb +++ b/app/helpers/organizations/organization_helper.rb @@ -23,6 +23,14 @@ module Organizations }.to_json end + def organization_settings_general_app_data(organization) + { + organization: organization.slice(:id, :name, :path), + organizations_path: organizations_path, + root_url: root_url + }.to_json + end + def organization_groups_and_projects_app_data shared_groups_and_projects_app_data.to_json end @@ -34,6 +42,19 @@ module Organizations } end + def organization_user_app_data(organization) + { + organization_gid: organization.to_global_id + } + end + + def home_organization_setting_app_data + { + # TODO: use real setting - https://gitlab.com/gitlab-org/gitlab/-/issues/428668 + initial_selection: 1 + }.to_json + end + private def shared_groups_and_projects_app_data diff --git a/app/helpers/preferences_helper.rb b/app/helpers/preferences_helper.rb index 656d35e927d..204e3b149b9 100644 --- a/app/helpers/preferences_helper.rb +++ b/app/helpers/preferences_helper.rb @@ -59,6 +59,10 @@ module PreferencesHelper ] end + def time_display_format_choices + UserPreference.time_display_formats + end + def first_day_of_week_choices_with_default first_day_of_week_choices.unshift([_('System default (%{default})') % { default: default_first_day_of_week }, nil]) end @@ -122,8 +126,8 @@ module PreferencesHelper def integration_views [].tap do |views| - views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod.md') } if Gitlab::CurrentSettings.gitpod_enabled - views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences.md', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled + views << { name: 'gitpod', message: gitpod_enable_description, message_url: gitpod_url_placeholder, help_link: help_page_path('integration/gitpod') } if Gitlab::CurrentSettings.gitpod_enabled + views << { name: 'sourcegraph', message: sourcegraph_url_message, message_url: Gitlab::CurrentSettings.sourcegraph_url, help_link: help_page_path('user/profile/preferences', anchor: 'sourcegraph') } if Gitlab::Sourcegraph.feature_available? && Gitlab::CurrentSettings.sourcegraph_enabled end end diff --git a/app/helpers/projects/pipeline_helper.rb b/app/helpers/projects/pipeline_helper.rb index 0c3b7d26fe2..fc33e239451 100644 --- a/app/helpers/projects/pipeline_helper.rb +++ b/app/helpers/projects/pipeline_helper.rb @@ -37,9 +37,11 @@ module Projects failure_reason: pipeline.failure_reason, triggered_by_path: pipeline.child? ? pipeline_path(pipeline.triggered_by_pipeline) : '', schedule: pipeline.schedule?.to_s, + trigger: pipeline.trigger?.to_s, child: pipeline.child?.to_s, latest: pipeline.latest?.to_s, merge_train_pipeline: pipeline.merge_train_pipeline?.to_s, + merged_results_pipeline: (pipeline.merged_result_pipeline? && !pipeline.merge_train_pipeline?).to_s, invalid: pipeline.has_yaml_errors?.to_s, failed: pipeline.failure_reason?.to_s, auto_devops: pipeline.auto_devops_source?.to_s, diff --git a/app/helpers/projects_helper.rb b/app/helpers/projects_helper.rb index 04fe0a4450c..c3287d141f7 100644 --- a/app/helpers/projects_helper.rb +++ b/app/helpers/projects_helper.rb @@ -11,7 +11,7 @@ module ProjectsHelper end def link_to_project(project) - link_to namespace_project_path(namespace_id: project.namespace, id: project), title: h(project.name), class: 'gl-link' do + link_to namespace_project_path(namespace_id: project.namespace, id: project), title: h(project.name), class: 'gl-link gl-text-truncate' do title = content_tag(:span, project.name, class: 'project-name') if project.namespace @@ -187,7 +187,7 @@ module ProjectsHelper end def link_to_autodeploy_doc - link_to _('About auto deploy'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener' + link_to _('About auto deploy'), help_page_path('topics/autodevops/stages', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener' end def autodeploy_flash_notice(branch_name) @@ -200,6 +200,10 @@ module ProjectsHelper .load_in_batch_for_projects(projects) end + def load_catalog_resources(projects) + ActiveRecord::Associations::Preloader.new(records: projects, associations: :catalog_resource).call + end + def last_pipeline_from_status_cache(project) if Feature.enabled?(:last_pipeline_from_pipeline_status, project) pipeline_status = project.pipeline_status diff --git a/app/helpers/sidebars_helper.rb b/app/helpers/sidebars_helper.rb index 33ca5ad584e..f983812ad22 100644 --- a/app/helpers/sidebars_helper.rb +++ b/app/helpers/sidebars_helper.rb @@ -358,7 +358,9 @@ module SidebarsHelper def context_switcher_links links = [ ({ title: s_('Navigation|Your work'), link: root_path, icon: 'work' } if current_user), - { title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' } + { title: s_('Navigation|Explore'), link: explore_root_path, icon: 'compass' }, + ({ title: s_('Navigation|Profile'), link: profile_path, icon: 'profile' } if current_user), + ({ title: s_('Navigation|Preferences'), link: profile_preferences_path, icon: 'preferences' } if current_user) ] # Usually, using current_user.admin? is discouraged because it does not diff --git a/app/helpers/sorting_helper.rb b/app/helpers/sorting_helper.rb index 94445564c22..8b5c0707d08 100644 --- a/app/helpers/sorting_helper.rb +++ b/app/helpers/sorting_helper.rb @@ -263,36 +263,6 @@ module SortingHelper sort_direction_button(url, reverse_sort, sort_value) end - def packages_sort_options_hash - { - sort_value_recently_created => sort_title_created_date, - sort_value_oldest_created => sort_title_created_date, - sort_value_name => sort_title_name, - sort_value_name_desc => sort_title_name, - sort_value_version_desc => sort_title_version, - sort_value_version_asc => sort_title_version, - sort_value_type_desc => sort_title_type, - sort_value_type_asc => sort_title_type, - sort_value_project_name_desc => sort_title_project_name, - sort_value_project_name_asc => sort_title_project_name - } - end - - def packages_reverse_sort_order_hash - { - sort_value_recently_created => sort_value_oldest_created, - sort_value_oldest_created => sort_value_recently_created, - sort_value_name => sort_value_name_desc, - sort_value_name_desc => sort_value_name, - sort_value_version_desc => sort_value_version_asc, - sort_value_version_asc => sort_value_version_desc, - sort_value_type_desc => sort_value_type_asc, - sort_value_type_asc => sort_value_type_desc, - sort_value_project_name_desc => sort_value_project_name_asc, - sort_value_project_name_asc => sort_value_project_name_desc - } - end - def forks_sort_direction_button(sort_value, without = [:state, :scope, :label_name, :milestone_id, :assignee_id, :author_id]) reverse_sort = forks_reverse_sort_options_hash[sort_value] url = page_filter_path(sort: reverse_sort, without: without) diff --git a/app/helpers/users/callouts_helper.rb b/app/helpers/users/callouts_helper.rb index 1b5d0b276a3..6f1d4db4349 100644 --- a/app/helpers/users/callouts_helper.rb +++ b/app/helpers/users/callouts_helper.rb @@ -15,7 +15,7 @@ module Users REGISTRATION_ENABLED_CALLOUT_ALLOWED_CONTROLLER_PATHS = [/^root/, /^dashboard\S*/, /^admin\S*/].freeze WEB_HOOK_DISABLED = 'web_hook_disabled' BRANCH_RULES_INFO_CALLOUT = 'branch_rules_info_callout' - NEW_NAVIGATION_CALLOUT = 'new_navigation_callout' + NEW_NAV_FOR_EVERYONE_CALLOUT = 'new_nav_for_everyone_callout' def show_gke_cluster_integration_callout?(project) active_nav_link?(controller: sidebar_operations_paths) && @@ -71,26 +71,16 @@ module Users !user_dismissed?(MERGE_REQUEST_SETTINGS_MOVED_CALLOUT) && project.merge_requests_enabled? end - def show_pages_menu_callout? - !user_dismissed?(PAGES_MOVED_CALLOUT) - end - def show_branch_rules_info? !user_dismissed?(BRANCH_RULES_INFO_CALLOUT) end - def show_new_navigation_callout? - show_super_sidebar? && - !user_dismissed?(NEW_NAVIGATION_CALLOUT) && - # GitLab.com users created after the feature flag's full rollout (June 2nd 2023) don't need to see the callout. - # Remove the gitlab_com_user_created_after_new_nav_rollout? method when the callout isn't needed anymore. - !gitlab_com_user_created_after_new_nav_rollout? - end - - def gitlab_com_user_created_after_new_nav_rollout? - return true unless current_user - - Gitlab.com? && current_user.created_at >= Date.new(2023, 6, 2) + def show_new_nav_for_everyone_callout? + # The use_new_navigation user preference was controlled by the now removed "New navigation" toggle in the UI. + # We want to show this banner only to signed-in users who chose to disable the new nav (`false`). + # We don't want to show it for users who never touched the toggle and already had the new nav by default (`nil`) + user_had_new_nav_off = current_user && current_user.use_new_navigation == false + user_had_new_nav_off && !user_dismissed?(NEW_NAV_FOR_EVERYONE_CALLOUT) end private diff --git a/app/helpers/users_helper.rb b/app/helpers/users_helper.rb index a892b6e6ac6..84a809bc510 100644 --- a/app/helpers/users_helper.rb +++ b/app/helpers/users_helper.rb @@ -80,10 +80,6 @@ module UsersHelper current_user&.max_member_access_for_project(project.id) || Gitlab::Access::NO_ACCESS end - def max_project_member_access_cache_key(project) - "access:#{max_project_member_access(project)}" - end - def user_status(user) return unless user @@ -262,7 +258,9 @@ module UsersHelper delete_with_contributions: admin_user_path(:id, hard_delete: true), admin_user: admin_user_path(:id), ban: ban_admin_user_path(:id), - unban: unban_admin_user_path(:id) + unban: unban_admin_user_path(:id), + trust: trust_admin_user_path(:id), + untrust: untrust_admin_user_path(:id) } end @@ -334,27 +332,6 @@ module UsersHelper end end - def user_table_headers - [ - { - section_class_name: 'section-40', - header_text: _('Name') - }, - { - section_class_name: 'section-10', - header_text: _('Projects') - }, - { - section_class_name: 'section-15', - header_text: _('Created on') - }, - { - section_class_name: 'section-15', - header_text: _('Last activity') - } - ] - end - # the keys should match the user model defined roles in app/models/user.rb def localized_user_roles { @@ -370,10 +347,6 @@ module UsersHelper }.with_indifferent_access.freeze end - def saved_replies_enabled? - Feature.enabled?(:saved_replies, current_user) - end - def preload_project_associations(_) # Overridden in EE end diff --git a/app/helpers/visibility_level_helper.rb b/app/helpers/visibility_level_helper.rb index 68b15f7e042..cddfc48c649 100644 --- a/app/helpers/visibility_level_helper.rb +++ b/app/helpers/visibility_level_helper.rb @@ -76,16 +76,6 @@ module VisibilityLevelHelper end end - def visibility_level_options(form_model) - available_visibility_levels(form_model).map do |level| - { - level: level, - label: visibility_level_label(level), - description: visibility_level_description(level, form_model) - } - end - end - def snippets_selected_visibility_level(visibility_levels, selected) visibility_levels.find { |level| level == selected } || visibility_levels.min end diff --git a/app/helpers/vite_helper.rb b/app/helpers/vite_helper.rb index 4d1085a5169..5096d3649b7 100644 --- a/app/helpers/vite_helper.rb +++ b/app/helpers/vite_helper.rb @@ -1,22 +1,6 @@ # frozen_string_literal: true module ViteHelper - def universal_javascript_include_tag(*args) - if vite_enabled - vite_javascript_tag(*args) - else - javascript_include_tag(*args) - end - end - - def universal_asset_path(*args) - if vite_enabled - vite_asset_path(*args) - else - asset_path(*args) - end - end - private def vite_enabled diff --git a/app/helpers/wiki_helper.rb b/app/helpers/wiki_helper.rb index bd63381e9d1..eda789d5e55 100644 --- a/app/helpers/wiki_helper.rb +++ b/app/helpers/wiki_helper.rb @@ -68,14 +68,6 @@ module WikiHelper render Pajamas::ButtonComponent.new(href: wiki_path(wiki, **link_options), icon: "sort-#{icon_class}", button_options: { class: link_class, title: title }) end - def wiki_sort_title(key) - if key == Wiki::CREATED_AT_ORDER - s_("Wiki|Created date") - else - s_("Wiki|Title") - end - end - def wiki_empty_state_messages(wiki) case wiki.container when Project diff --git a/app/mailers/emails/issues.rb b/app/mailers/emails/issues.rb index 52a16475c07..f859294960c 100644 --- a/app/mailers/emails/issues.rb +++ b/app/mailers/emails/issues.rb @@ -70,7 +70,7 @@ module Emails setup_issue_mail(issue_id, recipient_id) @label_names = label_names - @labels_url = project_labels_url(@project) + @labels_url = project_labels_url(@project, subscribed: true) mail_answer_thread( @issue, issue_thread_options( diff --git a/app/mailers/emails/merge_requests.rb b/app/mailers/emails/merge_requests.rb index cd7869123f3..5e82a3e8dcf 100644 --- a/app/mailers/emails/merge_requests.rb +++ b/app/mailers/emails/merge_requests.rb @@ -65,7 +65,7 @@ module Emails setup_merge_request_mail(merge_request_id, recipient_id) @label_names = label_names - @labels_url = project_labels_url(@project) + @labels_url = project_labels_url(@project, subscribed: true) mail_answer_thread(@merge_request, merge_request_thread_options(updated_by_user_id, reason)) end diff --git a/app/mailers/emails/service_desk.rb b/app/mailers/emails/service_desk.rb index f6595a91bee..f67c2636fc6 100644 --- a/app/mailers/emails/service_desk.rb +++ b/app/mailers/emails/service_desk.rb @@ -227,8 +227,6 @@ module Emails # Filepaths we should replace in markdown content @uploads_as_attachments = [] - return unless Feature.enabled?(:service_desk_new_note_email_native_attachments, @note.project) - uploaders = find_uploaders_for(@note) return if uploaders.nil? return if uploaders.sum(&:size) > EMAIL_ATTACHMENTS_SIZE_LIMIT diff --git a/app/models/abuse_report.rb b/app/models/abuse_report.rb index 872dedf07b1..de6b644c536 100644 --- a/app/models/abuse_report.rb +++ b/app/models/abuse_report.rb @@ -139,11 +139,11 @@ class AbuseReport < ApplicationRecord def reported_content case report_type when :issue - project.issues.iid_in(route_hash[:id]).pick(:description_html) + reported_project.issues.iid_in(route_hash[:id]).pick(:description_html) when :merge_request - project.merge_requests.iid_in(route_hash[:id]).pick(:description_html) + reported_project.merge_requests.iid_in(route_hash[:id]).pick(:description_html) when :comment - project.notes.id_in(note_id_from_url).pick(:note_html) + reported_project.notes.id_in(note_id_from_url).pick(:note_html) end end @@ -157,13 +157,19 @@ class AbuseReport < ApplicationRecord user.abuse_reports.open.by_category(category).id_not_in(id).includes(:reporter) end + # createNote mutation calls noteable.project, + # which in case of abuse reports is nil + def project + nil + end + private - def project + def reported_project Project.find_by_full_path(route_hash.values_at(:namespace_id, :project_id).join('/')) end - def group + def reported_group Group.find_by_full_path(route_hash[:group_id]) end diff --git a/app/models/active_session.rb b/app/models/active_session.rb index e42f9eeef23..9756e1b7dd3 100644 --- a/app/models/active_session.rb +++ b/app/models/active_session.rb @@ -84,7 +84,7 @@ class ActiveSession ) Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.pipelined do |pipeline| + Gitlab::Redis::CrossSlot::Pipeline.new(redis).pipelined do |pipeline| pipeline.setex( key_name(user.id, session_private_id), expiry, @@ -135,9 +135,15 @@ class ActiveSession redis.srem(lookup_key_name(user.id), session_ids) + session_keys = rack_session_keys(session_ids) Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.del(key_names) - redis.del(rack_session_keys(session_ids)) + if Gitlab::Redis::ClusterUtil.cluster?(redis) + Gitlab::Redis::ClusterUtil.batch_unlink(key_names, redis) + Gitlab::Redis::ClusterUtil.batch_unlink(session_keys, redis) + else + redis.del(key_names) + redis.del(session_keys) + end end end @@ -206,7 +212,13 @@ class ActiveSession session_keys.each_slice(SESSION_BATCH_SIZE).flat_map do |session_keys_batch| Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.mget(session_keys_batch).compact.map do |raw_session| + raw_sessions = if Gitlab::Redis::ClusterUtil.cluster?(redis) + Gitlab::Redis::ClusterUtil.batch_get(session_keys_batch, redis) + else + redis.mget(session_keys_batch) + end + + raw_sessions.compact.map do |raw_session| load_raw_session(raw_session) end end @@ -249,7 +261,13 @@ class ActiveSession found = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do entry_keys = session_ids.map { |session_id| key_name(user_id, session_id) } - session_ids.zip(redis.mget(entry_keys)).to_h + entries = if Gitlab::Redis::ClusterUtil.cluster?(redis) + Gitlab::Redis::ClusterUtil.batch_get(entry_keys, redis) + else + redis.mget(entry_keys) + end + + session_ids.zip(entries).to_h end found.compact! @@ -258,7 +276,13 @@ class ActiveSession fallbacks = Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do entry_keys = missing.map { |session_id| key_name_v1(user_id, session_id) } - missing.zip(redis.mget(entry_keys)).to_h + entries = if Gitlab::Redis::ClusterUtil.cluster?(redis) + Gitlab::Redis::ClusterUtil.batch_get(entry_keys, redis) + else + redis.mget(entry_keys) + end + + missing.zip(entries).to_h end fallbacks.merge(found.compact) diff --git a/app/models/activity_pub.rb b/app/models/activity_pub.rb new file mode 100644 index 00000000000..9131d8be776 --- /dev/null +++ b/app/models/activity_pub.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +module ActivityPub + def self.table_name_prefix + "activity_pub_" + end +end diff --git a/app/models/activity_pub/releases_subscription.rb b/app/models/activity_pub/releases_subscription.rb new file mode 100644 index 00000000000..a6304f1fc35 --- /dev/null +++ b/app/models/activity_pub/releases_subscription.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module ActivityPub + class ReleasesSubscription < ApplicationRecord + belongs_to :project, optional: false + + enum :status, [:requested, :accepted], default: :requested + + attribute :payload, Gitlab::Database::Type::JsonPgSafe.new + + validates :payload, json_schema: { filename: 'activity_pub_follow_payload' }, allow_blank: true + validates :subscriber_url, presence: true, uniqueness: { case_sensitive: false, scope: :project_id }, + public_url: true + validates :subscriber_inbox_url, uniqueness: { case_sensitive: false, scope: :project_id }, + public_url: { allow_nil: true } + validates :shared_inbox_url, public_url: { allow_nil: true } + + def self.find_by_subscriber_url(subscriber_url) + find_by('LOWER(subscriber_url) = ?', subscriber_url.downcase) + end + end +end diff --git a/app/models/ai/service_access_token.rb b/app/models/ai/service_access_token.rb index b8a2a271976..46dfbe9078c 100644 --- a/app/models/ai/service_access_token.rb +++ b/app/models/ai/service_access_token.rb @@ -2,11 +2,13 @@ module Ai class ServiceAccessToken < ApplicationRecord + include IgnorableColumns self.table_name = 'service_access_tokens' + ignore_column :category, remove_with: '16.8', remove_after: '2024-01-22' + scope :expired, -> { where('expires_at < :now', now: Time.current) } scope :active, -> { where('expires_at > :now', now: Time.current) } - scope :for_category, ->(category) { where(category: category) } attr_encrypted :token, mode: :per_attribute_iv, @@ -16,11 +18,5 @@ module Ai encode_iv: false validates :token, :expires_at, presence: true - - enum category: { - code_suggestions: 1 - } - - validates :category, presence: true end end diff --git a/app/models/analytics/cycle_analytics/value_stream.rb b/app/models/analytics/cycle_analytics/value_stream.rb index 7f8c6eef704..d884932072b 100644 --- a/app/models/analytics/cycle_analytics/value_stream.rb +++ b/app/models/analytics/cycle_analytics/value_stream.rb @@ -36,6 +36,12 @@ module Analytics new(name: Analytics::CycleAnalytics::Stages::BaseService::DEFAULT_VALUE_STREAM_NAME, namespace: namespace) end + def project + return unless namespace.is_a?(::Namespaces::ProjectNamespace) + + namespace.project + end + private def max_value_streams_count diff --git a/app/models/application_setting.rb b/app/models/application_setting.rb index 824a2bd9fa4..8d4f50de75e 100644 --- a/app/models/application_setting.rb +++ b/app/models/application_setting.rb @@ -30,7 +30,9 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord jitsu_project_xid jitsu_administrator_email ], remove_with: '16.5', remove_after: '2023-09-22' - ignore_columns %i[encrypted_ai_access_token encrypted_ai_access_token_iv], remove_with: '16.6', remove_after: '2023-10-22' + ignore_columns %i[encrypted_ai_access_token encrypted_ai_access_token_iv], remove_with: '16.10', remove_after: '2024-03-22' + + ignore_columns %i[repository_storages], remove_with: '16.8', remove_after: '2023-12-21' INSTANCE_REVIEW_MIN_USERS = 50 GRAFANA_URL_ERROR_MESSAGE = 'Please check your Grafana URL setting in ' \ @@ -91,7 +93,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord serialize :disabled_oauth_sign_in_sources, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :domain_allowlist, Array # rubocop:disable Cop/ActiveRecordSerialize serialize :domain_denylist, Array # rubocop:disable Cop/ActiveRecordSerialize - serialize :repository_storages # rubocop:disable Cop/ActiveRecordSerialize # See https://gitlab.com/gitlab-org/gitlab/-/issues/300916 serialize :asset_proxy_allowlist, Array # rubocop:disable Cop/ActiveRecordSerialize @@ -303,8 +304,6 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord presence: true, numericality: { only_integer: true, greater_than_or_equal_to: 0 } - validates :repository_storages, presence: true - validate :check_repository_storages validate :check_repository_storages_weighted validates :auto_devops_domain, @@ -488,7 +487,7 @@ class ApplicationSetting < MainClusterwide::ApplicationRecord validates :invisible_captcha_enabled, inclusion: { in: [true, false], message: N_('must be a boolean value') } - validates :invitation_flow_enforcement, :can_create_group, :user_defaults_to_private_profile, + validates :invitation_flow_enforcement, :can_create_group, :allow_project_creation_for_guest_and_below, :user_defaults_to_private_profile, allow_nil: false, inclusion: { in: [true, false], message: N_('must be a boolean value') } diff --git a/app/models/application_setting_implementation.rb b/app/models/application_setting_implementation.rb index 1bd15a56de5..00b093c8ac3 100644 --- a/app/models/application_setting_implementation.rb +++ b/app/models/application_setting_implementation.rb @@ -57,6 +57,7 @@ module ApplicationSettingImplementation default_artifacts_expire_in: '30 days', default_branch_name: nil, default_branch_protection: Settings.gitlab['default_branch_protection'], + default_branch_protection_defaults: Settings.gitlab['default_branch_protection_defaults'], default_ci_config_path: nil, default_group_visibility: Settings.gitlab.default_projects_features['visibility_level'], default_project_creation: Settings.gitlab['default_project_creation'], @@ -158,7 +159,6 @@ module ApplicationSettingImplementation recaptcha_enabled: false, repository_checks_enabled: true, repository_storages_weighted: { 'default' => 100 }, - repository_storages: ['default'], require_admin_approval_after_user_signup: true, require_two_factor_authentication: false, restricted_visibility_levels: Settings.gitlab['restricted_visibility_levels'], @@ -433,10 +433,6 @@ module ApplicationSettingImplementation read_attribute(:asset_proxy_whitelist) end - def repository_storages - Array(read_attribute(:repository_storages)) - end - def commit_email_hostname super.presence || self.class.default_commit_email_hostname end @@ -644,12 +640,6 @@ module ApplicationSettingImplementation self.uuid = SecureRandom.uuid end - def check_repository_storages - invalid = repository_storages - Gitlab.config.repositories.storages.keys - errors.add(:repository_storages, "can't include: #{invalid.join(", ")}") unless - invalid.empty? - end - def coerce_repository_storages_weighted repository_storages_weighted.transform_values!(&:to_i) end diff --git a/app/models/bulk_imports/entity.rb b/app/models/bulk_imports/entity.rb index 437118c36e8..a075c2f7e4f 100644 --- a/app/models/bulk_imports/entity.rb +++ b/app/models/bulk_imports/entity.rb @@ -124,6 +124,10 @@ class BulkImports::Entity < ApplicationRecord entity_type.pluralize end + def portable_class + entity_type.classify.constantize + end + def base_resource_url_path "/#{pluralized_name}/#{encoded_source_full_path}" end diff --git a/app/models/bulk_imports/failure.rb b/app/models/bulk_imports/failure.rb index 44d16618c77..8a6077b523c 100644 --- a/app/models/bulk_imports/failure.rb +++ b/app/models/bulk_imports/failure.rb @@ -15,6 +15,10 @@ class BulkImports::Failure < ApplicationRecord pipeline_relation || default_relation end + def exception_message=(message) + super(::Projects::ImportErrorFilter.filter_message(message).truncate(255)) + end + private def pipeline_relation diff --git a/app/models/ci/bridge.rb b/app/models/ci/bridge.rb index d0ccf5c543a..cf6401dc1da 100644 --- a/app/models/ci/bridge.rb +++ b/app/models/ci/bridge.rb @@ -114,7 +114,7 @@ module Ci project = options&.dig(:trigger, :project) next unless project - scoped_variables.to_runner_variables.yield_self do |all_variables| + scoped_variables.to_runner_variables.then do |all_variables| ::ExpandVariables.expand(project, all_variables) end end @@ -199,7 +199,7 @@ module Ci branch = options&.dig(:trigger, :branch) return unless branch - scoped_variables.to_runner_variables.yield_self do |all_variables| + scoped_variables.to_runner_variables.then do |all_variables| ::ExpandVariables.expand(branch, all_variables) end end diff --git a/app/models/ci/build.rb b/app/models/ci/build.rb index d2cf9058976..0bb93a68470 100644 --- a/app/models/ci/build.rb +++ b/app/models/ci/build.rb @@ -392,8 +392,8 @@ module Ci name == 'pages' end - # overridden on EE - def pages_path_prefix; end + # Overriden on EE + def pages; end def runnable? true @@ -729,7 +729,7 @@ module Ci end def artifacts_expired? - artifacts_expire_at && artifacts_expire_at < Time.current + artifacts_expire_at&.past? end def artifacts_expire_in @@ -745,7 +745,7 @@ module Ci def has_expired_locked_archive_artifacts? locked_artifacts? && - artifacts_expire_at.present? && artifacts_expire_at < Time.current + artifacts_expire_at&.past? end def has_expiring_archive_artifacts? @@ -921,13 +921,25 @@ module Ci # Consider this object to have a structural integrity problems def doom! transaction do - update_columns(status: :failed, failure_reason: :data_integrity_failure) + now = Time.current + attributes = { + status: :failed, + failure_reason: :data_integrity_failure, + updated_at: now + } + attributes[:finished_at] = now unless finished_at.present? + + update_columns(attributes) all_queuing_entries.delete_all all_runtime_metadata.delete_all end deployment&.sync_status_with(self) + ::Gitlab::Ci::Pipeline::Metrics + .job_failure_reason_counter + .increment(reason: :data_integrity_failure) + Gitlab::AppLogger.info( message: 'Build doomed', class: self.class.name, diff --git a/app/models/ci/build_trace_chunks/redis_base.rb b/app/models/ci/build_trace_chunks/redis_base.rb index 3b7a844d122..5f6b5c30a6a 100644 --- a/app/models/ci/build_trace_chunks/redis_base.rb +++ b/app/models/ci/build_trace_chunks/redis_base.rb @@ -71,7 +71,11 @@ module Ci with_redis do |redis| # https://gitlab.com/gitlab-org/gitlab/-/issues/224171 Gitlab::Instrumentation::RedisClusterValidator.allow_cross_slot_commands do - redis.del(keys) + if Gitlab::Redis::ClusterUtil.cluster?(redis) + Gitlab::Redis::ClusterUtil.batch_unlink(keys, redis) + else + redis.del(keys) + end end end end diff --git a/app/models/ci/build_trace_metadata.rb b/app/models/ci/build_trace_metadata.rb index c5ad3d19425..525cb08f2ca 100644 --- a/app/models/ci/build_trace_metadata.rb +++ b/app/models/ci/build_trace_metadata.rb @@ -33,7 +33,7 @@ module Ci return false unless archival_attempts_available? return true unless last_archival_attempt_at - last_archival_attempt_at + backoff < Time.current + (last_archival_attempt_at + backoff).past? end def archival_attempts_available? diff --git a/app/models/ci/catalog/components_project.rb b/app/models/ci/catalog/components_project.rb index 2bc33a6f050..02593d41bc2 100644 --- a/app/models/ci/catalog/components_project.rb +++ b/app/models/ci/catalog/components_project.rb @@ -9,7 +9,8 @@ module Ci TEMPLATE_FILE = 'template.yml' TEMPLATES_DIR = 'templates' - TEMPLATE_PATH_REGEX = '^templates\/\w+\-?\w+(?:\/template)?\.yml$' + TEMPLATE_PATH_REGEX = '^templates\/[\w-]+(?:\/template)?\.yml$' + COMPONENTS_LIMIT = 10 ComponentData = Struct.new(:content, :path, keyword_init: true) @@ -18,8 +19,8 @@ module Ci @sha = sha end - def fetch_component_paths(sha) - project.repository.search_files_by_regexp(TEMPLATE_PATH_REGEX, sha) + def fetch_component_paths(sha, limit: COMPONENTS_LIMIT) + project.repository.search_files_by_regexp(TEMPLATE_PATH_REGEX, sha, limit: limit) end def extract_component_name(path) diff --git a/app/models/ci/catalog/listing.rb b/app/models/ci/catalog/listing.rb index c3b18af8c3f..51bd85016a5 100644 --- a/app/models/ci/catalog/listing.rb +++ b/app/models/ci/catalog/listing.rb @@ -3,42 +3,53 @@ module Ci module Catalog class Listing - # This class is the SSoT to displaying the list of resources in the - # CI/CD Catalog given a namespace as a scope. + # This class is the SSoT to displaying the list of resources in the CI/CD Catalog. # This model is not directly backed by a table and joins catalog resources # with projects to return relevant data. - def initialize(namespace, current_user) - raise ArgumentError, 'Namespace is not a root namespace' unless namespace.root? - @namespace = namespace + MIN_SEARCH_LENGTH = 3 + + def initialize(current_user) @current_user = current_user end - def resources(sort: nil) + def resources(namespace: nil, sort: nil, search: nil) + relation = all_resources + relation = by_namespace(relation, namespace) + relation = by_search(relation, search) + case sort.to_s - when 'name_desc' then all_resources.order_by_name_desc - when 'name_asc' then all_resources.order_by_name_asc - when 'latest_released_at_desc' then all_resources.order_by_latest_released_at_desc - when 'latest_released_at_asc' then all_resources.order_by_latest_released_at_asc + when 'name_desc' then relation.order_by_name_desc + when 'name_asc' then relation.order_by_name_asc + when 'latest_released_at_desc' then relation.order_by_latest_released_at_desc + when 'latest_released_at_asc' then relation.order_by_latest_released_at_asc + when 'created_at_asc' then relation.order_by_created_at_asc else - all_resources.order_by_created_at_desc + relation.order_by_created_at_desc end end private - attr_reader :namespace, :current_user + attr_reader :current_user def all_resources - Ci::Catalog::Resource - .joins(:project).includes(:project) - .merge(projects_in_namespace_visible_to_user) + Ci::Catalog::Resource.joins(:project).includes(:project) + .merge(Project.public_or_visible_to_user(current_user)) + end + + def by_namespace(relation, namespace) + return relation unless namespace + raise ArgumentError, 'Namespace is not a root namespace' unless namespace.root? + + relation.merge(Project.in_namespace(namespace.self_and_descendant_ids)) end - def projects_in_namespace_visible_to_user - Project - .in_namespace(namespace.self_and_descendant_ids) - .public_or_visible_to_user(current_user, ::Gitlab::Access::DEVELOPER) + def by_search(relation, search) + return relation unless search + return relation.none if search.length < MIN_SEARCH_LENGTH + + relation.search(search) end end end diff --git a/app/models/ci/catalog/resource.rb b/app/models/ci/catalog/resource.rb index 8ffc0292a69..f947c5158cf 100644 --- a/app/models/ci/catalog/resource.rb +++ b/app/models/ci/catalog/resource.rb @@ -8,29 +8,55 @@ module Ci # dependency on the Project model and its need to join with that table # in order to generate the CI/CD catalog. class Resource < ::ApplicationRecord + include Gitlab::SQL::Pattern + self.table_name = 'catalog_resources' belongs_to :project - has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :catalog_resource - has_many :versions, class_name: 'Ci::Catalog::Resources::Version', inverse_of: :catalog_resource + has_many :components, class_name: 'Ci::Catalog::Resources::Component', foreign_key: :catalog_resource_id, + inverse_of: :catalog_resource + has_many :versions, class_name: 'Ci::Catalog::Resources::Version', foreign_key: :catalog_resource_id, + inverse_of: :catalog_resource scope :for_projects, ->(project_ids) { where(project_id: project_ids) } + scope :search, ->(query) { fuzzy_search(query, [:name, :description], use_minimum_char_limit: false) } + scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } - scope :order_by_name_desc, -> { joins(:project).merge(Project.sorted_by_name_desc) } - scope :order_by_name_asc, -> { joins(:project).merge(Project.sorted_by_name_asc) } + scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } + scope :order_by_name_desc, -> { reorder(arel_table[:name].desc.nulls_last) } + scope :order_by_name_asc, -> { reorder(arel_table[:name].asc.nulls_last) } scope :order_by_latest_released_at_desc, -> { reorder(arel_table[:latest_released_at].desc.nulls_last) } scope :order_by_latest_released_at_asc, -> { reorder(arel_table[:latest_released_at].asc.nulls_last) } - delegate :avatar_path, :description, :name, :star_count, :forks_count, to: :project + delegate :avatar_path, :star_count, :forks_count, to: :project enum state: { draft: 0, published: 1 } - def versions - project.releases.order_released_desc + before_create :sync_with_project + + def unpublish! + update!(state: :draft) + end + + def publish! + update!(state: :published) + end + + def sync_with_project! + sync_with_project + save! end - def latest_version - project.releases.latest + private + + # These columns are denormalized from the `projects` table. We first sync these + # columns when the catalog resource record is created. Then any updates to the + # `projects` columns will be synced to the `catalog_resources` table by a worker + # (to be implemented in https://gitlab.com/gitlab-org/gitlab/-/issues/429376.) + def sync_with_project + self.name = project.name + self.description = project.description + self.visibility_level = project.visibility_level end end end diff --git a/app/models/ci/catalog/resources/component.rb b/app/models/ci/catalog/resources/component.rb index 7b95c14ba7e..07d5404981b 100644 --- a/app/models/ci/catalog/resources/component.rb +++ b/app/models/ci/catalog/resources/component.rb @@ -6,6 +6,8 @@ module Ci # This class represents a CI/CD Catalog resource component. # The data will be used as metadata of a component. class Component < ::ApplicationRecord + include BulkInsertSafe + self.table_name = 'catalog_resource_components' belongs_to :project, inverse_of: :ci_components diff --git a/app/models/ci/catalog/resources/version.rb b/app/models/ci/catalog/resources/version.rb index 68f60e6a965..bd0ebc77a6d 100644 --- a/app/models/ci/catalog/resources/version.rb +++ b/app/models/ci/catalog/resources/version.rb @@ -6,6 +6,8 @@ module Ci # This class represents a CI/CD Catalog resource version. # Only versions which contain valid CI components are included in this table. class Version < ::ApplicationRecord + include BulkInsertableAssociations + self.table_name = 'catalog_resource_versions' belongs_to :release, inverse_of: :catalog_resource_version @@ -14,6 +16,100 @@ module Ci has_many :components, class_name: 'Ci::Catalog::Resources::Component', inverse_of: :version validates :release, :catalog_resource, :project, presence: true + + scope :for_catalog_resources, ->(catalog_resources) { where(catalog_resource_id: catalog_resources) } + scope :preloaded, -> { includes(:catalog_resource, project: [:route, { namespace: :route }], release: :author) } + + scope :order_by_created_at_asc, -> { reorder(created_at: :asc) } + scope :order_by_created_at_desc, -> { reorder(created_at: :desc) } + # After we denormalize the `released_at` column, we won't need to use `joins(:release)` and keyset_order_* + scope :order_by_released_at_asc, -> { joins(:release).keyset_order_by_released_at_asc } + scope :order_by_released_at_desc, -> { joins(:release).keyset_order_by_released_at_desc } + + delegate :name, :description, :tag, :sha, :released_at, :author_id, to: :release + + class << self + # In the future, we should support semantic versioning. + # See https://gitlab.com/gitlab-org/gitlab/-/issues/427286 + def latest + order_by_released_at_desc.first + end + + # This query uses LATERAL JOIN to find the latest version for each catalog resource. To avoid + # joining the `catalog_resources` table, we build an in-memory table using the resource ids. + # Example: + # SELECT ... + # FROM (VALUES (CATALOG_RESOURCE_ID_1),(CATALOG_RESOURCE_ID_2)) catalog_resources (id) + # INNER JOIN LATERAL (...) + def latest_for_catalog_resources(catalog_resources) + return none if catalog_resources.empty? + + catalog_resources_table = Ci::Catalog::Resource.arel_table + catalog_resources_id_list = catalog_resources.map { |resource| "(#{resource.id})" }.join(',') + + # We need to use an alias for the `releases` table here so that it does not + # conflict with `joins(:release)` in the `order_by_released_at_*` scope. + join_query = Ci::Catalog::Resources::Version + .where(catalog_resources_table[:id].eq(arel_table[:catalog_resource_id])) + .joins("INNER JOIN releases AS rel ON rel.id = #{table_name}.release_id") + .order(Arel.sql('rel.released_at DESC')) + .limit(1) + + Ci::Catalog::Resources::Version + .from("(VALUES #{catalog_resources_id_list}) #{catalog_resources_table.name} (id)") + .joins("INNER JOIN LATERAL (#{join_query.to_sql}) #{table_name} ON TRUE") + end + + def keyset_order_by_released_at_asc + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :released_at, + column_expression: Release.arel_table[:released_at], + order_expression: Release.arel_table[:released_at].asc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Release.arel_table[:id].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + + reorder(keyset_order) + end + + def keyset_order_by_released_at_desc + keyset_order = Gitlab::Pagination::Keyset::Order.build([ + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :released_at, + column_expression: Release.arel_table[:released_at], + order_expression: Release.arel_table[:released_at].desc, + nullable: :not_nullable, + distinct: false + ), + Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: :id, + order_expression: Release.arel_table[:id].desc, + nullable: :not_nullable, + distinct: true + ) + ]) + + reorder(keyset_order) + end + + def order_by(order) + case order.to_s + when 'created_asc' then order_by_created_at_asc + when 'created_desc' then order_by_created_at_desc + when 'released_at_asc' then order_by_released_at_asc + else + order_by_released_at_desc + end + end + end end end end diff --git a/app/models/ci/job_artifact.rb b/app/models/ci/job_artifact.rb index 2a346f97958..fe4437a4ad6 100644 --- a/app/models/ci/job_artifact.rb +++ b/app/models/ci/job_artifact.rb @@ -306,7 +306,7 @@ module Ci end def expired? - expire_at.present? && expire_at < Time.current + expire_at.present? && expire_at.past? end def expiring? diff --git a/app/models/ci/job_token/scope.rb b/app/models/ci/job_token/scope.rb index f389c642fd8..17809ba20d3 100644 --- a/app/models/ci/job_token/scope.rb +++ b/app/models/ci/job_token/scope.rb @@ -54,6 +54,11 @@ module Ci # if the setting is disabled any project is considered to be in scope. return true unless current_project.ci_outbound_job_token_scope_enabled? + if !accessed_project.private? && + Feature.enabled?(:restrict_ci_job_token_for_public_and_internal_projects, accessed_project) + return true + end + outbound_allowlist.includes?(accessed_project) end diff --git a/app/models/ci/pipeline.rb b/app/models/ci/pipeline.rb index 0a876d26cc9..cf3efc5998f 100644 --- a/app/models/ci/pipeline.rb +++ b/app/models/ci/pipeline.rb @@ -30,9 +30,11 @@ module Ci PROJECT_ROUTE_AND_NAMESPACE_ROUTE = { project: [:project_feature, :route, { namespace: :route }] }.freeze - CONFIG_EXTENSION = '.gitlab-ci.yml' - DEFAULT_CONFIG_PATH = CONFIG_EXTENSION + + DEFAULT_CONFIG_PATH = '.gitlab-ci.yml' + CANCELABLE_STATUSES = (Ci::HasStatus::CANCELABLE_STATUSES + ['manual']).freeze + UNLOCKABLE_STATUSES = (Ci::Pipeline.completed_statuses + [:manual]).freeze paginates_per 15 @@ -189,6 +191,7 @@ module Ci # this is needed to ensure tests to be covered transition [:running] => :running + transition [:waiting_for_callback] => :waiting_for_callback end event :request_resource do @@ -203,6 +206,10 @@ module Ci transition any - [:running] => :running end + event :wait_for_callback do + transition any - [:waiting_for_callback] => :waiting_for_callback + end + event :skip do transition any - [:skipped] => :skipped end @@ -266,6 +273,32 @@ module Ci pipeline.run_after_commit { PipelineMetricsWorker.perform_async(pipeline.id) } end + after_transition any => UNLOCKABLE_STATUSES do |pipeline| + # This is a temporary flag that we added just in case we need to totally + # stop unlocking pipelines due to unexpected issues during rollout. + next if Feature.enabled?(:ci_stop_unlock_pipelines, pipeline.project) + + next unless Feature.enabled?(:ci_unlock_non_successful_pipelines, pipeline.project) + + pipeline.run_after_commit do + Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(pipeline.ci_ref_id) + end + end + + # TODO: Remove this block once we've completed roll-out of ci_unlock_non_successful_pipelines + # https://gitlab.com/gitlab-org/gitlab/-/issues/428408 + after_transition any => :success do |pipeline| + # This is a temporary flag that we added just in case we need to totally + # stop unlocking pipelines due to unexpected issues during rollout. + next if Feature.enabled?(:ci_stop_unlock_pipelines, pipeline.project) + + next unless Feature.disabled?(:ci_unlock_non_successful_pipelines, pipeline.project) + + pipeline.run_after_commit do + Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(pipeline.ci_ref_id) + end + end + after_transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success do |pipeline| # We wait a little bit to ensure that all Ci::BuildFinishedWorkers finish first # because this is where some metrics like code coverage is parsed and stored @@ -380,7 +413,7 @@ module Ci pipeline.run_after_commit do next if pipeline.child? - next unless project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) + next unless Feature.enabled?(:widget_pipeline_pass_subscription_update, project) || project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) pipeline.all_merge_requests.opened.each do |merge_request| GraphqlTriggers.merge_request_merge_status_updated(merge_request) @@ -389,6 +422,7 @@ module Ci end end + scope :with_unlockable_status, -> { with_status(*UNLOCKABLE_STATUSES) } scope :internal, -> { where(source: internal_sources) } scope :no_child, -> { where.not(source: :parent_pipeline) } scope :ci_sources, -> { where(source: Enums::Ci::Pipeline.ci_sources.values) } @@ -554,7 +588,7 @@ module Ci end def self.bridgeable_statuses - ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource preparing pending] + ::Ci::Pipeline::AVAILABLE_STATUSES - %w[created waiting_for_resource waiting_for_callback preparing pending] end def self.auto_devops_pipelines_completed_total @@ -850,6 +884,7 @@ module Ci when 'created' then nil when 'waiting_for_resource' then request_resource when 'preparing' then prepare + when 'waiting_for_callback' then wait_for_callback when 'pending' then enqueue when 'running' then run when 'success' then succeed @@ -1366,11 +1401,6 @@ module Ci merge_request.merge_request_diff_for(merge_request_diff_sha) end - def reduced_build_attributes_list_for_rules? - ::Feature.enabled?(:reduced_build_attributes_list_for_rules, project) - end - strong_memoize_attr :reduced_build_attributes_list_for_rules? - private def add_message(severity, content) diff --git a/app/models/ci/ref.rb b/app/models/ci/ref.rb index 8655e8eb9b8..e8ce58f2de5 100644 --- a/app/models/ci/ref.rb +++ b/app/models/ci/ref.rb @@ -30,15 +30,6 @@ module Ci state :fixed, value: 3 state :broken, value: 4 state :still_failing, value: 5 - - after_transition any => [:fixed, :success] do |ci_ref| - # Do not try to unlock if no artifacts are locked - next unless ci_ref.artifacts_locked? - - ci_ref.run_after_commit do - Ci::Refs::UnlockPreviousPipelinesWorker.perform_async(ci_ref.id) - end - end end class << self @@ -75,5 +66,13 @@ module Ci self.status_name end end + + def last_successful_ci_source_pipeline + pipelines.ci_sources.success.order(id: :desc).first + end + + def last_unlockable_ci_source_pipeline + pipelines.ci_sources.with_unlockable_status.order(id: :desc).first + end end end diff --git a/app/models/ci/runner.rb b/app/models/ci/runner.rb index 91c919dc662..9c30beeeb59 100644 --- a/app/models/ci/runner.rb +++ b/app/models/ci/runner.rb @@ -123,6 +123,8 @@ module Ci joins(:runner_namespaces).where(ci_runner_namespaces: { namespace_id: group_id }) } + scope :with_creator_id, -> (value) { where(creator_id: value) } + scope :belonging_to_group_or_project_descendants, -> (group_id) { group_ids = Ci::NamespaceMirror.by_group_and_descendants(group_id).select(:namespace_id) project_ids = Ci::ProjectMirror.by_namespace_id(group_ids).select(:project_id) @@ -217,6 +219,8 @@ module Ci validate :any_project, if: :project_type? validate :exactly_one_group, if: :group_type? + scope :with_version_prefix, ->(value) { joins(:runner_managers).merge(RunnerManager.with_version_prefix(value)) } + acts_as_taggable after_destroy :cleanup_runner_queue diff --git a/app/models/ci/runner_manager.rb b/app/models/ci/runner_manager.rb index 7d8fc097f51..e6576859827 100644 --- a/app/models/ci/runner_manager.rb +++ b/app/models/ci/runner_manager.rb @@ -62,6 +62,16 @@ module Ci scope :order_id_desc, -> { order(id: :desc) } + scope :with_version_prefix, ->(value) do + regex = version_regex_expression_for_version(value) + value += '.' if regex.end_with?('\.') && !value.end_with?('.') + substring = Arel::Nodes::NamedFunction.new('substring', [ + Ci::RunnerManager.arel_table[:version], + Arel.sql("'#{regex}'::text") + ]) + where(substring.eq(sanitize_sql_like(value))) + end + scope :with_upgrade_status, ->(upgrade_status) do joins(:runner_version).where(runner_version: { status: upgrade_status }) end @@ -137,5 +147,16 @@ module Ci Ci::Runners::ProcessRunnerVersionUpdateWorker.perform_async(new_version) end + + def self.version_regex_expression_for_version(version) + case version + when /\d+\.\d+\.\d+/ + '^\d+\.\d+\.\d+' + when /\d+\.\d+(\.)?/ + '^\d+\.\d+\.' + else + '^\d+\.' + end + end end end diff --git a/app/models/ci/sources/pipeline.rb b/app/models/ci/sources/pipeline.rb index 5b6946b04fd..475d57ee4c8 100644 --- a/app/models/ci/sources/pipeline.rb +++ b/app/models/ci/sources/pipeline.rb @@ -12,7 +12,7 @@ module Ci :pipeline_id_convert_to_bigint, :source_pipeline_id_convert_to_bigint ], remove_with: '16.6', remove_after: '2023-10-22' - columns_changing_default :partition_id + columns_changing_default :partition_id, :source_partition_id self.table_name = "ci_sources_pipelines" diff --git a/app/models/ci/stage.rb b/app/models/ci/stage.rb index 3a498972153..3d2df9a45ef 100644 --- a/app/models/ci/stage.rb +++ b/app/models/ci/stage.rb @@ -78,6 +78,10 @@ module Ci transition any - [:running] => :running end + event :wait_for_callback do + transition any - [:waiting_for_callback] => :waiting_for_callback + end + event :skip do transition any - [:skipped] => :skipped end @@ -109,6 +113,7 @@ module Ci when 'created' then nil when 'waiting_for_resource' then request_resource when 'preparing' then prepare + when 'waiting_for_callback' then wait_for_callback when 'pending' then enqueue when 'running' then run when 'success' then succeed diff --git a/app/models/commit.rb b/app/models/commit.rb index 39e12b53f21..886e6e9fbd7 100644 --- a/app/models/commit.rb +++ b/app/models/commit.rb @@ -372,9 +372,7 @@ class Commit strong_memoize(:raw_signature_type) do next unless @raw.instance_of?(Gitlab::Git::Commit) - if raw_commit_from_rugged? && gpg_commit.signature_text.present? - :PGP - elsif defined? @raw.raw_commit.signature_type + if defined? @raw.raw_commit.signature_type @raw.raw_commit.signature_type end end @@ -397,10 +395,6 @@ class Commit end end - def raw_commit_from_rugged? - @raw.raw_commit.is_a?(Rugged::Commit) - end - def gpg_commit @gpg_commit ||= Gitlab::Gpg::Commit.new(self) end diff --git a/app/models/commit_status.rb b/app/models/commit_status.rb index 3761aa81bf7..9f77bd8ebe2 100644 --- a/app/models/commit_status.rb +++ b/app/models/commit_status.rb @@ -8,20 +8,24 @@ class CommitStatus < Ci::ApplicationRecord include Presentable include BulkInsertableAssociations include TaggableQueries - - def self.switch_table_names - if Gitlab::Utils.to_boolean(ENV['USE_CI_BUILDS_ROUTING_TABLE']) - :p_ci_builds - else - :ci_builds - end - end - - self.table_name = self.switch_table_names + include IgnorableColumns + + ignore_columns %i[ + auto_canceled_by_id_convert_to_bigint + commit_id_convert_to_bigint + erased_by_id_convert_to_bigint + project_id_convert_to_bigint + runner_id_convert_to_bigint + trigger_request_id_convert_to_bigint + upstream_pipeline_id_convert_to_bigint + user_id_convert_to_bigint + ], remove_with: '17.0', remove_after: '2024-04-22' + + self.table_name = :p_ci_builds self.sequence_name = :ci_builds_id_seq self.primary_key = :id - partitionable scope: :pipeline + partitionable scope: :pipeline, partitioned: true belongs_to :user belongs_to :project @@ -155,15 +159,15 @@ class CommitStatus < Ci::ApplicationRecord end event :drop do - transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :failed + transition [:created, :waiting_for_resource, :preparing, :waiting_for_callback, :pending, :running, :manual, :scheduled] => :failed end event :success do - transition [:created, :waiting_for_resource, :preparing, :pending, :running] => :success + transition [:created, :waiting_for_resource, :preparing, :waiting_for_callback, :pending, :running] => :success end event :cancel do - transition [:created, :waiting_for_resource, :preparing, :pending, :running, :manual, :scheduled] => :canceled + transition [:created, :waiting_for_resource, :preparing, :waiting_for_callback, :pending, :running, :manual, :scheduled] => :canceled end before_transition [:created, :waiting_for_resource, :preparing, :skipped, :manual, :scheduled] => :pending do |commit_status| diff --git a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb index 1d9cf5729cd..dfcc905b3c3 100644 --- a/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb +++ b/app/models/concerns/analytics/cycle_analytics/stage_event_model.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module Analytics module CycleAnalytics module StageEventModel diff --git a/app/models/concerns/can_move_repository_storage.rb b/app/models/concerns/can_move_repository_storage.rb index 1132e4e79ac..1646ed3dc7c 100644 --- a/app/models/concerns/can_move_repository_storage.rb +++ b/app/models/concerns/can_move_repository_storage.rb @@ -9,6 +9,9 @@ module CanMoveRepositoryStorage # progress beforehand. Setting a repository read-only will fail if it is # already in that state. # + # It is assumed that `with_lock` is used here to ensure that no race condition + # appears between reading and writing the read-only column. + # # @return nil. Failures will raise an exception def set_repository_read_only!(skip_git_transfer_check: false) with_lock do @@ -16,10 +19,10 @@ module CanMoveRepositoryStorage !skip_git_transfer_check && git_transfer_in_progress? raise RepositoryReadOnlyError, _('Repository already read-only') if - _safe_read_repository_read_only_column + safe_read_repository_read_only_column raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - _update_repository_read_only_column(true) + update_repository_read_only_column(true) nil end @@ -28,12 +31,8 @@ module CanMoveRepositoryStorage # Set repository as writable again. Unlike setting it read-only, this will # succeed if the repository is already writable. def set_repository_writable! - with_lock do - raise ActiveRecord::RecordNotSaved, _('Database update failed') unless - _update_repository_read_only_column(false) - - nil - end + raise ActiveRecord::RecordNotSaved, _('Database update failed') unless + update_repository_read_only_column(false) end def git_transfer_in_progress? @@ -49,13 +48,13 @@ module CanMoveRepositoryStorage # Not all resources that can move repositories have the `repository_read_only` # in their table, for example groups. We need these methods to override the # behavior in those classes in order to access the column. - def _safe_read_repository_read_only_column + def safe_read_repository_read_only_column # This was added originally this way because of # https://gitlab.com/gitlab-org/gitlab/-/commit/43f9b98302d3985312c9f8b66018e2835d8293d2 self.class.where(id: id).pick(:repository_read_only) end - def _update_repository_read_only_column(value) + def update_repository_read_only_column(value) update_column(:repository_read_only, value) end end diff --git a/app/models/concerns/ci/has_status.rb b/app/models/concerns/ci/has_status.rb index 2971ecb04b8..fb2b12e5f00 100644 --- a/app/models/concerns/ci/has_status.rb +++ b/app/models/concerns/ci/has_status.rb @@ -6,19 +6,20 @@ module Ci DEFAULT_STATUS = 'created' BLOCKED_STATUS = %w[manual scheduled].freeze - AVAILABLE_STATUSES = %w[created waiting_for_resource preparing pending running success failed canceled skipped manual scheduled].freeze + AVAILABLE_STATUSES = %w[created waiting_for_resource preparing waiting_for_callback pending running success failed canceled skipped manual scheduled].freeze STARTED_STATUSES = %w[running success failed].freeze - ACTIVE_STATUSES = %w[waiting_for_resource preparing pending running].freeze + ACTIVE_STATUSES = %w[waiting_for_resource preparing waiting_for_callback pending running].freeze COMPLETED_STATUSES = %w[success failed canceled skipped].freeze STOPPED_STATUSES = COMPLETED_STATUSES + BLOCKED_STATUS - ORDERED_STATUSES = %w[failed preparing pending running waiting_for_resource manual scheduled canceled success skipped created].freeze + ORDERED_STATUSES = %w[failed preparing pending running waiting_for_callback waiting_for_resource manual scheduled canceled success skipped created].freeze PASSED_WITH_WARNINGS_STATUSES = %w[failed canceled].to_set.freeze IGNORED_STATUSES = %w[manual].to_set.freeze ALIVE_STATUSES = (ACTIVE_STATUSES + ['created']).freeze CANCELABLE_STATUSES = (ALIVE_STATUSES + ['scheduled']).freeze STATUSES_ENUM = { created: 0, pending: 1, running: 2, success: 3, failed: 4, canceled: 5, skipped: 6, manual: 7, - scheduled: 8, preparing: 9, waiting_for_resource: 10 }.freeze + scheduled: 8, preparing: 9, waiting_for_resource: 10, + waiting_for_callback: 11 }.freeze UnknownStatusError = Class.new(StandardError) @@ -58,6 +59,7 @@ module Ci state :created, value: 'created' state :waiting_for_resource, value: 'waiting_for_resource' state :preparing, value: 'preparing' + state :waiting_for_callback, value: 'waiting_for_callback' state :pending, value: 'pending' state :running, value: 'running' state :failed, value: 'failed' @@ -72,6 +74,7 @@ module Ci scope :waiting_for_resource, -> { with_status(:waiting_for_resource) } scope :preparing, -> { with_status(:preparing) } scope :relevant, -> { without_status(:created) } + scope :waiting_for_callback, -> { with_status(:waiting_for_callback) } scope :running, -> { with_status(:running) } scope :pending, -> { with_status(:pending) } scope :success, -> { with_status(:success) } diff --git a/app/models/concerns/commit_signature.rb b/app/models/concerns/commit_signature.rb index 5bdf6bb31bf..201994cb321 100644 --- a/app/models/concerns/commit_signature.rb +++ b/app/models/concerns/commit_signature.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module CommitSignature extend ActiveSupport::Concern diff --git a/app/models/concerns/diff_positionable_note.rb b/app/models/concerns/diff_positionable_note.rb index 2f64129b65f..e799127d69a 100644 --- a/app/models/concerns/diff_positionable_note.rb +++ b/app/models/concerns/diff_positionable_note.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module DiffPositionableNote extend ActiveSupport::Concern diff --git a/app/models/concerns/enums/package_metadata.rb b/app/models/concerns/enums/package_metadata.rb index 3f107987ef6..352eb41829b 100644 --- a/app/models/concerns/enums/package_metadata.rb +++ b/app/models/concerns/enums/package_metadata.rb @@ -14,7 +14,8 @@ module Enums apk: 9, rpm: 10, deb: 11, - cbl_mariner: 12 + 'cbl-mariner': 12, + wolfi: 13 }.with_indifferent_access.freeze ADVISORY_SOURCES = { diff --git a/app/models/concerns/enums/sbom.rb b/app/models/concerns/enums/sbom.rb index 59aafc32d94..af8e37b4248 100644 --- a/app/models/concerns/enums/sbom.rb +++ b/app/models/concerns/enums/sbom.rb @@ -18,7 +18,8 @@ module Enums apk: 9, rpm: 10, deb: 11, - cbl_mariner: 12 + 'cbl-mariner': 12, + wolfi: 13 }.with_indifferent_access.freeze def self.component_types diff --git a/app/models/concerns/merge_request_reviewer_state.rb b/app/models/concerns/merge_request_reviewer_state.rb index 412b1da55da..e4ee6e7e58e 100644 --- a/app/models/concerns/merge_request_reviewer_state.rb +++ b/app/models/concerns/merge_request_reviewer_state.rb @@ -6,7 +6,8 @@ module MergeRequestReviewerState included do enum state: { unreviewed: 0, - reviewed: 1 + reviewed: 1, + requested_changes: 2 } validates :state, diff --git a/app/models/concerns/repository_storage_movable.rb b/app/models/concerns/repository_storage_movable.rb index 77edabb9706..b1dbebff4fb 100644 --- a/app/models/concerns/repository_storage_movable.rb +++ b/app/models/concerns/repository_storage_movable.rb @@ -6,6 +6,9 @@ module RepositoryStorageMovable included do scope :order_created_at_desc, -> { order(created_at: :desc) } + scope :scheduled_or_started, -> do + where(state: [state_machine.states[:scheduled].value, state_machine.states[:started].value]) + end validates :container, presence: true validates :state, presence: true @@ -43,6 +46,8 @@ module RepositoryStorageMovable transition replicated: :cleanup_failed end + # An after_transition can't affect the success of the transition. + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/45160#note_431071664 around_transition initial: :scheduled do |storage_move, block| block.call @@ -61,13 +66,9 @@ module RepositoryStorageMovable true end - before_transition started: :replicated do |storage_move| + after_transition started: :replicated do |storage_move| storage_move.container.set_repository_writable! - storage_move.update_repository_storage(storage_move.destination_storage_name) - end - - after_transition started: :replicated do |storage_move| # We have several scripts in place that replicate some statistics information # to other databases. Some of them depend on the updated_at column # to identify the models they need to extract. @@ -83,6 +84,13 @@ module RepositoryStorageMovable storage_move.container.set_repository_writable! end + # This callback ensures the repository is set to writable in the event of + # a connection error during the :started -> :replicated transition + # https://gitlab.com/gitlab-org/gitlab/-/issues/427254#note_1636072125 + before_transition replicated: :cleanup_failed do |storage_move| + storage_move.container.set_repository_writable! + end + state :initial, value: 1 state :scheduled, value: 2 state :started, value: 3 @@ -93,15 +101,6 @@ module RepositoryStorageMovable end end - # Projects, snippets, and group wikis has different db structure. In projects, - # we need to update some columns in this step, but we don't with the other resources. - # - # Therefore, we create this No-op method for snippets and wikis and let project - # overwrite it in their implementation. - def update_repository_storage(new_storage) - # No-op - end - def schedule_repository_storage_update_worker raise NotImplementedError end diff --git a/app/models/concerns/restricted_signup.rb b/app/models/concerns/restricted_signup.rb index 6af9ede5e8b..87b62214529 100644 --- a/app/models/concerns/restricted_signup.rb +++ b/app/models/concerns/restricted_signup.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + module RestrictedSignup extend ActiveSupport::Concern diff --git a/app/models/concerns/token_authenticatable_strategies/base.rb b/app/models/concerns/token_authenticatable_strategies/base.rb index d0085b60d98..b25ee434484 100644 --- a/app/models/concerns/token_authenticatable_strategies/base.rb +++ b/app/models/concerns/token_authenticatable_strategies/base.rb @@ -65,7 +65,7 @@ module TokenAuthenticatableStrategies return false unless expirable? && token_expiration_enforced? exp = expires_at(instance) - !!exp && Time.current > exp + !!exp && exp.past? end def expirable? diff --git a/app/models/concerns/use_sql_function_for_primary_key_lookups.rb b/app/models/concerns/use_sql_function_for_primary_key_lookups.rb new file mode 100644 index 00000000000..c3ca3cfc038 --- /dev/null +++ b/app/models/concerns/use_sql_function_for_primary_key_lookups.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module UseSqlFunctionForPrimaryKeyLookups + extend ActiveSupport::Concern + + class_methods do + def find(*args) + return super unless Feature.enabled?(:use_sql_functions_for_primary_key_lookups, Feature.current_request) + return super unless args.one? + return super if block_given? || primary_key.nil? || scope_attributes? + + return_array = false + id = args.first + + if id.is_a?(Array) + return super if id.many? + + return_array = true + + id = id.first + end + + return super if id.nil? || (id.is_a?(String) && !id.number?) + + from_clause = "find_#{table_name}_by_id(?) #{quoted_table_name}" + filter_empty_row = "#{quoted_table_name}.#{connection.quote_column_name(primary_key)} IS NOT NULL" + query = from(from_clause).where(filter_empty_row).limit(1).to_sql + # Using find_by_sql so we get query cache working + record = find_by_sql([query, id]).first + + unless record + message = "Couldn't find #{name} with '#{primary_key}'=#{id}" + raise(ActiveRecord::RecordNotFound.new(message, name, primary_key, id)) + end + + return_array ? [record] : record + end + end +end diff --git a/app/models/concerns/users/visitable.rb b/app/models/concerns/users/visitable.rb index cb8e5fdc682..029d60d61ee 100644 --- a/app/models/concerns/users/visitable.rb +++ b/app/models/concerns/users/visitable.rb @@ -13,6 +13,45 @@ module Users time = time.to_datetime where(entity_id: entity_id, user_id: user_id, visited_at: (time - 15.minutes)..(time + 15.minutes)) end + + scope :for_user, ->(user_id) { where(user_id: user_id) } + + scope :recently_visited, -> do + where('visited_at > ?', 3.months.ago) + .where('visited_at <= ?', Time.current) + end + + def self.grouped_by_week_start_and_entity_for_user(user_id:) + recently_visited + .for_user(user_id) + .group(:week_start, :entity_id) + .select( + :entity_id, + "COUNT(entity_id) AS week_count", + "DATE_TRUNC('week', visited_at)::date AS week_start", + "DENSE_RANK() OVER (ORDER BY DATE_TRUNC('week', visited_at)::date)" + ) + end + + def self.frecent_visits_scores(user_id:, limit:) + ranked_entity_visits_query = grouped_by_week_start_and_entity_for_user(user_id: user_id).to_sql + sql = <<~SQL + SELECT + entity_id, + SUM(week_count * dense_rank) AS score + FROM + (#{ranked_entity_visits_query}) as ranked_entity_visits + GROUP BY + entity_id + ORDER BY + score DESC + LIMIT #{limit} + SQL + + ::Gitlab::Database::LoadBalancing::Session.current.fallback_to_replicas_for_ambiguous_queries do + connection.execute(sql).to_a + end + end end end end diff --git a/app/models/container_repository.rb b/app/models/container_repository.rb index 6a52f6a0112..15ed517dc12 100644 --- a/app/models/container_repository.rb +++ b/app/models/container_repository.rb @@ -482,6 +482,24 @@ class ContainerRepository < ApplicationRecord raise 'too many pages requested' if page_count >= MAX_TAGS_PAGES end + def tags_page(before: nil, last: nil, sort: nil, name: nil, page_size: 100) + raise ArgumentError, 'not a migrated repository' unless migrated? + + page = gitlab_api_client.tags( + self.path, + page_size: page_size, + before: before, + last: last, + sort: sort, + name: name + ) + + { + tags: transform_tags_page(page[:response_body]), + pagination: page[:pagination] + } + end + def tags_count return 0 unless manifest && manifest['tags'] @@ -505,15 +523,11 @@ class ContainerRepository < ApplicationRecord digests = tags.map { |tag| tag.digest }.compact.to_set - digests.map { |digest| delete_tag_by_digest(digest) }.all? - end - - def delete_tag_by_digest(digest) - client.delete_repository_tag_by_digest(self.path, digest) + digests.map { |digest| delete_tag(digest) }.all? end - def delete_tag_by_name(name) - client.delete_repository_tag_by_name(self.path, name) + def delete_tag(name_or_digest) + client.delete_repository_tag_by_digest(self.path, name_or_digest) end def start_expiration_policy! @@ -640,6 +654,9 @@ class ContainerRepository < ApplicationRecord tag = ContainerRegistry::Tag.new(self, raw_tag['name']) tag.force_created_at_from_iso8601(raw_tag['created_at']) tag.updated_at = raw_tag['updated_at'] + tag.total_size = raw_tag['size_bytes'] + tag.manifest_digest = raw_tag['digest'] + tag.revision = raw_tag['config_digest'].to_s.split(':')[1] tag end end diff --git a/app/models/deployment.rb b/app/models/deployment.rb index 0bdce18bab5..f0093445ba8 100644 --- a/app/models/deployment.rb +++ b/app/models/deployment.rb @@ -8,12 +8,15 @@ class Deployment < ApplicationRecord include Importable include Gitlab::Utils::StrongMemoize include FastDestroyAll + include IgnorableColumns StatusUpdateError = Class.new(StandardError) StatusSyncError = Class.new(StandardError) ARCHIVABLE_OFFSET = 50_000 + ignore_column :cluster_id, remove_with: '16.8', remove_after: '2023-12-21' + belongs_to :project, optional: false belongs_to :environment, optional: false belongs_to :user diff --git a/app/models/environment.rb b/app/models/environment.rb index efdcf7174aa..4f76fae24eb 100644 --- a/app/models/environment.rb +++ b/app/models/environment.rb @@ -8,6 +8,8 @@ class Environment < ApplicationRecord include NullifyIfBlank include FromUnion + LONG_STOP = 1.week + self.reactive_cache_refresh_interval = 1.minute self.reactive_cache_lifetime = 55.seconds self.reactive_cache_hard_limit = 10.megabytes @@ -89,6 +91,7 @@ class Environment < ApplicationRecord delegate :auto_rollback_enabled?, to: :project scope :available, -> { with_state(:available) } + scope :active, -> { with_state(:available, :stopping) } scope :stopped, -> { with_state(:stopped) } scope :order_by_last_deployed_at, -> do @@ -104,6 +107,7 @@ class Environment < ApplicationRecord scope :preload_project, -> { preload(:project) } scope :auto_stoppable, -> (limit) { available.where('auto_stop_at < ?', Time.zone.now).limit(limit) } scope :auto_deletable, -> (limit) { stopped.where('auto_delete_at < ?', Time.zone.now).limit(limit) } + scope :long_stopping, -> { with_state(:stopping).where('updated_at < ?', LONG_STOP.ago) } scope :deployed_and_updated_before, -> (project_id, before) do # this query joins deployments and filters out any environment that has recent deployments @@ -322,6 +326,10 @@ class Environment < ApplicationRecord last_deployment.try(:created_at) end + def long_stopping? + stopping? && self.updated_at < LONG_STOP.ago + end + def ref_path "refs/#{Repository::REF_ENVIRONMENTS}/#{slug}" end diff --git a/app/models/group.rb b/app/models/group.rb index c83dd24e98e..51c26767569 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -300,14 +300,15 @@ class Group < Namespace groups.drop(1).each { |group| group.root_ancestor = root } end - # Returns the ids of the passed group models where the `emails_disabled` - # column is set to true anywhere in the ancestor hierarchy. + # Returns the ids of the passed group models where the `emails_enabled` + # column is set to false anywhere in the ancestor hierarchy. def ids_with_disabled_email(groups) inner_groups = Group.where('id = namespaces_with_emails_disabled.id') inner_query = inner_groups .self_and_ancestors - .where(emails_disabled: true) + .joins(:namespace_settings) + .where(namespace_settings: { emails_enabled: false }) .select('1') .limit(1) @@ -593,40 +594,13 @@ class Group < Namespace end def authorizable_members_with_parents - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_hierarchy_members = GroupMember.where(source_id: source_ids).select(*GroupMember.cached_column_list) - - GroupMember.from_union([group_hierarchy_members, - members_from_self_and_ancestor_group_shares]).authorizable + Members::MembersWithParents.new(self).all_members.authorizable end def members_with_parents(only_active_users: true) - # Avoids an unnecessary SELECT when the group has no parents - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_hierarchy_members = GroupMember.non_minimal_access - .where(source_id: source_ids) - .select(*GroupMember.cached_column_list) - - group_hierarchy_members = if only_active_users - group_hierarchy_members.active_without_invites_and_requests - else - group_hierarchy_members.without_invites_and_requests - end - - GroupMember.from_union([group_hierarchy_members, - members_from_self_and_ancestor_group_shares]) + Members::MembersWithParents + .new(self) + .members(active_users: only_active_users) end def members_from_self_and_ancestors_with_effective_access_level @@ -671,15 +645,6 @@ class Group < Namespace members.count end - # Returns all users that are members of projects - # belonging to the current group or sub-groups - def project_users_with_descendants - User - .joins(projects: :group) - .where(namespaces: { id: self_and_descendants.select(:id) }) - .allow_cross_joins_across_databases(url: "https://gitlab.com/gitlab-org/gitlab/-/issues/417455") - end - # Return the highest access level for a user # # A special case is handled here when the user is a GitLab admin @@ -996,48 +961,6 @@ class Group < Namespace errors.add(:require_two_factor_authentication, _('is forbidden by a top-level group')) end - def members_from_self_and_ancestor_group_shares - group_group_link_table = GroupGroupLink.arel_table - group_member_table = GroupMember.arel_table - - source_ids = - if has_parent? - self_and_ancestors.reorder(nil).select(:id) - else - id - end - - group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids) - cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) - cte_alias = cte.table.alias(GroupGroupLink.table_name) - - # Instead of members.access_level, we need to maximize that access_level at - # the respective group_group_links.group_access. - member_columns = GroupMember.attribute_names.map do |column_name| - if column_name == 'access_level' - smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level') - else - group_member_table[column_name] - end - end - - GroupMember - .with(cte.to_arel) - .select(*member_columns) - .from([group_member_table, cte.alias_to(group_group_link_table)]) - .where(group_member_table[:requested_at].eq(nil)) - .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) - .where(group_member_table[:source_type].eq('Namespace')) - .where(group_member_table[:state].eq(::Member::STATE_ACTIVE)) - .non_minimal_access - end - - def smallest_value_arel(args, column_alias) - Arel::Nodes::As.new( - Arel::Nodes::NamedFunction.new('LEAST', args), - Arel::Nodes::SqlLiteral.new(column_alias)) - end - def runners_token_prefix RunnersTokenPrefixable::RUNNERS_TOKEN_PREFIX end diff --git a/app/models/guest.rb b/app/models/guest.rb deleted file mode 100644 index 9c8097e1ac8..00000000000 --- a/app/models/guest.rb +++ /dev/null @@ -1,9 +0,0 @@ -# frozen_string_literal: true - -class Guest - class << self - def can?(action, subject = :global) - Ability.allowed?(nil, action, subject) - end - end -end diff --git a/app/models/integration.rb b/app/models/integration.rb index b4408301c6d..7c14c1b1716 100644 --- a/app/models/integration.rb +++ b/app/models/integration.rb @@ -237,6 +237,18 @@ class Integration < ApplicationRecord end private_class_method :boolean_accessor + def self.title + raise NotImplementedError + end + + def self.description + raise NotImplementedError + end + + def self.help + # no-op + end + def self.to_param raise NotImplementedError end @@ -447,19 +459,18 @@ class Integration < ApplicationRecord end def title - # implement inside child + self.class.title end def description - # implement inside child + self.class.description end def help - # implement inside child + self.class.help end def to_param - # implement inside child self.class.to_param end @@ -588,7 +599,7 @@ class Integration < ApplicationRecord return if ::Gitlab::SilentMode.enabled? return unless supported_events.include?(data[:object_kind]) - Integrations::ExecuteWorker.perform_async(id, data) + Integrations::ExecuteWorker.perform_async(id, data.deep_stringify_keys) end # override if needed diff --git a/app/models/integrations/apple_app_store.rb b/app/models/integrations/apple_app_store.rb index ef12fc6bf6f..f8fddf8a457 100644 --- a/app/models/integrations/apple_app_store.rb +++ b/app/models/integrations/apple_app_store.rb @@ -37,15 +37,15 @@ module Integrations title: -> { s_('AppleAppStore|Protected branches and tags only') }, checkbox_label: -> { s_('AppleAppStore|Only set variables on protected branches and tags') } - def title + def self.title 'Apple App Store Connect' end - def description + def self.description s_('AppleAppStore|Use GitLab to build and release an app in the Apple App Store.') end - def help + def self.help variable_list = [ '<code>APP_STORE_CONNECT_API_KEY_ISSUER_ID</code>', '<code>APP_STORE_CONNECT_API_KEY_KEY_ID</code>', diff --git a/app/models/integrations/asana.rb b/app/models/integrations/asana.rb index 77555996cd9..39407acd6c9 100644 --- a/app/models/integrations/asana.rb +++ b/app/models/integrations/asana.rb @@ -20,15 +20,15 @@ module Integrations title: -> { s_('Integrations|Restrict to branch (optional)') }, help: -> { s_('AsanaService|Comma-separated list of branches to be automatically inspected. Leave blank to include all branches.') } - def title + def self.title 'Asana' end - def description + def self.description s_('AsanaService|Add commit messages as comments to Asana tasks.') end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/asana'), target: '_blank', rel: 'noopener noreferrer' s_('Add commit messages as comments to Asana tasks. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/assembla.rb b/app/models/integrations/assembla.rb index 1d3616b4c3b..bbdd0e183f2 100644 --- a/app/models/integrations/assembla.rb +++ b/app/models/integrations/assembla.rb @@ -15,11 +15,11 @@ module Integrations exposes_secrets: true, placeholder: '' - def title + def self.title 'Assembla' end - def description + def self.description _('Manage projects.') end diff --git a/app/models/integrations/bamboo.rb b/app/models/integrations/bamboo.rb index 9f15532a0b0..9fe73f86be3 100644 --- a/app/models/integrations/bamboo.rb +++ b/app/models/integrations/bamboo.rb @@ -38,15 +38,15 @@ module Integrations attr_accessor :response - def title + def self.title s_('BambooService|Atlassian Bamboo') end - def description + def self.description s_('BambooService|Run CI/CD pipelines with Atlassian Bamboo.') end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bamboo'), diff --git a/app/models/integrations/base_chat_notification.rb b/app/models/integrations/base_chat_notification.rb index b75801335bd..167bc210349 100644 --- a/app/models/integrations/base_chat_notification.rb +++ b/app/models/integrations/base_chat_notification.rb @@ -136,10 +136,6 @@ module Integrations raise NotImplementedError end - def help - raise NotImplementedError - end - # With some integrations the webhook is already tied to a specific channel, # for others the channels are configurable for each event. def configurable_channels? diff --git a/app/models/integrations/base_slack_notification.rb b/app/models/integrations/base_slack_notification.rb index 09a0c9ba361..33dd9d9d387 100644 --- a/app/models/integrations/base_slack_notification.rb +++ b/app/models/integrations/base_slack_notification.rb @@ -36,7 +36,7 @@ module Integrations true end - def help + def self.help # noop end diff --git a/app/models/integrations/bugzilla.rb b/app/models/integrations/bugzilla.rb index 74e282f6848..3ca348e42a1 100644 --- a/app/models/integrations/bugzilla.rb +++ b/app/models/integrations/bugzilla.rb @@ -6,15 +6,15 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def title + def self.title 'Bugzilla' end - def description + def self.description s_("IssueTracker|Use Bugzilla as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/bugzilla'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use Bugzilla as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/buildkite.rb b/app/models/integrations/buildkite.rb index 82a5142e8c2..aab0cdf2134 100644 --- a/app/models/integrations/buildkite.rb +++ b/app/models/integrations/buildkite.rb @@ -75,20 +75,20 @@ module Integrations "#{project_url}/builds?commit=#{sha}" end - def title + def self.title 'Buildkite' end - def description + def self.description 'Run CI/CD pipelines with Buildkite.' end - def self.to_param - 'buildkite' + def self.help + s_('ProjectService|Run CI/CD pipelines with Buildkite.') end - def help - s_('ProjectService|Run CI/CD pipelines with Buildkite.') + def self.to_param + 'buildkite' end def calculate_reactive_cache(sha, ref) diff --git a/app/models/integrations/campfire.rb b/app/models/integrations/campfire.rb index 8b5797a9d24..18268ed18f4 100644 --- a/app/models/integrations/campfire.rb +++ b/app/models/integrations/campfire.rb @@ -36,15 +36,15 @@ module Integrations placeholder: '123456', help: -> { s_('CampfireService|From the end of the room URL.') } - def title + def self.title 'Campfire' end - def description + def self.description 'Send notifications about push events to Campfire chat rooms.' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('api/integrations', anchor: 'campfire'), diff --git a/app/models/integrations/clickup.rb b/app/models/integrations/clickup.rb index 7cc05d41e14..25287b53300 100644 --- a/app/models/integrations/clickup.rb +++ b/app/models/integrations/clickup.rb @@ -10,15 +10,15 @@ module Integrations @reference_pattern ||= /((#|CU-)(?<issue>[a-z0-9]+)|(?<issue>[A-Z0-9_]{2,10}-\d+))\b/ end - def title + def self.title 'ClickUp' end - def description + def self.description s_("IssueTracker|Use Clickup as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/clickup'), target: '_blank', diff --git a/app/models/integrations/confluence.rb b/app/models/integrations/confluence.rb index eda8c37fc72..f97f1fd25c9 100644 --- a/app/models/integrations/confluence.rb +++ b/app/models/integrations/confluence.rb @@ -22,11 +22,11 @@ module Integrations 'confluence' end - def title + def self.title s_('ConfluenceService|Confluence Workspace') end - def description + def self.description s_('ConfluenceService|Link to a Confluence Workspace from the sidebar.') end diff --git a/app/models/integrations/custom_issue_tracker.rb b/app/models/integrations/custom_issue_tracker.rb index 3770e813eaa..fe0d01d60bd 100644 --- a/app/models/integrations/custom_issue_tracker.rb +++ b/app/models/integrations/custom_issue_tracker.rb @@ -6,15 +6,15 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def title + def self.title s_('IssueTracker|Custom issue tracker') end - def description + def self.description s_("IssueTracker|Use a custom issue tracker as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/custom_issue_tracker'), target: '_blank', rel: 'noopener noreferrer' s_('IssueTracker|Use a custom issue tracker that is not in the integration list. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/datadog.rb b/app/models/integrations/datadog.rb index b1f1361afcd..5682fc2b139 100644 --- a/app/models/integrations/datadog.rb +++ b/app/models/integrations/datadog.rb @@ -117,15 +117,15 @@ module Integrations # archive_trace is opt-in but we handle it with a more detailed field below end - def title + def self.title 'Datadog' end - def description + def self.description s_('DatadogIntegration|Trace your GitLab pipelines with Datadog.') end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( s_('DatadogIntegration|How do I set up this integration?'), Rails.application.routes.url_helpers.help_page_url('integration/datadog'), diff --git a/app/models/integrations/discord.rb b/app/models/integrations/discord.rb index 33b2b52fa62..7ce597389f0 100644 --- a/app/models/integrations/discord.rb +++ b/app/models/integrations/discord.rb @@ -21,23 +21,23 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title s_("DiscordService|Discord Notifications") end - def description + def self.description s_("DiscordService|Send notifications about project events to a Discord channel.") end - def self.to_param - "discord" - end - - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/discord_notifications'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to a Discord channel. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end + def self.to_param + "discord" + end + def default_channel_placeholder s_('DiscordService|Override the default webhook (e.g. https://discord.com/api/webhooks/…)') end diff --git a/app/models/integrations/drone_ci.rb b/app/models/integrations/drone_ci.rb index f6a12c4bb1a..b59e504c98f 100644 --- a/app/models/integrations/drone_ci.rb +++ b/app/models/integrations/drone_ci.rb @@ -87,20 +87,20 @@ module Integrations "gitlab/#{project.full_path}/redirect/commits/#{sha}?branch=#{Addressable::URI.encode_component(ref.to_s)}") end - def title + def self.title 'Drone' end - def description + def self.description s_('ProjectService|Run CI/CD pipelines with Drone.') end - def self.to_param - 'drone_ci' + def self.help + s_('ProjectService|Run CI/CD pipelines with Drone.') end - def help - s_('ProjectService|Run CI/CD pipelines with Drone.') + def self.to_param + 'drone_ci' end override :hook_url diff --git a/app/models/integrations/emails_on_push.rb b/app/models/integrations/emails_on_push.rb index 144d1a07b04..77be8f5db45 100644 --- a/app/models/integrations/emails_on_push.rb +++ b/app/models/integrations/emails_on_push.rb @@ -39,11 +39,11 @@ module Integrations recipients.split.grep(Devise.email_regexp).uniq(&:downcase) end - def title + def self.title s_('EmailsOnPushService|Emails on push') end - def description + def self.description s_('EmailsOnPushService|Email the commits and diff of each push to a list of recipients.') end diff --git a/app/models/integrations/ewm.rb b/app/models/integrations/ewm.rb index 003c896704a..9d6f4c2a56c 100644 --- a/app/models/integrations/ewm.rb +++ b/app/models/integrations/ewm.rb @@ -10,15 +10,15 @@ module Integrations @reference_pattern ||= %r{(?<issue>\b(bug|task|work item|workitem|rtcwi|defect)\b\s+\d+)}i end - def title + def self.title 'EWM' end - def description + def self.description s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/ewm'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use IBM Engineering Workflow Management as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/external_wiki.rb b/app/models/integrations/external_wiki.rb index acacab2528e..7408f86d231 100644 --- a/app/models/integrations/external_wiki.rb +++ b/app/models/integrations/external_wiki.rb @@ -11,24 +11,24 @@ module Integrations help: -> { s_('ExternalWikiService|Enter the URL to the external wiki.') }, required: true - def title + def self.title s_('ExternalWikiService|External wiki') end - def description + def self.description s_('ExternalWikiService|Link to an external wiki from the sidebar.') end - def self.to_param - 'external_wiki' - end - - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/wiki/index', anchor: 'link-an-external-wiki'), target: '_blank', rel: 'noopener noreferrer' s_('Link an external wiki from the project\'s sidebar. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end + def self.to_param + 'external_wiki' + end + def sections [ { diff --git a/app/models/integrations/gitlab_slack_application.rb b/app/models/integrations/gitlab_slack_application.rb index 2d520eaf7e7..d008a28a226 100644 --- a/app/models/integrations/gitlab_slack_application.rb +++ b/app/models/integrations/gitlab_slack_application.rb @@ -26,11 +26,11 @@ module Integrations update(active: !!slack_integration) end - def title + def self.title s_('Integrations|GitLab for Slack app') end - def description + def self.description s_('Integrations|Enable slash commands and notifications for a Slack workspace.') end diff --git a/app/models/integrations/google_play.rb b/app/models/integrations/google_play.rb index 5389e8dfa81..746f68fdc4c 100644 --- a/app/models/integrations/google_play.rb +++ b/app/models/integrations/google_play.rb @@ -32,15 +32,15 @@ module Integrations title: -> { s_('GooglePlayStore|Protected branches and tags only') }, checkbox_label: -> { s_('GooglePlayStore|Only set variables on protected branches and tags') } - def title + def self.title s_('GooglePlay|Google Play') end - def description + def self.description s_('GooglePlay|Use GitLab to build and release an app in Google Play.') end - def help + def self.help variable_list = [ '<code>SUPPLY_PACKAGE_NAME</code>', '<code>SUPPLY_JSON_KEY_DATA</code>' diff --git a/app/models/integrations/hangouts_chat.rb b/app/models/integrations/hangouts_chat.rb index 6e4753470a3..6a9d603e6e5 100644 --- a/app/models/integrations/hangouts_chat.rb +++ b/app/models/integrations/hangouts_chat.rb @@ -17,11 +17,11 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title 'Google Chat' end - def description + def self.description 'Send notifications from GitLab to a room in Google Chat.' end @@ -29,7 +29,7 @@ module Integrations 'hangouts_chat' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to(_('How do I set up a Google Chat webhook?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/hangouts_chat'), target: '_blank', rel: 'noopener noreferrer') diff --git a/app/models/integrations/harbor.rb b/app/models/integrations/harbor.rb index 559e48afd10..cc570e49e36 100644 --- a/app/models/integrations/harbor.rb +++ b/app/models/integrations/harbor.rb @@ -32,34 +32,32 @@ module Integrations non_empty_password_help: -> { s_('HarborIntegration|Leave blank to use your current password.') }, required: true - def title + def self.title 'Harbor' end - def description + def self.description s_("HarborIntegration|Use Harbor as this project's container registry.") end - def help + def self.help s_("HarborIntegration|After the Harbor integration is activated, global variables `$HARBOR_USERNAME`, `$HARBOR_HOST`, `$HARBOR_OCI`, `$HARBOR_PASSWORD`, `$HARBOR_URL` and `$HARBOR_PROJECT` will be created for CI/CD use.") end + def self.to_param + name.demodulize.downcase + end + def hostname Gitlab::Utils.parse_url(url).hostname end - class << self - def to_param - name.demodulize.downcase - end - - def supported_events - [] - end + def self.supported_events + [] + end - def supported_event_actions - [] - end + def self.supported_event_actions + [] end def test(*_args) diff --git a/app/models/integrations/irker.rb b/app/models/integrations/irker.rb index a54946f074a..a1ce0877957 100644 --- a/app/models/integrations/irker.rb +++ b/app/models/integrations/irker.rb @@ -53,14 +53,31 @@ module Integrations # in the UI or API. prop_accessor :channels - def title + def self.title s_('IrkerService|irker (IRC gateway)') end - def description + def self.description s_('IrkerService|Send update messages to an irker server.') end + def self.help + docs_link = ActionController::Base.helpers.link_to( + _('Learn more.'), + Rails.application.routes.url_helpers.help_page_url( + 'user/project/integrations/irker', + anchor: 'set-up-an-irker-daemon' + ), + target: '_blank', + rel: 'noopener noreferrer' + ) + + format(s_( + 'IrkerService|Send update messages to an irker server. ' \ + 'Before you can use this, you need to set up the irker daemon. %{docs_link}' + ).html_safe, docs_link: docs_link.html_safe) + end + def self.to_param 'irker' end @@ -85,23 +102,6 @@ module Integrations } end - def help - docs_link = ActionController::Base.helpers.link_to( - _('Learn more.'), - Rails.application.routes.url_helpers.help_page_url( - 'user/project/integrations/irker', - anchor: 'set-up-an-irker-daemon' - ), - target: '_blank', - rel: 'noopener noreferrer' - ) - - format(s_( - 'IrkerService|Send update messages to an irker server. ' \ - 'Before you can use this, you need to set up the irker daemon. %{docs_link}' - ).html_safe, docs_link: docs_link.html_safe) - end - private def get_channels diff --git a/app/models/integrations/jenkins.rb b/app/models/integrations/jenkins.rb index 0683c8408bc..a2f5667eaee 100644 --- a/app/models/integrations/jenkins.rb +++ b/app/models/integrations/jenkins.rb @@ -69,15 +69,15 @@ module Integrations %w[push merge_request tag_push] end - def title + def self.title 'Jenkins' end - def description + def self.description s_('Run CI/CD pipelines with Jenkins.') end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('integration/jenkins'), target: '_blank', rel: 'noopener noreferrer' s_('Run CI/CD pipelines with Jenkins when you push to a repository, or when a merge request is created, updated, or merged. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/jira.rb b/app/models/integrations/jira.rb index f6e99454cb1..22367ee336d 100644 --- a/app/models/integrations/jira.rb +++ b/app/models/integrations/jira.rb @@ -184,16 +184,24 @@ module Integrations options end - def client - @client ||= JIRA::Client.new(options).tap do |client| + def client(additional_options = {}) + JIRA::Client.new(options.merge(additional_options)).tap do |client| # Replaces JIRA default http client with our implementation client.request_client = Gitlab::Jira::HttpClient.new(client.options) end end - def help + def self.title + 'Jira' + end + + def self.description + s_("JiraService|Use Jira as this project's issue tracker.") + end + + def self.help jira_doc_link_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe, - url: help_page_path('integration/jira/index')) + url: Gitlab::Routing.url_helpers.help_page_path('integration/jira/index')) format( s_("JiraService|You must configure Jira before enabling this integration. " \ "%{jira_doc_link_start}Learn more.%{link_end}"), @@ -201,14 +209,6 @@ module Integrations link_end: '</a>'.html_safe) end - def title - 'Jira' - end - - def description - s_("JiraService|Use Jira as this project's issue tracker.") - end - def self.to_param 'jira' end diff --git a/app/models/integrations/mattermost.rb b/app/models/integrations/mattermost.rb index 7e391b11d82..361ff4afce8 100644 --- a/app/models/integrations/mattermost.rb +++ b/app/models/integrations/mattermost.rb @@ -5,11 +5,11 @@ module Integrations include SlackMattermostNotifier include SlackMattermostFields - def title + def self.title _('Mattermost notifications') end - def description + def self.description s_('Send notifications about project events to Mattermost channels.') end @@ -17,7 +17,7 @@ module Integrations 'mattermost' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/mattermost'), target: '_blank', rel: 'noopener noreferrer' s_('Send notifications about project events to Mattermost channels. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/mattermost_slash_commands.rb b/app/models/integrations/mattermost_slash_commands.rb index 73cddd163e0..9554dec4168 100644 --- a/app/models/integrations/mattermost_slash_commands.rb +++ b/app/models/integrations/mattermost_slash_commands.rb @@ -14,11 +14,11 @@ module Integrations false end - def title + def self.title s_('Integrations|Mattermost slash commands') end - def description + def self.description s_('Integrations|Perform common tasks with slash commands.') end diff --git a/app/models/integrations/microsoft_teams.rb b/app/models/integrations/microsoft_teams.rb index 208172d6303..3a7c848d411 100644 --- a/app/models/integrations/microsoft_teams.rb +++ b/app/models/integrations/microsoft_teams.rb @@ -18,11 +18,11 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title 'Microsoft Teams notifications' end - def description + def self.description 'Send notifications about project events to Microsoft Teams.' end @@ -30,7 +30,7 @@ module Integrations 'microsoft_teams' end - def help + def self.help '<p>Use this service to send notifications about events in GitLab projects to your Microsoft Teams channels. <a href="https://docs.gitlab.com/ee/user/project/integrations/microsoft_teams.html" target="_blank" rel="noopener noreferrer">How do I configure this integration?</a></p>' end diff --git a/app/models/integrations/mock_ci.rb b/app/models/integrations/mock_ci.rb index 2d8e26d409f..9c129ca727c 100644 --- a/app/models/integrations/mock_ci.rb +++ b/app/models/integrations/mock_ci.rb @@ -14,11 +14,11 @@ module Integrations validates :mock_service_url, presence: true, public_url: true, if: :activated? - def title + def self.title 'MockCI' end - def description + def self.description 'Mock an external CI' end diff --git a/app/models/integrations/mock_monitoring.rb b/app/models/integrations/mock_monitoring.rb index 72bb292edaa..9e474078b28 100644 --- a/app/models/integrations/mock_monitoring.rb +++ b/app/models/integrations/mock_monitoring.rb @@ -2,11 +2,11 @@ module Integrations class MockMonitoring < BaseMonitoring - def title + def self.title 'Mock monitoring' end - def description + def self.description 'Mock monitoring service' end diff --git a/app/models/integrations/packagist.rb b/app/models/integrations/packagist.rb index c0acb6c87b4..f027afe0381 100644 --- a/app/models/integrations/packagist.rb +++ b/app/models/integrations/packagist.rb @@ -29,11 +29,11 @@ module Integrations validates :username, presence: true, if: :activated? validates :token, presence: true, if: :activated? - def title + def self.title 'Packagist' end - def description + def self.description s_('Integrations|Keep your PHP dependencies updated on Packagist.') end diff --git a/app/models/integrations/pipelines_email.rb b/app/models/integrations/pipelines_email.rb index 01efbc3e4a4..c7a93d48825 100644 --- a/app/models/integrations/pipelines_email.rb +++ b/app/models/integrations/pipelines_email.rb @@ -44,11 +44,11 @@ module Integrations end end - def title + def self.title _('Pipeline status emails') end - def description + def self.description _('Email the pipeline status to a list of recipients.') end diff --git a/app/models/integrations/pivotaltracker.rb b/app/models/integrations/pivotaltracker.rb index b3cbc988dd6..97e6e3e09d1 100644 --- a/app/models/integrations/pivotaltracker.rb +++ b/app/models/integrations/pivotaltracker.rb @@ -20,15 +20,15 @@ module Integrations 'automatically inspect. Leave blank to include all branches.') end - def title + def self.title 'Pivotal Tracker' end - def description + def self.description s_('PivotalTrackerService|Add commit messages as comments to Pivotal Tracker stories.') end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pivotal_tracker'), target: '_blank', rel: 'noopener noreferrer' s_('Add commit messages as comments to Pivotal Tracker stories. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/prometheus.rb b/app/models/integrations/prometheus.rb index ff8d07a1b4c..de923bbbdd5 100644 --- a/app/models/integrations/prometheus.rb +++ b/app/models/integrations/prometheus.rb @@ -51,11 +51,11 @@ module Integrations false end - def title + def self.title 'Prometheus' end - def description + def self.description s_('PrometheusService|Monitor application health with Prometheus metrics and dashboards') end diff --git a/app/models/integrations/pumble.rb b/app/models/integrations/pumble.rb index 09e011023ed..36ff5189b0f 100644 --- a/app/models/integrations/pumble.rb +++ b/app/models/integrations/pumble.rb @@ -18,11 +18,11 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title 'Pumble' end - def description + def self.description s_("PumbleIntegration|Send notifications about project events to Pumble.") end @@ -30,7 +30,7 @@ module Integrations 'pumble' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/pumble'), diff --git a/app/models/integrations/pushover.rb b/app/models/integrations/pushover.rb index 2feae29f627..b2c4e06e71f 100644 --- a/app/models/integrations/pushover.rb +++ b/app/models/integrations/pushover.rb @@ -71,11 +71,11 @@ module Integrations ] end - def title + def self.title 'Pushover' end - def description + def self.description s_('PushoverService|Get real-time notifications on your device.') end diff --git a/app/models/integrations/redmine.rb b/app/models/integrations/redmine.rb index bc2a64b0848..11eda7c69f7 100644 --- a/app/models/integrations/redmine.rb +++ b/app/models/integrations/redmine.rb @@ -6,15 +6,15 @@ module Integrations validates :project_url, :issues_url, :new_issue_url, presence: true, public_url: true, if: :activated? - def title + def self.title 'Redmine' end - def description + def self.description s_("IssueTracker|Use Redmine as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/redmine'), target: '_blank', rel: 'noopener noreferrer' s_('IssueTracker|Use Redmine as the issue tracker. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/shimo.rb b/app/models/integrations/shimo.rb index 227fdca5c91..1d004356469 100644 --- a/app/models/integrations/shimo.rb +++ b/app/models/integrations/shimo.rb @@ -16,11 +16,11 @@ module Integrations valid? && activated? end - def title + def self.title s_('Shimo|Shimo') end - def description + def self.description s_('Shimo|Link to a Shimo Workspace from the sidebar.') end diff --git a/app/models/integrations/slack.rb b/app/models/integrations/slack.rb index f70376e2f0d..9f9614a84fd 100644 --- a/app/models/integrations/slack.rb +++ b/app/models/integrations/slack.rb @@ -5,11 +5,11 @@ module Integrations include SlackMattermostNotifier include SlackMattermostFields - def title + def self.title 'Slack notifications' end - def description + def self.description 'Send notifications about project events to Slack.' end diff --git a/app/models/integrations/slack_slash_commands.rb b/app/models/integrations/slack_slash_commands.rb index b209f37ee7c..c5ea6f22951 100644 --- a/app/models/integrations/slack_slash_commands.rb +++ b/app/models/integrations/slack_slash_commands.rb @@ -10,11 +10,11 @@ module Integrations non_empty_password_help: -> { s_('ProjectService|Leave blank to use your current token.') }, placeholder: '' - def title + def self.title 'Slack slash commands' end - def description + def self.description "Perform common operations in Slack." end diff --git a/app/models/integrations/squash_tm.rb b/app/models/integrations/squash_tm.rb index bf3f391564f..1b4ab152b1d 100644 --- a/app/models/integrations/squash_tm.rb +++ b/app/models/integrations/squash_tm.rb @@ -22,15 +22,15 @@ module Integrations validates :token, length: { maximum: 255 }, allow_blank: true end - def title + def self.title 'Squash TM' end - def description + def self.description s_("SquashTmIntegration|Update Squash TM requirements when GitLab issues are modified.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/squash_tm'), diff --git a/app/models/integrations/teamcity.rb b/app/models/integrations/teamcity.rb index 575c3b8a334..913242ef9ac 100644 --- a/app/models/integrations/teamcity.rb +++ b/app/models/integrations/teamcity.rb @@ -47,15 +47,15 @@ module Integrations end end - def title + def self.title 'JetBrains TeamCity' end - def description + def self.description s_('ProjectService|Run CI/CD pipelines with JetBrains TeamCity.') end - def help + def self.help s_('To run CI/CD pipelines with JetBrains TeamCity, input the GitLab project details in the TeamCity project Version Control Settings.') end diff --git a/app/models/integrations/telegram.rb b/app/models/integrations/telegram.rb index 71fe6f8d6ef..8eb1a7ad0ea 100644 --- a/app/models/integrations/telegram.rb +++ b/app/models/integrations/telegram.rb @@ -38,11 +38,11 @@ module Integrations before_validation :set_webhook - def title + def self.title 'Telegram' end - def description + def self.description s_("TelegramIntegration|Send notifications about project events to Telegram.") end @@ -50,7 +50,7 @@ module Integrations 'telegram' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to( _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/telegram'), diff --git a/app/models/integrations/unify_circuit.rb b/app/models/integrations/unify_circuit.rb index 3b4bcfa28d3..6ee95c1173b 100644 --- a/app/models/integrations/unify_circuit.rb +++ b/app/models/integrations/unify_circuit.rb @@ -17,11 +17,11 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title 'Unify Circuit' end - def description + def self.description s_('Integrations|Send notifications about project events to Unify Circuit.') end @@ -29,7 +29,7 @@ module Integrations 'unify_circuit' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('How do I set up this service?'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/unify_circuit'), target: '_blank', rel: 'noopener noreferrer' s_('Integrations|Send notifications about project events to a Unify Circuit conversation. %{docs_link}').html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/webex_teams.rb b/app/models/integrations/webex_teams.rb index 3ef8ab39352..5f8cc195544 100644 --- a/app/models/integrations/webex_teams.rb +++ b/app/models/integrations/webex_teams.rb @@ -17,11 +17,11 @@ module Integrations title: -> { s_('Integrations|Branches for which notifications are to be sent') }, choices: -> { branch_choices } - def title + def self.title s_("WebexTeamsService|Webex Teams") end - def description + def self.description s_("WebexTeamsService|Send notifications about project events to Webex Teams.") end @@ -29,7 +29,7 @@ module Integrations 'webex_teams' end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/webex_teams'), target: '_blank', rel: 'noopener noreferrer' s_("WebexTeamsService|Send notifications about project events to a Webex Teams conversation. %{docs_link}") % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/youtrack.rb b/app/models/integrations/youtrack.rb index 15246a37aa7..932e588a829 100644 --- a/app/models/integrations/youtrack.rb +++ b/app/models/integrations/youtrack.rb @@ -14,15 +14,15 @@ module Integrations @reference_pattern = /(?<issue>\b[A-Za-z][A-Za-z0-9_]*-\d+\b)#{regex_suffix if only_long}/ end - def title + def self.title 'YouTrack' end - def description + def self.description s_("IssueTracker|Use YouTrack as this project's issue tracker.") end - def help + def self.help docs_link = ActionController::Base.helpers.link_to _('Learn more.'), Rails.application.routes.url_helpers.help_page_url('user/project/integrations/youtrack'), target: '_blank', rel: 'noopener noreferrer' s_("IssueTracker|Use YouTrack as this project's issue tracker. %{docs_link}").html_safe % { docs_link: docs_link.html_safe } end diff --git a/app/models/integrations/zentao.rb b/app/models/integrations/zentao.rb index 58ec4abf30c..2aec0c1e871 100644 --- a/app/models/integrations/zentao.rb +++ b/app/models/integrations/zentao.rb @@ -57,18 +57,18 @@ module Integrations data_fields.api_url ||= issues_tracker['api_url'] end - def title + def self.title 'ZenTao' end - def description + def self.description s_("ZentaoIntegration|Use ZenTao as this project's issue tracker.") end - def help + def self.help s_("ZentaoIntegration|Before you enable this integration, you must configure ZenTao. For more details, read the %{link_start}ZenTao integration documentation%{link_end}.") % { link_start: '<a href="%{url}" target="_blank" rel="noopener noreferrer">' - .html_safe % { url: help_page_url('user/project/integrations/zentao') }, + .html_safe % { url: Rails.application.routes.url_helpers.help_page_url('user/project/integrations/zentao') }, link_end: '</a>'.html_safe } end diff --git a/app/models/member.rb b/app/models/member.rb index 77e283044ea..9690e16fd7d 100644 --- a/app/models/member.rb +++ b/app/models/member.rb @@ -135,11 +135,12 @@ class Member < ApplicationRecord .reorder(nil) end - scope :without_invites_and_requests, -> do - active_state - .non_request - .non_invite - .non_minimal_access + scope :without_invites_and_requests, ->(minimal_access: false) do + result = active_state.non_request.non_invite + + result = result.non_minimal_access unless minimal_access + + result end scope :invite, -> { where.not(invite_token: nil) } diff --git a/app/models/members/members/members_with_parents.rb b/app/models/members/members/members_with_parents.rb new file mode 100644 index 00000000000..61ce99e1f3e --- /dev/null +++ b/app/models/members/members/members_with_parents.rb @@ -0,0 +1,105 @@ +# frozen_string_literal: true + +module Members + class MembersWithParents + attr_reader :group + + def initialize(group) + @group = group + end + + # Returns all members for group and parents, with no filters + def all_members + GroupMember.from_union([ + members_from_self_and_ancestors, + members_from_self_and_ancestor_group_shares + ]) + end + + # Returns members based on filter options: + # + # - `active_users`. DEPRECATED. If true, returns only members for active users + # - `minimal_access`. Used only in EE (GitLab Premium). If true, returns + # members which has minimal access. If false (default), does not return + # members with minimal access + # + # NOTE : this method does not return pending invites, nor requests. + def members(active_users: false, minimal_access: false) + raise ArgumentError, 'active_users: is deprecated' if active_users && minimal_access + + group_hierarchy_members = members_from_self_and_ancestors + + group_hierarchy_members = + if active_users + group_hierarchy_members.active_without_invites_and_requests + else + filter_invites_and_requests(group_hierarchy_members, minimal_access) + end + + GroupMember.from_union([ + group_hierarchy_members, + members_from_self_and_ancestor_group_shares + ]) + end + + private + + # NOTE: minimal access is Premium, so in FOSS we will not include minimal access member + def filter_invites_and_requests(members, _minimal_access) + members.without_invites_and_requests(minimal_access: false) + end + + def source_ids + # Avoids an unnecessary SELECT when the group has no parents + @source_ids ||= + if group.has_parent? + group.self_and_ancestors.reorder(nil).select(:id) + else + group.id + end + end + + def members_from_self_and_ancestors + GroupMember + .with_source_id(source_ids) + .select(*GroupMember.cached_column_list) + end + + def members_from_self_and_ancestor_group_shares + group_group_link_table = GroupGroupLink.arel_table + group_member_table = GroupMember.arel_table + + group_group_links_query = GroupGroupLink.where(shared_group_id: source_ids) + cte = Gitlab::SQL::CTE.new(:group_group_links_cte, group_group_links_query) + cte_alias = cte.table.alias(GroupGroupLink.table_name) + + # Instead of members.access_level, we need to maximize that access_level at + # the respective group_group_links.group_access. + member_columns = GroupMember.attribute_names.map do |column_name| + if column_name == 'access_level' + smallest_value_arel([cte_alias[:group_access], group_member_table[:access_level]], 'access_level') + else + group_member_table[column_name] + end + end + + GroupMember + .with(cte.to_arel) + .select(*member_columns) + .from([group_member_table, cte.alias_to(group_group_link_table)]) + .where(group_member_table[:requested_at].eq(nil)) + .where(group_member_table[:source_id].eq(group_group_link_table[:shared_with_group_id])) + .where(group_member_table[:source_type].eq('Namespace')) + .where(group_member_table[:state].eq(::Member::STATE_ACTIVE)) + .non_minimal_access + end + + def smallest_value_arel(args, column_alias) + Arel::Nodes::As.new( + Arel::Nodes::NamedFunction.new('LEAST', args), + Arel::Nodes::SqlLiteral.new(column_alias)) + end + end +end + +Members::MembersWithParents.prepend_mod diff --git a/app/models/members/project_member.rb b/app/models/members/project_member.rb index d07e4f9e298..5e5f9ab7385 100644 --- a/app/models/members/project_member.rb +++ b/app/models/members/project_member.rb @@ -48,6 +48,12 @@ class ProjectMember < Member end end + def permissible_access_level_roles_for_project_access_token(current_user, project) + permissible_access_level_roles(current_user, project).filter do |_, value| + value <= project.project_authorizations.find_by(user: current_user).access_level + end + end + def access_level_roles Gitlab::Access.options end diff --git a/app/models/merge_request.rb b/app/models/merge_request.rb index d9726e76c4b..524a9b8074b 100644 --- a/app/models/merge_request.rb +++ b/app/models/merge_request.rb @@ -384,7 +384,6 @@ class MergeRequest < ApplicationRecord } scope :with_csv_entity_associations, -> { preload(:assignees, :approved_by_users, :author, :milestone, metrics: [:merged_by]) } - scope :with_jira_integration_associations, -> { preload_routables.preload(:metrics, :assignees, :author) } scope :recently_unprepared, -> { where(prepared_at: nil).where(created_at: 2.hours.ago..).order(:created_at, :id) } # id is the tie-breaker scope :by_target_branch_wildcard, ->(wildcard_branch_name) do @@ -530,6 +529,14 @@ class MergeRequest < ApplicationRecord .pluck(:target_branch) end + def self.recent_source_branches(limit: 100) + group(:source_branch) + .select(:source_branch) + .reorder(arel_table[:updated_at].maximum.desc) + .limit(limit) + .pluck(:source_branch) + end + def self.sort_by_attribute(method, excluded_labels: []) case method.to_s when 'merged_at', 'merged_at_asc' then order_merged_at_asc @@ -1235,17 +1242,14 @@ class MergeRequest < ApplicationRecord } end - def mergeable?( - skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, check_mergeability_retry_lease: false, - skip_draft_check: false, skip_rebase_check: false, skip_blocked_check: false) - - return false unless mergeable_state?( - skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check, - skip_draft_check: skip_draft_check, - skip_approved_check: skip_approved_check, - skip_blocked_check: skip_blocked_check - ) + # mergeable_state_check_params allows a hash of merge checks to skip or not + # skip_ci_check + # skip_discussions_check + # skip_draft_check + # skip_approved_check + # skip_blocked_check + def mergeable?(check_mergeability_retry_lease: false, skip_rebase_check: false, **mergeable_state_check_params) + return false unless mergeable_state?(**mergeable_state_check_params) check_mergeability(sync_retry_lease: check_mergeability_retry_lease) mergeable_git_state?(skip_rebase_check: skip_rebase_check) @@ -1275,18 +1279,16 @@ class MergeRequest < ApplicationRecord mergeable_state_checks + mergeable_git_state_checks end - def mergeable_state?( - skip_ci_check: false, skip_discussions_check: false, skip_approved_check: false, - skip_draft_check: false, skip_blocked_check: false) + # mergeable_state_check_params allows a hash of merge checks to skip or not + # skip_ci_check + # skip_discussions_check + # skip_draft_check + # skip_approved_check + # skip_blocked_check + def mergeable_state?(**mergeable_state_check_params) additional_checks = execute_merge_checks( self.class.mergeable_state_checks, - params: { - skip_ci_check: skip_ci_check, - skip_discussions_check: skip_discussions_check, - skip_approved_check: skip_approved_check, - skip_draft_check: skip_draft_check, - skip_blocked_check: skip_blocked_check - } + params: mergeable_state_check_params ) additional_checks.success? end @@ -1386,7 +1388,7 @@ class MergeRequest < ApplicationRecord end def mergeable_discussions_state? - return true unless project.only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: true) + return true unless only_allow_merge_if_all_discussions_are_resolved? unresolved_notes.none?(&:to_be_resolved?) end @@ -1566,8 +1568,16 @@ class MergeRequest < ApplicationRecord access.can_push_to_branch?(target_branch) end + def only_allow_merge_if_pipeline_succeeds? + project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) + end + + def only_allow_merge_if_all_discussions_are_resolved? + project.only_allow_merge_if_all_discussions_are_resolved?(inherit_group_setting: true) + end + def mergeable_ci_state? - return true unless project.only_allow_merge_if_pipeline_succeeds?(inherit_group_setting: true) + return true unless only_allow_merge_if_pipeline_succeeds? return false unless actual_head_pipeline return true if project.allow_merge_on_skipped_pipeline?(inherit_group_setting: true) && actual_head_pipeline.skipped? diff --git a/app/models/merge_request_context_commit_diff_file.rb b/app/models/merge_request_context_commit_diff_file.rb index fdf57068928..2fb995ee512 100644 --- a/app/models/merge_request_context_commit_diff_file.rb +++ b/app/models/merge_request_context_commit_diff_file.rb @@ -10,7 +10,6 @@ class MergeRequestContextCommitDiffFile < ApplicationRecord belongs_to :merge_request_context_commit, inverse_of: :diff_files sha_attribute :sha - alias_attribute :id, :sha # create MergeRequestContextCommitDiffFile by given diff file record(s) def self.bulk_insert(*args) diff --git a/app/models/merge_request_diff_commit.rb b/app/models/merge_request_diff_commit.rb index fc08dd4d9c8..790520c4123 100644 --- a/app/models/merge_request_diff_commit.rb +++ b/app/models/merge_request_diff_commit.rb @@ -6,13 +6,8 @@ class MergeRequestDiffCommit < ApplicationRecord include BulkInsertSafe include ShaAttribute include CachedCommit - include IgnorableColumns include FromUnion - ignore_column %i[author_name author_email committer_name committer_email], - remove_with: '14.6', - remove_after: '2021-11-22' - belongs_to :merge_request_diff # This relation is called `commit_author` and not `author`, as the project @@ -33,7 +28,6 @@ class MergeRequestDiffCommit < ApplicationRecord belongs_to :committer, class_name: 'MergeRequest::DiffCommitUser' sha_attribute :sha - alias_attribute :id, :sha attribute :trailers, :ind_jsonb validates :trailers, json_schema: { filename: 'git_trailers' } @@ -129,4 +123,8 @@ class MergeRequestDiffCommit < ApplicationRecord def committer_email committer&.email end + + def to_hash + super.merge({ 'id' => sha }) + end end diff --git a/app/models/ml/candidate.rb b/app/models/ml/candidate.rb index 6f4728a1d98..70eaab8c0ab 100644 --- a/app/models/ml/candidate.rb +++ b/app/models/ml/candidate.rb @@ -12,12 +12,14 @@ module Ml validates :eid, :experiment, presence: true validates :status, inclusion: { in: statuses.keys } + validates :model_version_id, uniqueness: { allow_nil: true } belongs_to :experiment, class_name: 'Ml::Experiment' belongs_to :user belongs_to :package, class_name: 'Packages::Package' belongs_to :project belongs_to :ci_build, class_name: 'Ci::Build', optional: true + belongs_to :model_version, class_name: 'Ml::ModelVersion', optional: true, inverse_of: :candidate has_many :metrics, class_name: 'Ml::CandidateMetric' has_many :params, class_name: 'Ml::CandidateParam' has_many :metadata, class_name: 'Ml::CandidateMetadata' diff --git a/app/models/ml/model.rb b/app/models/ml/model.rb index 27f03ed5857..b6f7e9a0639 100644 --- a/app/models/ml/model.rb +++ b/app/models/ml/model.rb @@ -3,6 +3,7 @@ module Ml class Model < ApplicationRecord include Presentable + include Sortable validates :project, :default_experiment, presence: true validates :name, @@ -15,15 +16,19 @@ module Ml has_one :default_experiment, class_name: 'Ml::Experiment' belongs_to :project + belongs_to :user has_many :versions, class_name: 'Ml::ModelVersion' + has_many :metadata, class_name: 'Ml::ModelMetadata' has_one :latest_version, -> { latest_by_model }, class_name: 'Ml::ModelVersion', inverse_of: :model scope :including_latest_version, -> { includes(:latest_version) } + scope :including_project, -> { includes(:project) } scope :with_version_count, -> { left_outer_joins(:versions) .select("ml_models.*, count(ml_model_versions.id) as version_count") .group(:id) } + scope :by_name, ->(name) { where("ml_models.name LIKE ?", "%#{sanitize_sql_like(name)}%") } # rubocop:disable GitlabSecurity/SqlInjection scope :by_project, ->(project) { where(project_id: project.id) } def valid_default_experiment? @@ -33,13 +38,12 @@ module Ml errors.add(:default_experiment) unless default_experiment.project_id == project_id end - def self.find_or_create(project, name, experiment) - create_with(default_experiment: experiment) - .find_or_create_by(project: project, name: name) - end - def self.by_project_id_and_id(project_id, id) find_by(project_id: project_id, id: id) end + + def self.by_project_id_and_name(project_id, name) + find_by(project_id: project_id, name: name) + end end end diff --git a/app/models/ml/model_metadata.rb b/app/models/ml/model_metadata.rb new file mode 100644 index 00000000000..9c4273c629c --- /dev/null +++ b/app/models/ml/model_metadata.rb @@ -0,0 +1,13 @@ +# frozen_string_literal: true + +module Ml + class ModelMetadata < ApplicationRecord + validates :name, + length: { maximum: 250 }, + presence: true, + uniqueness: { scope: :model, message: ->(metadata, _) { "'#{metadata.name}' already taken" } } + validates :value, length: { maximum: 5000 }, presence: true + + belongs_to :model, class_name: 'Ml::Model', optional: false + end +end diff --git a/app/models/ml/model_version.rb b/app/models/ml/model_version.rb index e7fcde2cb5c..58da57f27d6 100644 --- a/app/models/ml/model_version.rb +++ b/app/models/ml/model_version.rb @@ -2,6 +2,8 @@ module Ml class ModelVersion < ApplicationRecord + include Presentable + validates :project, :model, presence: true validates :version, @@ -10,11 +12,15 @@ module Ml presence: true, length: { maximum: 255 } + validates :description, + length: { maximum: 500 } + validate :valid_model?, :valid_package? belongs_to :model, class_name: 'Ml::Model' belongs_to :project belongs_to :package, class_name: 'Packages::MlModel::Package', optional: true + has_one :candidate, class_name: 'Ml::Candidate' delegate :name, to: :model @@ -22,8 +28,17 @@ module Ml scope :latest_by_model, -> { order_by_model_id_id_desc.select('DISTINCT ON (model_id) *') } class << self - def find_or_create!(model, version, package) - create_with(package: package).find_or_create_by!(project: model.project, model: model, version: version) + def find_or_create!(model, version, package, description) + create_with(package: package, description: description) + .find_or_create_by!(project: model.project, model: model, version: version) + end + + def by_project_id_and_id(project_id, id) + find_by(project_id: project_id, id: id) + end + + def by_project_id_name_and_version(project_id, name, version) + joins(:model).find_by(model: { name: name, project_id: project_id }, project_id: project_id, version: version) end end diff --git a/app/models/namespace.rb b/app/models/namespace.rb index 733b89fcaf2..cd54ac1b24a 100644 --- a/app/models/namespace.rb +++ b/app/models/namespace.rb @@ -18,6 +18,7 @@ class Namespace < ApplicationRecord include Referable include CrossDatabaseIgnoredTables include IgnorableColumns + include UseSqlFunctionForPrimaryKeyLookups ignore_column :unlock_membership_to_ldap, remove_with: '16.7', remove_after: '2023-11-16' @@ -138,6 +139,8 @@ class Namespace < ApplicationRecord to: :namespace_settings delegate :runner_registration_enabled, :runner_registration_enabled?, :runner_registration_enabled=, to: :namespace_settings + delegate :emails_enabled, :emails_enabled=, + to: :namespace_settings, allow_nil: true delegate :allow_runner_registration_token, :allow_runner_registration_token=, to: :namespace_settings @@ -204,7 +207,7 @@ class Namespace < ApplicationRecord # Make sure that the name is same as strong_memoize name in root_ancestor # method - attr_writer :root_ancestor, :emails_disabled_memoized + attr_writer :root_ancestor, :emails_enabled_memoized class << self def sti_class_for(type_name) @@ -299,6 +302,14 @@ class Namespace < ApplicationRecord super || Gitlab::CurrentSettings.default_branch_protection end + def default_branch_protection_settings + settings = default_branch_protection_defaults + + return settings unless settings.blank? + + Gitlab::CurrentSettings.default_branch_protection_defaults + end + def visibility_level_field :visibility_level end @@ -382,17 +393,16 @@ class Namespace < ApplicationRecord # any ancestor can disable emails for all descendants def emails_disabled? - strong_memoize(:emails_disabled_memoized) do - if parent_id - self_and_ancestors.where(emails_disabled: true).exists? - else - !!emails_disabled - end - end + !emails_enabled? end def emails_enabled? - !emails_disabled? + # If no namespace_settings, we can assume it has not changed from enabled + return true unless namespace_settings + + strong_memoize(:emails_enabled_memoized) do + namespace_settings.emails_enabled? + end end def lfs_enabled? @@ -626,8 +636,7 @@ class Namespace < ApplicationRecord :route, :project_setting, :project_feature, - pages_metadatum: :pages_deployment - ) + :active_pages_deployments) end private diff --git a/app/models/namespace_setting.rb b/app/models/namespace_setting.rb index 3befcdeaec5..13d2c5a62e2 100644 --- a/app/models/namespace_setting.rb +++ b/app/models/namespace_setting.rb @@ -63,6 +63,12 @@ class NamespaceSetting < ApplicationRecord namespace.root_ancestor.prevent_sharing_groups_outside_hierarchy end + def emails_enabled? + return emails_enabled unless namespace.has_parent? + + all_ancestors_have_emails_enabled? + end + def show_diff_preview_in_email? return show_diff_preview_in_email unless namespace.has_parent? @@ -89,6 +95,10 @@ class NamespaceSetting < ApplicationRecord private + def all_ancestors_have_emails_enabled? + self.class.where(namespace_id: namespace.self_and_ancestors, emails_enabled: false).none? + end + def all_ancestors_allow_diff_preview_in_email? !self.class.where(namespace_id: namespace.self_and_ancestors, show_diff_preview_in_email: false).exists? end diff --git a/app/models/network/graph.rb b/app/models/network/graph.rb index 0f410d4810d..f60e7682418 100644 --- a/app/models/network/graph.rb +++ b/app/models/network/graph.rb @@ -2,7 +2,7 @@ module Network class Graph - attr_reader :days, :commits, :map, :notes, :repo + attr_reader :days, :commits, :map, :repo def self.max_count @max_count ||= 650 @@ -17,28 +17,10 @@ module Network @commits = collect_commits @days = index_commits - @notes = collect_notes end protected - def collect_notes - return {} if Feature.enabled?(:disable_network_graph_notes_count, @project, type: :experiment) - - h = Hash.new(0) - - @project - .notes - .where(noteable_type: 'Commit') - .group('notes.commit_id') - .select('notes.commit_id, count(notes.id) as note_count') - .each do |item| - h[item.commit_id] = item.note_count.to_i - end - - h - end - # Get commits from repository # def collect_commits diff --git a/app/models/note.rb b/app/models/note.rb index eae7a40fb4e..6f4a56dd3cc 100644 --- a/app/models/note.rb +++ b/app/models/note.rb @@ -383,7 +383,11 @@ class Note < ApplicationRecord end def for_project_noteable? - !(for_personal_snippet? || for_abuse_report?) + !(for_personal_snippet? || for_abuse_report? || group_level_issue?) + end + + def group_level_issue? + (for_issue? || for_work_item?) && noteable&.project_id.blank? end def for_design? diff --git a/app/models/organizations/organization.rb b/app/models/organizations/organization.rb index 893b08d7872..157b851e009 100644 --- a/app/models/organizations/organization.rb +++ b/app/models/organizations/organization.rb @@ -42,6 +42,10 @@ module Organizations organization_users.exists?(user: user) end + def web_url(only_path: nil) + Gitlab::UrlBuilder.build(self, only_path: only_path) + end + private def check_if_default_organization diff --git a/app/models/packages/npm/metadata_cache.rb b/app/models/packages/npm/metadata_cache.rb index 02efeda69cb..b6ab2a88a98 100644 --- a/app/models/packages/npm/metadata_cache.rb +++ b/app/models/packages/npm/metadata_cache.rb @@ -5,6 +5,9 @@ module Packages class MetadataCache < ApplicationRecord include FileStoreMounter include Packages::Downloadable + include Packages::Destructible + + enum status: { default: 0, processing: 1, error: 3 } belongs_to :project, inverse_of: :npm_metadata_caches @@ -18,6 +21,9 @@ module Packages before_validation :set_object_storage_key attr_readonly :object_storage_key + scope :stale, -> { where(project_id: nil) } + scope :pending_destruction, -> { stale.default } + def self.find_or_build(package_name:, project_id:) find_or_initialize_by( package_name: package_name, diff --git a/app/models/packages/nuget/symbol.rb b/app/models/packages/nuget/symbol.rb index 643b5552d84..3315f11b974 100644 --- a/app/models/packages/nuget/symbol.rb +++ b/app/models/packages/nuget/symbol.rb @@ -4,6 +4,7 @@ module Packages module Nuget class Symbol < ApplicationRecord include FileStoreMounter + include ShaAttribute belongs_to :package, -> { where(package_type: :nuget) }, inverse_of: :nuget_symbols @@ -13,6 +14,8 @@ module Packages validates :signature, uniqueness: { scope: :file_path } validates :object_storage_key, uniqueness: true + sha256_attribute :file_sha256 + mount_file_store_uploader SymbolUploader before_validation :set_object_storage_key, on: :create diff --git a/app/models/packages/protection/rule.rb b/app/models/packages/protection/rule.rb index 582b51475c2..f13bcc6e32e 100644 --- a/app/models/packages/protection/rule.rb +++ b/app/models/packages/protection/rule.rb @@ -12,6 +12,12 @@ module Packages validates :package_name_pattern, presence: true, uniqueness: { scope: [:project_id, :package_type] }, length: { maximum: 255 } + validates :package_name_pattern, + format: { + with: Gitlab::Regex.protection_rules_npm_package_name_pattern_regex, + message: ->(_object, _data) { _('should be a valid NPM package name with optional wildcard characters.') } + }, + if: :npm? validates :package_type, presence: true validates :push_protected_up_to_access_level, presence: true @@ -20,7 +26,7 @@ module Packages scope :for_package_name, ->(package_name) { return none if package_name.blank? - where(":package_name ILIKE package_name_pattern_ilike_query", package_name: package_name) + where(':package_name ILIKE package_name_pattern_ilike_query', package_name: package_name) } def self.push_protected_from?(access_level:, package_name:, package_type:) diff --git a/app/models/packages/pypi/metadatum.rb b/app/models/packages/pypi/metadatum.rb index ff247fedb59..f7360409507 100644 --- a/app/models/packages/pypi/metadatum.rb +++ b/app/models/packages/pypi/metadatum.rb @@ -3,10 +3,24 @@ class Packages::Pypi::Metadatum < ApplicationRecord self.primary_key = :package_id + MAX_REQUIRED_PYTHON_LENGTH = 255 + MAX_KEYWORDS_LENGTH = 255 + MAX_METADATA_VERSION_LENGTH = 16 + MAX_AUTHOR_EMAIL_LENGTH = 2048 + MAX_SUMMARY_LENGTH = 255 + MAX_DESCRIPTION_LENGTH = 4000 + MAX_DESCRIPTION_CONTENT_TYPE = 128 + belongs_to :package, -> { where(package_type: :pypi) }, inverse_of: :pypi_metadatum validates :package, presence: true - validates :required_python, length: { maximum: 255 }, allow_nil: false + validates :required_python, length: { maximum: MAX_REQUIRED_PYTHON_LENGTH }, allow_nil: false + validates :keywords, length: { maximum: MAX_KEYWORDS_LENGTH }, allow_nil: true + validates :metadata_version, length: { maximum: MAX_METADATA_VERSION_LENGTH }, allow_nil: true + validates :author_email, length: { maximum: MAX_AUTHOR_EMAIL_LENGTH }, allow_nil: true + validates :summary, length: { maximum: MAX_SUMMARY_LENGTH }, allow_nil: true + validates :description, length: { maximum: MAX_DESCRIPTION_LENGTH }, allow_nil: true + validates :description_content_type, length: { maximum: MAX_DESCRIPTION_CONTENT_TYPE }, allow_nil: true validate :pypi_package_type diff --git a/app/models/packages/tag.rb b/app/models/packages/tag.rb index 9c17a147bf4..0df64bfba54 100644 --- a/app/models/packages/tag.rb +++ b/app/models/packages/tag.rb @@ -1,9 +1,12 @@ # frozen_string_literal: true class Packages::Tag < ApplicationRecord belongs_to :package, inverse_of: :tags + belongs_to :project validates :package, :name, presence: true + before_save :ensure_project_id + FOR_PACKAGES_TAGS_LIMIT = 200 NUGET_TAGS_SEPARATOR = ' ' # https://docs.microsoft.com/en-us/nuget/reference/nuspec#tags @@ -15,4 +18,8 @@ class Packages::Tag < ApplicationRecord .order(updated_at: :desc) .limit(FOR_PACKAGES_TAGS_LIMIT) end + + def ensure_project_id + self.project_id ||= package.project_id + end end diff --git a/app/models/pages/lookup_path.rb b/app/models/pages/lookup_path.rb index 8a02415aef4..e5e23c3bb84 100644 --- a/app/models/pages/lookup_path.rb +++ b/app/models/pages/lookup_path.rb @@ -4,8 +4,6 @@ module Pages class LookupPath include Gitlab::Utils::StrongMemoize - LegacyStorageDisabledError = Class.new(::StandardError) - def initialize(project, trim_prefix: nil, domain: nil) @project = project @domain = domain @@ -15,6 +13,7 @@ module Pages def project_id project.id end + strong_memoize_attr :project_id def access_control project.private_pages? @@ -76,8 +75,15 @@ module Pages attr_reader :project, :trim_prefix, :domain + # project.active_pages_deployments is already loaded from the database, + # so selecting from the array to avoid N+1 + # this will change with when serving multiple versions on + # https://gitlab.com/gitlab-org/gitlab/-/merge_requests/133261 def deployment - project.pages_metadatum.pages_deployment + project + .active_pages_deployments + .to_a + .find { |deployment| deployment.path_prefix.blank? } end strong_memoize_attr :deployment diff --git a/app/models/pages_deployment.rb b/app/models/pages_deployment.rb index f05ed2aac6e..2aa36a94171 100644 --- a/app/models/pages_deployment.rb +++ b/app/models/pages_deployment.rb @@ -17,7 +17,8 @@ class PagesDeployment < ApplicationRecord scope :with_files_stored_locally, -> { where(file_store: ::ObjectStorage::Store::LOCAL) } scope :with_files_stored_remotely, -> { where(file_store: ::ObjectStorage::Store::REMOTE) } scope :project_id_in, ->(ids) { where(project_id: ids) } - scope :active, -> { where(deleted_at: nil) } + scope :with_path_prefix, ->(prefix) { where("COALESCE(path_prefix, '') = ?", prefix.to_s) } + scope :active, -> { where(deleted_at: nil).order(created_at: :desc) } scope :deactivated, -> { where('deleted_at < ?', Time.now.utc) } validates :file, presence: true @@ -33,11 +34,23 @@ class PagesDeployment < ApplicationRecord skip_callback :save, :after, :store_file! after_commit :store_file_after_commit!, on: [:create, :update] + def self.latest_pipeline_id + Ci::Build.id_in(pluck(:ci_build_id)).maximum(:commit_id) + end + + def self.deactivate_all(project) + now = Time.now.utc + active + .project_id_in(project.id) + .update_all(updated_at: now, deleted_at: now) + end + def self.deactivate_deployments_older_than(deployment, time: nil) now = Time.now.utc active .older_than(deployment.id) - .where(project_id: deployment.project_id, path_prefix: deployment.path_prefix) + .project_id_in(deployment.project_id) + .with_path_prefix(deployment.path_prefix) .update_all(updated_at: now, deleted_at: time || now) end diff --git a/app/models/pages_domain.rb b/app/models/pages_domain.rb index b86bc761cc1..cabd3924fd6 100644 --- a/app/models/pages_domain.rb +++ b/app/models/pages_domain.rb @@ -11,6 +11,8 @@ class PagesDomain < ApplicationRecord MAX_CERTIFICATE_KEY_LENGTH = 8192 + X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN = 19 + enum certificate_source: { user_provided: 0, gitlab_provided: 1 }, _prefix: :certificate enum scope: { instance: 0, group: 1, project: 2 }, _prefix: :scope, _default: :project enum usage: { pages: 0, serverless: 1 }, _prefix: :usage, _default: :pages @@ -122,15 +124,23 @@ class PagesDomain < ApplicationRecord x509.check_private_key(pkey) end - def has_intermediates? + def has_valid_intermediates? return false unless x509 - # self-signed certificates doesn't have the certificate chain + # self-signed certificates don't have the certificate chain return true if x509.verify(x509.public_key) store = OpenSSL::X509::Store.new store.set_default_paths + store.verify_callback = ->(is_valid, store_ctx) { + # allow self signed certs, see https://gitlab.com/gitlab-org/gitlab/-/issues/356447 + return true if store_ctx.error == X509_V_ERR_SELF_SIGNED_CERT_IN_CHAIN + + self.errors.add(:certificate, store_ctx.error_string) unless is_valid + is_valid + } + store.verify(x509, untrusted_ca_certs_bundle) rescue OpenSSL::X509::StoreError false @@ -230,9 +240,7 @@ class PagesDomain < ApplicationRecord end def pages_deployed? - return false unless project - - project.pages_metadatum&.deployed? + project&.pages_deployed? end private @@ -260,9 +268,7 @@ class PagesDomain < ApplicationRecord end def validate_intermediates - unless has_intermediates? - self.errors.add(:certificate, 'misses intermediates') - end + self.errors.add(:certificate, 'misses intermediates') unless has_valid_intermediates? end def validate_pages_domain diff --git a/app/models/personal_access_token.rb b/app/models/personal_access_token.rb index 4dfe7252a0c..f2fbb5b989e 100644 --- a/app/models/personal_access_token.rb +++ b/app/models/personal_access_token.rb @@ -44,8 +44,9 @@ class PersonalAccessToken < ApplicationRecord scope :last_used_after, -> (date) { where("last_used_at >= ?", date) } validates :scopes, presence: true + validates :expires_at, presence: true, on: :create, unless: :allow_expires_at_to_be_empty? + validate :validate_scopes - validates :expires_at, presence: true, on: :create validate :expires_at_before_instance_max_expiry_date, on: :create def revoke! @@ -97,6 +98,10 @@ class PersonalAccessToken < ApplicationRecord self.class.token_prefix end + def allow_expires_at_to_be_empty? + false + end + def expires_at_before_instance_max_expiry_date return unless expires_at diff --git a/app/models/project.rb b/app/models/project.rb index fd226d23e77..0d103094aec 100644 --- a/app/models/project.rb +++ b/app/models/project.rb @@ -45,6 +45,7 @@ class Project < ApplicationRecord include UpdatedAtFilterable include IgnorableColumns include CrossDatabaseIgnoredTables + include UseSqlFunctionForPrimaryKeyLookups ignore_column :emails_disabled, remove_with: '16.3', remove_after: '2023-08-22' @@ -140,8 +141,14 @@ class Project < ApplicationRecord after_create -> { create_or_load_association(:pages_metadatum) } after_create :set_timestamps_for_create after_create :check_repository_absence! + + # TODO: Remove this callback after background syncing is implemented. See https://gitlab.com/gitlab-org/gitlab/-/issues/429376. + after_update :update_catalog_resource, + if: -> { (saved_change_to_name? || saved_change_to_description? || saved_change_to_visibility_level?) && catalog_resource } + before_destroy :remove_private_deploy_keys after_destroy :remove_exports + after_save :update_project_statistics, if: :saved_change_to_namespace_id? after_save :schedule_sync_event_worker, if: -> { saved_change_to_id? || saved_change_to_namespace_id? } @@ -457,8 +464,10 @@ class Project < ApplicationRecord # GitLab Pages has_many :pages_domains has_one :pages_metadatum, class_name: 'ProjectPagesMetadatum', inverse_of: :project - # we need to clean up files, not only remove records - has_many :pages_deployments, dependent: :destroy # rubocop:disable Cop/ActiveRecordDependent + # rubocop:disable Cop/ActiveRecordDependent -- we need to clean up files, not only remove records + has_many :pages_deployments, dependent: :destroy, inverse_of: :project + # rubocop:enable Cop/ActiveRecordDependent + has_many :active_pages_deployments, -> { active }, class_name: 'PagesDeployment', inverse_of: :project # Can be too many records. We need to implement delete_all in batches. # Issue https://gitlab.com/gitlab-org/gitlab/-/issues/228637 @@ -497,7 +506,7 @@ class Project < ApplicationRecord delegate :merge_requests_access_level, :forking_access_level, :issues_access_level, :wiki_access_level, :snippets_access_level, :builds_access_level, :repository_access_level, :package_registry_access_level, :pages_access_level, :metrics_dashboard_access_level, :analytics_access_level, :operations_access_level, :security_and_compliance_access_level, :container_registry_access_level, :environments_access_level, :feature_flags_access_level, :monitor_access_level, :releases_access_level, :infrastructure_access_level, :model_experiments_access_level, to: :project_feature, allow_nil: true delegate :name, to: :owner, allow_nil: true, prefix: true - delegate :log_jira_dvcs_integration_usage, :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage + delegate :jira_dvcs_server_last_sync_at, :jira_dvcs_cloud_last_sync_at, to: :feature_usage delegate :last_pipeline, to: :commit, allow_nil: true with_options to: :team do @@ -620,42 +629,6 @@ class Project < ApplicationRecord .or(arel_table[:storage_version].eq(nil))) end - scope :sorted_by_name_desc, -> { - keyset_order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: :name, - column_expression: Project.arel_table[:name], - order_expression: Project.arel_table[:name].desc, - distinct: false, - nullable: :nulls_last - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: :id, - order_expression: Project.arel_table[:id].desc - ) - ]) - - reorder(keyset_order) - } - - scope :sorted_by_name_asc, -> { - keyset_order = Gitlab::Pagination::Keyset::Order.build([ - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: :name, - column_expression: Project.arel_table[:name], - order_expression: Project.arel_table[:name].asc, - distinct: false, - nullable: :nulls_last - ), - Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( - attribute_name: :id, - order_expression: Project.arel_table[:id].asc - ) - ]) - - reorder(keyset_order) - } - scope :sorted_by_updated_asc, -> { reorder(self.arel_table['updated_at'].asc) } scope :sorted_by_updated_desc, -> { reorder(self.arel_table['updated_at'].desc) } scope :sorted_by_stars_desc, -> { reorder(self.arel_table['star_count'].desc) } @@ -769,7 +742,7 @@ class Project < ApplicationRecord end scope :with_pages_deployed, -> do - joins(:pages_metadatum).merge(ProjectPagesMetadatum.deployed) + where_exists(PagesDeployment.active.where('pages_deployments.project_id = projects.id')) end scope :pages_metadata_not_migrated, -> do @@ -1476,12 +1449,10 @@ class Project < ApplicationRecord end def build_or_assign_import_data(data: nil, credentials: nil) - return if data.nil? && credentials.nil? - project_import_data = import_data || build_import_data - project_import_data.merge_data(data.to_h) - project_import_data.merge_credentials(credentials.to_h) + project_import_data.merge_data(data.to_h) if data + project_import_data.merge_credentials(credentials.to_h) if credentials project_import_data end @@ -1564,9 +1535,9 @@ class Project < ApplicationRecord limit = creator.projects_limit error = if limit == 0 - _('Personal project creation is not allowed. Please contact your administrator with questions') + _('You cannot create projects in your personal namespace. Contact your GitLab administrator.') else - _('Your project limit is %{limit} projects! Please contact your administrator to increase it') + _("You've reached your limit of %{limit} projects created. Contact your GitLab administrator.") end self.errors.add(:limit_reached, error % { limit: limit }) @@ -2236,11 +2207,11 @@ class Project < ApplicationRecord end def pages_deployed? - pages_metadatum&.deployed? + active_pages_deployments.exists? end def pages_show_onboarding? - !(pages_metadatum&.onboarding_complete || pages_metadatum&.deployed) + !(pages_metadatum&.onboarding_complete || pages_deployed?) end def remove_private_deploy_keys @@ -2262,27 +2233,6 @@ class Project < ApplicationRecord ensure_pages_metadatum.update!(onboarding_complete: true) end - def mark_pages_as_deployed - ensure_pages_metadatum.update!(deployed: true) - end - - def mark_pages_as_not_deployed - ensure_pages_metadatum.update!(deployed: false) - end - - def update_pages_deployment!(deployment) - ensure_pages_metadatum.update!(pages_deployment: deployment) - end - - def set_first_pages_deployment!(deployment) - ensure_pages_metadatum - - # where().update_all to perform update in the single transaction with check for null - ProjectPagesMetadatum - .where(project_id: id, pages_deployment_id: nil) - .update_all(deployed: deployment.present?, pages_deployment_id: deployment&.id) - end - def set_full_path(gl_full_path: full_path) # We'd need to keep track of project full path otherwise directory tree # created with hashed storage enabled cannot be usefully imported using @@ -2875,7 +2825,7 @@ class Project < ApplicationRecord end def uses_default_ci_config? - ci_config_path.blank? || ci_config_path == Gitlab::FileDetector::PATTERNS[:gitlab_ci] + ci_config_path.blank? || Gitlab::FileDetector.type_of(ci_config_path) == :gitlab_ci end def limited_protected_branches(limit) @@ -3026,7 +2976,7 @@ class Project < ApplicationRecord end def ci_config_for(sha) - repository.gitlab_ci_yml_for(sha, ci_config_path_or_default) + repository.blob_data_at(sha, ci_config_path_or_default) end def enabled_group_deploy_keys @@ -3530,6 +3480,10 @@ class Project < ApplicationRecord pool_repository_shard == repository_storage end + + def update_catalog_resource + catalog_resource.sync_with_project! + end end Project.prepend_mod_with('Project') diff --git a/app/models/project_feature_usage.rb b/app/models/project_feature_usage.rb index dba81a6cb60..5e47ec6310d 100644 --- a/app/models/project_feature_usage.rb +++ b/app/models/project_feature_usage.rb @@ -19,19 +19,6 @@ class ProjectFeatureUsage < ApplicationRecord end end - def log_jira_dvcs_integration_usage(cloud: true) - ::Gitlab::Database::LoadBalancing::Session.without_sticky_writes do - integration_field = self.class.jira_dvcs_integration_field(cloud: cloud) - - # The feature usage is used only once later to query the feature usage in a - # long date range. Therefore, we just need to update the timestamp once per - # day - break if persisted? && updated_today?(integration_field) - - persist_jira_dvcs_usage(integration_field) - end - end - private def updated_today?(integration_field) diff --git a/app/models/project_pages_metadatum.rb b/app/models/project_pages_metadatum.rb index eca2e5a740e..87cff4f2715 100644 --- a/app/models/project_pages_metadatum.rb +++ b/app/models/project_pages_metadatum.rb @@ -10,7 +10,4 @@ class ProjectPagesMetadatum < ApplicationRecord belongs_to :project, inverse_of: :pages_metadatum belongs_to :pages_deployment - - scope :deployed, -> { where(deployed: true) } - scope :with_project_route_and_deployment, -> { preload(:pages_deployment, project: [:namespace, :route]) } end diff --git a/app/models/project_snippet.rb b/app/models/project_snippet.rb index ffb08e10f1f..7a80ad33d68 100644 --- a/app/models/project_snippet.rb +++ b/app/models/project_snippet.rb @@ -5,4 +5,6 @@ class ProjectSnippet < Snippet validates :project, presence: true validates :secret, inclusion: { in: [false] } + + scope :by_project, ->(project) { where(project: project) } end diff --git a/app/models/projects/repository_storage_move.rb b/app/models/projects/repository_storage_move.rb index f4411e0b4fd..e2c6d1853a9 100644 --- a/app/models/projects/repository_storage_move.rb +++ b/app/models/projects/repository_storage_move.rb @@ -14,11 +14,6 @@ module Projects alias_attribute :project, :container scope :with_projects, -> { includes(container: :route) } - override :update_repository_storage - def update_repository_storage(new_storage) - container.update_column(:repository_storage, new_storage) - end - override :schedule_repository_storage_update_worker def schedule_repository_storage_update_worker Projects::UpdateRepositoryStorageWorker.perform_async( diff --git a/app/models/protected_branch.rb b/app/models/protected_branch.rb index aebce59a040..40a1a4392dd 100644 --- a/app/models/protected_branch.rb +++ b/app/models/protected_branch.rb @@ -5,6 +5,7 @@ class ProtectedBranch < ApplicationRecord include Gitlab::SQL::Pattern include FromUnion include EachBatch + include Presentable belongs_to :group, foreign_key: :namespace_id, touch: true, inverse_of: :protected_branches diff --git a/app/models/repository.rb b/app/models/repository.rb index e565de9c4ba..e639a389e0a 100644 --- a/app/models/repository.rb +++ b/app/models/repository.rb @@ -1102,10 +1102,6 @@ class Repository blob_data_at(sha, '.gitlab/route-map.yml') end - def gitlab_ci_yml_for(sha, path = '.gitlab-ci.yml') - blob_data_at(sha, path) - end - def lfsconfig_for(sha) blob_data_at(sha, '.lfsconfig') end @@ -1245,6 +1241,10 @@ class Repository def get_patch_id(old_revision, new_revision) raw_repository.get_patch_id(old_revision, new_revision) rescue Gitlab::Git::CommandError, Gitlab::Git::Repository::NoRepository => e + # This is expected when there are no differences between the old_revision and the new_revision. + # It's not ideal, but is simpler to handle this here than making breaking changes to gitaly. + return if e.message.match?(/no difference between old and new revision./) + Gitlab::ErrorTracking.track_exception( e, project_id: project.id, diff --git a/app/models/resource_label_event.rb b/app/models/resource_label_event.rb index d5c839724d4..ad1ce740c89 100644 --- a/app/models/resource_label_event.rb +++ b/app/models/resource_label_event.rb @@ -112,7 +112,7 @@ class ResourceLabelEvent < ResourceEvent end def resource_parent - issuable.project || issuable.group + issuable.try(:resource_parent) || issuable.project || issuable.group end def discussion_id_key diff --git a/app/models/service_desk/custom_email_credential.rb b/app/models/service_desk/custom_email_credential.rb index 5986ac8a43f..82bda673491 100644 --- a/app/models/service_desk/custom_email_credential.rb +++ b/app/models/service_desk/custom_email_credential.rb @@ -2,6 +2,14 @@ module ServiceDesk class CustomEmailCredential < ApplicationRecord + # Used to explicitly set the SMTP AUTH method. + # If nil Net::SMTP will choose one of methods listed by the SMTP server. + enum smtp_authentication: { + plain: 0, + login: 1, + cram_md5: 2 + } + attr_encrypted :smtp_username, mode: :per_attribute_iv, algorithm: 'aes-256-gcm', @@ -44,7 +52,8 @@ module ServiceDesk password: smtp_password, address: smtp_address, domain: Mail::Address.new(service_desk_setting.custom_email).domain, - port: smtp_port || 587 + port: smtp_port || 587, + authentication: smtp_authentication } end diff --git a/app/models/snippet.rb b/app/models/snippet.rb index 78b0c0849e3..3e075fdaa9e 100644 --- a/app/models/snippet.rb +++ b/app/models/snippet.rb @@ -77,6 +77,7 @@ class Snippet < ApplicationRecord scope :inc_relations_for_view, -> { includes(author: :status) } scope :inc_statistics, -> { includes(:statistics) } scope :with_statistics, -> { joins(:statistics) } + scope :with_repository_storage_moves, -> { joins(:repository_storage_moves) } scope :inc_projects_namespace_route, -> { includes(project: [:route, :namespace]) } scope :without_created_by_banned_user, -> do diff --git a/app/models/snippet_repository.rb b/app/models/snippet_repository.rb index a262802c8af..6b2fa99d547 100644 --- a/app/models/snippet_repository.rb +++ b/app/models/snippet_repository.rb @@ -31,6 +31,11 @@ class SnippetRepository < ApplicationRecord options[:actions] = transform_file_entries(files) + # The Gitaly calls perform HTTP requests for permissions check + # Stick to the primary in order to make those requests aware that + # primary database must be used to fetch the data + self.class.sticking.stick(:user, user.id) + capture_git_error { repository.commit_files(user, **options) } ensure Gitlab::ExclusiveLease.cancel(lease_key, uuid) diff --git a/app/models/system/broadcast_message.rb b/app/models/system/broadcast_message.rb index 06f0115ade6..d959a6339a4 100644 --- a/app/models/system/broadcast_message.rb +++ b/app/models/system/broadcast_message.rb @@ -117,7 +117,7 @@ module System end def ended? - ends_at < Time.current + ends_at.past? end def now? diff --git a/app/models/system_note_metadata.rb b/app/models/system_note_metadata.rb index dc93decce5e..8624a1a9463 100644 --- a/app/models/system_note_metadata.rb +++ b/app/models/system_note_metadata.rb @@ -4,6 +4,7 @@ class SystemNoteMetadata < ApplicationRecord include Importable include IgnorableColumns + ignore_column :id_convert_to_bigint, remove_with: '16.9', remove_after: '2024-01-13' ignore_column :note_id_convert_to_bigint, remove_with: '16.7', remove_after: '2023-11-16' # These notes's action text might contain a reference that is external. diff --git a/app/models/upload.rb b/app/models/upload.rb index 59ce9a1f37a..745a6174931 100644 --- a/app/models/upload.rb +++ b/app/models/upload.rb @@ -174,7 +174,7 @@ class Upload < ApplicationRecord end def update_project_statistics - ProjectCacheWorker.perform_async(model_id, [], [:uploads_size]) + ProjectCacheWorker.perform_async(model_id, [], ['uploads_size']) end end diff --git a/app/models/user.rb b/app/models/user.rb index 4034677509f..25f22563136 100644 --- a/app/models/user.rb +++ b/app/models/user.rb @@ -32,6 +32,7 @@ class User < MainClusterwide::ApplicationRecord include EachBatch include CrossDatabaseIgnoredTables include IgnorableColumns + include UseSqlFunctionForPrimaryKeyLookups ignore_column %i[ email_opted_in @@ -48,7 +49,7 @@ class User < MainClusterwide::ApplicationRecord # Associations with dependent: option cross_database_ignore_tables( - %w[namespaces projects project_authorizations issues merge_requests merge_requests issues issues merge_requests], + %w[namespaces projects project_authorizations issues merge_requests merge_requests issues issues merge_requests events], url: 'https://gitlab.com/gitlab-org/gitlab/-/issues/424285', on: :destroy ) @@ -390,6 +391,7 @@ class User < MainClusterwide::ApplicationRecord :first_day_of_week, :first_day_of_week=, :timezone, :timezone=, :time_display_relative, :time_display_relative=, + :time_display_format, :time_display_format=, :show_whitespace_in_diffs, :show_whitespace_in_diffs=, :view_diffs_file_by_file, :view_diffs_file_by_file=, :pass_user_identities_to_ci_jwt, :pass_user_identities_to_ci_jwt=, @@ -417,6 +419,7 @@ class User < MainClusterwide::ApplicationRecord delegate :pronouns, :pronouns=, to: :user_detail, allow_nil: true delegate :pronunciation, :pronunciation=, to: :user_detail, allow_nil: true delegate :registration_objective, :registration_objective=, to: :user_detail, allow_nil: true + delegate :mastodon, :mastodon=, to: :user_detail, allow_nil: true delegate :linkedin, :linkedin=, to: :user_detail, allow_nil: true delegate :twitter, :twitter=, to: :user_detail, allow_nil: true delegate :skype, :skype=, to: :user_detail, allow_nil: true @@ -425,6 +428,7 @@ class User < MainClusterwide::ApplicationRecord delegate :organization, :organization=, to: :user_detail, allow_nil: true delegate :discord, :discord=, to: :user_detail, allow_nil: true delegate :email_reset_offered_at, :email_reset_offered_at=, to: :user_detail, allow_nil: true + delegate :project_authorizations_recalculated_at, :project_authorizations_recalculated_at=, to: :user_detail, allow_nil: true accepts_nested_attributes_for :user_preference, update_only: true accepts_nested_attributes_for :user_detail, update_only: true @@ -600,6 +604,12 @@ class User < MainClusterwide::ApplicationRecord scope :by_provider_and_extern_uid, ->(provider, extern_uid) { joins(:identities).merge(Identity.with_extern_uid(provider, extern_uid)) } scope :by_ids_or_usernames, -> (ids, usernames) { where(username: usernames).or(where(id: ids)) } scope :without_forbidden_states, -> { where.not(state: FORBIDDEN_SEARCH_STATES) } + scope :trusted, -> do + where('EXISTS (?)', ::UserCustomAttribute + .select(1) + .where('user_id = users.id') + .trusted_with_spam) + end strip_attributes! :name @@ -768,6 +778,8 @@ class User < MainClusterwide::ApplicationRecord external when 'deactivated' deactivated + when "trusted" + trusted else active_without_ghosts end @@ -791,9 +803,9 @@ class User < MainClusterwide::ApplicationRecord order = <<~SQL CASE - WHEN LOWER(users.name) = :query THEN 0 + WHEN LOWER(users.public_email) = :query THEN 0 WHEN LOWER(users.username) = :query THEN 1 - WHEN LOWER(users.public_email) = :query THEN 2 + WHEN LOWER(users.name) = :query THEN 2 ELSE 3 END SQL @@ -1081,7 +1093,7 @@ class User < MainClusterwide::ApplicationRecord def otp_secret_expired? return true unless otp_secret_expires_at - otp_secret_expires_at < Time.current + otp_secret_expires_at.past? end def update_otp_secret! @@ -1446,7 +1458,7 @@ class User < MainClusterwide::ApplicationRecord if !Gitlab.config.ldap.enabled false elsif ldap_user? - !last_credential_check_at || (last_credential_check_at + ldap_sync_time) < Time.current + !last_credential_check_at || (last_credential_check_at + ldap_sync_time).past? else false end @@ -2087,7 +2099,7 @@ class User < MainClusterwide::ApplicationRecord end def password_expired? - !!(password_expires_at && password_expires_at < Time.current) + !!(password_expires_at && password_expires_at.past?) end def password_expired_if_applicable? diff --git a/app/models/user_custom_attribute.rb b/app/models/user_custom_attribute.rb index 728c1f4844a..5a592b425df 100644 --- a/app/models/user_custom_attribute.rb +++ b/app/models/user_custom_attribute.rb @@ -20,6 +20,7 @@ class UserCustomAttribute < ApplicationRecord TRUSTED_BY = 'trusted_by' AUTO_BANNED_BY = 'auto_banned_by' IDENTITY_VERIFICATION_PHONE_EXEMPT = 'identity_verification_phone_exempt' + IDENTITY_VERIFICATION_EXEMPT = 'identity_verification_exempt' class << self def upsert_custom_attributes(custom_attributes) diff --git a/app/models/user_detail.rb b/app/models/user_detail.rb index 9ac814eebda..bbb08ed5774 100644 --- a/app/models/user_detail.rb +++ b/app/models/user_detail.rb @@ -17,10 +17,24 @@ class UserDetail < MainClusterwide::ApplicationRecord DEFAULT_FIELD_LENGTH = 500 + MASTODON_VALIDATION_REGEX = / + \A # beginning of string + @?\b # optional leading at + ([\w\d.%+-]+) # character group to pick up words in user portion of username + @ # separator between user and host + ( # beginning of charagter group for host portion + [\w\d.-]+ # character group to pick up words in host portion of username + \.\w{2,} # pick up tld of host domain, 2 chars or more + )\b # end of character group to pick up words in host portion of username + \z # end of string + /x + validates :discord, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validate :discord_format validates :linkedin, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :location, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validates :mastodon, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true + validate :mastodon_format validates :organization, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :skype, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true validates :twitter, length: { maximum: DEFAULT_FIELD_LENGTH }, allow_blank: true @@ -32,7 +46,7 @@ class UserDetail < MainClusterwide::ApplicationRecord enum registration_objective: REGISTRATION_OBJECTIVE_PAIRS, _suffix: true def sanitize_attrs - %i[discord linkedin skype twitter website_url].each do |attr| + %i[discord linkedin mastodon skype twitter website_url].each do |attr| value = self[attr] self[attr] = Sanitize.clean(value) if value.present? end @@ -49,6 +63,7 @@ class UserDetail < MainClusterwide::ApplicationRecord self.discord = '' if discord.nil? self.linkedin = '' if linkedin.nil? self.location = '' if location.nil? + self.mastodon = '' if mastodon.nil? self.organization = '' if organization.nil? self.skype = '' if skype.nil? self.twitter = '' if twitter.nil? @@ -62,4 +77,10 @@ def discord_format errors.add(:discord, _('must contain only a discord user ID.')) end +def mastodon_format + return if mastodon.blank? || mastodon =~ UserDetail::MASTODON_VALIDATION_REGEX + + errors.add(:mastodon, _('must contain only a mastodon username.')) +end + UserDetail.prepend_mod_with('UserDetail') diff --git a/app/models/user_preference.rb b/app/models/user_preference.rb index 8fc9f4617d0..59cfe9a8426 100644 --- a/app/models/user_preference.rb +++ b/app/models/user_preference.rb @@ -7,6 +7,7 @@ class UserPreference < MainClusterwide::ApplicationRecord # enum options with same name for multiple fields, also it creates # extra methods that aren't really needed here. NOTES_FILTERS = { all_notes: 0, only_comments: 1, only_activity: 2 }.freeze + TIME_DISPLAY_FORMATS = { system: 0, non_iso_format: 1, iso_format: 2 }.freeze belongs_to :user @@ -27,12 +28,15 @@ class UserPreference < MainClusterwide::ApplicationRecord validates :pinned_nav_items, json_schema: { filename: 'pinned_nav_items' } + validates :time_display_format, inclusion: { in: TIME_DISPLAY_FORMATS.values }, presence: true + ignore_columns :experience_level, remove_with: '14.10', remove_after: '2021-03-22' # 2023-06-22 is after 16.1 release and during 16.2 release https://docs.gitlab.com/ee/development/database/avoiding_downtime_in_migrations.html#ignoring-the-column-release-m ignore_columns :use_legacy_web_ide, remove_with: '16.2', remove_after: '2023-06-22' attribute :tab_width, default: -> { Gitlab::TabWidth::DEFAULT } attribute :time_display_relative, default: true + attribute :time_display_format, default: 0 attribute :render_whitespace_in_code, default: false attribute :project_shortcut_buttons, default: true attribute :keyboard_shortcuts_enabled, default: true @@ -80,6 +84,16 @@ class UserPreference < MainClusterwide::ApplicationRecord end end + class << self + def time_display_formats + { + s_('Time Display|System') => TIME_DISPLAY_FORMATS[:system], + s_('Time Display|12-hour: 2:34 PM') => TIME_DISPLAY_FORMATS[:non_iso_format], + s_('Time Display|24-hour: 14:34') => TIME_DISPLAY_FORMATS[:iso_format] + } + end + end + def time_display_relative value = read_attribute(:time_display_relative) return value unless value.nil? diff --git a/app/models/users/anonymous.rb b/app/models/users/anonymous.rb new file mode 100644 index 00000000000..b4a182ba203 --- /dev/null +++ b/app/models/users/anonymous.rb @@ -0,0 +1,11 @@ +# frozen_string_literal: true + +module Users + class Anonymous + class << self + def can?(action, subject = :global) + Ability.allowed?(nil, action, subject) + end + end + end +end diff --git a/app/models/users/callout.rb b/app/models/users/callout.rb index 60dd89c3ee7..a9880e56e8c 100644 --- a/app/models/users/callout.rb +++ b/app/models/users/callout.rb @@ -65,18 +65,19 @@ module Users # 62, removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/131314 # 63 and 64 were removed with https://gitlab.com/gitlab-org/gitlab/-/merge_requests/120233 branch_rules_info_callout: 65, - create_runner_workflow_banner: 66, + # 66 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/135470/ # 67 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/121920 project_repository_limit_alert_warning_threshold: 68, # EE-only project_repository_limit_alert_alert_threshold: 69, # EE-only project_repository_limit_alert_error_threshold: 70, # EE-only - new_navigation_callout: 71, + # 71 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/134432 # 72 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/129022 namespace_over_storage_users_combined_alert: 73, # EE-only # 74 removed in https://gitlab.com/gitlab-org/gitlab/-/merge_requests/132751 vsd_feedback_banner: 75, # EE-only security_policy_protected_branch_modification: 76, # EE-only - vulnerability_report_grouping: 77 # EE-only + vulnerability_report_grouping: 77, # EE-only + new_nav_for_everyone_callout: 78 } validates :feature_name, diff --git a/app/models/users/credit_card_validation.rb b/app/models/users/credit_card_validation.rb index 276d549006f..6d0a22c8b0a 100644 --- a/app/models/users/credit_card_validation.rb +++ b/app/models/users/credit_card_validation.rb @@ -2,10 +2,16 @@ module Users class CreditCardValidation < ApplicationRecord + include IgnorableColumns + RELEASE_DAY = Date.new(2021, 5, 17) self.table_name = 'user_credit_card_validations' + ignore_columns %i[last_digits network holder_name expiration_date], remove_with: '16.8', remove_after: '2023-12-22' + + attr_accessor :last_digits, :network, :holder_name, :expiration_date + belongs_to :user belongs_to :banned_user, class_name: '::Users::BannedUser', foreign_key: :user_id, inverse_of: :credit_card_validation diff --git a/app/models/users/group_visit.rb b/app/models/users/group_visit.rb index 0bcfda049fc..d7c76e2ee2c 100644 --- a/app/models/users/group_visit.rb +++ b/app/models/users/group_visit.rb @@ -13,5 +13,12 @@ module Users validates :entity_id, presence: true validates :user_id, presence: true validates :visited_at, presence: true + + MAX_FRECENT_ITEMS = 3 + + def self.frecent_groups(user_id:) + ids = frecent_visits_scores(user_id: user_id, limit: MAX_FRECENT_ITEMS).pluck("entity_id") + Group.find(ids) + end end end diff --git a/app/models/users/phone_number_validation.rb b/app/models/users/phone_number_validation.rb index e033445d76b..2256eb8ddc4 100644 --- a/app/models/users/phone_number_validation.rb +++ b/app/models/users/phone_number_validation.rb @@ -41,6 +41,10 @@ module Users ).exists? end + def self.by_reference_id(ref_id) + find_by(telesign_reference_xid: ref_id) + end + def validated? validated_at.present? end diff --git a/app/models/users/project_visit.rb b/app/models/users/project_visit.rb index 1d076e0be56..9ff3d8d2c91 100644 --- a/app/models/users/project_visit.rb +++ b/app/models/users/project_visit.rb @@ -13,5 +13,12 @@ module Users validates :entity_id, presence: true validates :user_id, presence: true validates :visited_at, presence: true + + MAX_FRECENT_ITEMS = 5 + + def self.frecent_projects(user_id:) + ids = frecent_visits_scores(user_id: user_id, limit: MAX_FRECENT_ITEMS).pluck("entity_id") + Project.find(ids) + end end end diff --git a/app/models/vs_code/settings/vs_code_setting.rb b/app/models/vs_code/settings/vs_code_setting.rb index e55d958d2b4..1401ce82045 100644 --- a/app/models/vs_code/settings/vs_code_setting.rb +++ b/app/models/vs_code/settings/vs_code_setting.rb @@ -5,7 +5,9 @@ module VsCode class VsCodeSetting < ApplicationRecord belongs_to :user, inverse_of: :vscode_settings - validates :setting_type, presence: true + validates :setting_type, presence: true, + inclusion: { in: SETTINGS_TYPES }, + uniqueness: { scope: :user_id } validates :content, presence: true scope :by_setting_type, ->(setting_type) { where(setting_type: setting_type) } diff --git a/app/models/wiki_page.rb b/app/models/wiki_page.rb index 2eed693ca76..3dd8f334a68 100644 --- a/app/models/wiki_page.rb +++ b/app/models/wiki_page.rb @@ -80,6 +80,7 @@ class WikiPage alias_method :to_param, :slug def human_title + return front_matter_title if Feature.enabled?(:wiki_front_matter_title, container) && front_matter_title.present? return 'Home' if title == Wiki::HOMEPAGE title @@ -95,6 +96,10 @@ class WikiPage attributes[:title] = new_title end + def front_matter_title + front_matter[:title] + end + def raw_content attributes[:content] ||= page&.text_data end @@ -320,7 +325,7 @@ class WikiPage def serialize_front_matter(hash) return '' unless hash.present? - YAML.dump(hash.transform_keys(&:to_s)) + "---\n" + YAML.dump(hash.to_h.transform_keys(&:to_s)) + "---\n" end def update_front_matter(attrs) diff --git a/app/models/work_item.rb b/app/models/work_item.rb index 0761a213532..a62d77939bf 100644 --- a/app/models/work_item.rb +++ b/app/models/work_item.rb @@ -73,6 +73,19 @@ class WorkItem < Issue includes(:parent_link).order(keyset_order) end + def linked_items_keyset_order + ::Gitlab::Pagination::Keyset::Order.build( + [ + ::Gitlab::Pagination::Keyset::ColumnOrderDefinition.new( + attribute_name: 'issue_link_id', + column_expression: IssueLink.arel_table[:id], + order_expression: IssueLink.arel_table[:id].asc, + nullable: :not_nullable, + distinct: true + ) + ]) + end + override :related_link_class def related_link_class WorkItems::RelatedWorkItemLink @@ -150,7 +163,9 @@ class WorkItem < Issue def linked_work_items(current_user = nil, authorize: true, preload: nil, link_type: nil) return [] if new_record? - linked_work_items = linked_work_items_query(link_type).preload(preload).reorder('issue_link_id') + linked_work_items = linked_work_items_query(link_type) + .preload(preload) + .reorder(self.class.linked_items_keyset_order) return linked_work_items unless authorize cross_project_filter = ->(work_items) { work_items.where(project: project) } diff --git a/app/policies/abuse_report_policy.rb b/app/policies/abuse_report_policy.rb index f1f994e6a42..043dbd0cb89 100644 --- a/app/policies/abuse_report_policy.rb +++ b/app/policies/abuse_report_policy.rb @@ -3,5 +3,6 @@ class AbuseReportPolicy < ::BasePolicy rule { admin }.policy do enable :read_abuse_report + enable :create_note end end diff --git a/app/policies/analytics/cycle_analytics/value_stream_policy.rb b/app/policies/analytics/cycle_analytics/value_stream_policy.rb new file mode 100644 index 00000000000..7e236f94e91 --- /dev/null +++ b/app/policies/analytics/cycle_analytics/value_stream_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module Analytics + module CycleAnalytics + class ValueStreamPolicy < ::BasePolicy + delegate { subject.namespace } + end + end +end diff --git a/app/policies/base_policy.rb b/app/policies/base_policy.rb index 1ec2495a661..462afbaa475 100644 --- a/app/policies/base_policy.rb +++ b/app/policies/base_policy.rb @@ -37,7 +37,7 @@ class BasePolicy < DeclarativePolicy::Base desc "User is security policy bot" with_options scope: :user, score: 0 - condition(:security_policy_bot) { @user&.security_policy_bot? } + condition(:security_policy_bot) { false } desc "User is automation bot" with_options scope: :user, score: 0 diff --git a/app/policies/ci/build_policy.rb b/app/policies/ci/build_policy.rb index bce7ceafe17..71ea42e1f23 100644 --- a/app/policies/ci/build_policy.rb +++ b/app/policies/ci/build_policy.rb @@ -81,6 +81,7 @@ module Ci end rule { ~can?(:jailbreak) & (archived | protected_ref) }.policy do + prevent :cancel_build prevent :update_build prevent :erase_build end @@ -88,6 +89,7 @@ module Ci rule { can?(:admin_build) | (can?(:update_build) & owner_of_job & unprotected_ref) }.enable :erase_build rule { can?(:public_access) & branch_allows_collaboration }.policy do + enable :cancel_build enable :update_build enable :update_commit_status end diff --git a/app/policies/ci/deployable_policy.rb b/app/policies/ci/deployable_policy.rb index f0105b001f2..e83bdd5361a 100644 --- a/app/policies/ci/deployable_policy.rb +++ b/app/policies/ci/deployable_policy.rb @@ -11,7 +11,10 @@ module Ci @subject.outdated_deployment? end - rule { outdated_deployment }.prevent :update_build + rule { outdated_deployment }.policy do + prevent :cancel_build + prevent :update_build + end end end end diff --git a/app/policies/ci/pipeline_policy.rb b/app/policies/ci/pipeline_policy.rb index 1d60b1e79de..c01162a86df 100644 --- a/app/policies/ci/pipeline_policy.rb +++ b/app/policies/ci/pipeline_policy.rb @@ -27,10 +27,14 @@ module Ci prevent :read_pipeline end - rule { protected_ref }.prevent :update_pipeline + rule { protected_ref }.policy do + prevent :update_pipeline + prevent :cancel_pipeline + end rule { can?(:public_access) & branch_allows_collaboration }.policy do enable :update_pipeline + enable :cancel_pipeline end rule { can?(:owner_access) }.policy do diff --git a/app/policies/concerns/policy_actor.rb b/app/policies/concerns/policy_actor.rb index e000f1514e5..8fa09683b06 100644 --- a/app/policies/concerns/policy_actor.rb +++ b/app/policies/concerns/policy_actor.rb @@ -53,10 +53,6 @@ module PolicyActor false end - def security_policy_bot? - false - end - def automation_bot? false end diff --git a/app/policies/container_registry/protection/rule_policy.rb b/app/policies/container_registry/protection/rule_policy.rb new file mode 100644 index 00000000000..4dc8dba3276 --- /dev/null +++ b/app/policies/container_registry/protection/rule_policy.rb @@ -0,0 +1,9 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + class RulePolicy < BasePolicy + delegate { @subject.project } + end + end +end diff --git a/app/policies/global_policy.rb b/app/policies/global_policy.rb index 7594360a91c..175f86c9673 100644 --- a/app/policies/global_policy.rb +++ b/app/policies/global_policy.rb @@ -63,10 +63,6 @@ class GlobalPolicy < BasePolicy prevent :access_git end - rule { security_policy_bot }.policy do - enable :access_git - end - rule { project_bot | service_account }.policy do prevent :log_in prevent :receive_notifications diff --git a/app/policies/group_group_link_policy.rb b/app/policies/group_group_link_policy.rb new file mode 100644 index 00000000000..0108f0b7fca --- /dev/null +++ b/app/policies/group_group_link_policy.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class GroupGroupLinkPolicy < ::BasePolicy # rubocop:disable Gitlab/NamespacedClass + condition(:can_read_shared_with_group) { can?(:read_group, @subject.shared_with_group) } + condition(:group_member) { @subject.shared_group.member?(@user) } + + rule { can_read_shared_with_group | group_member }.enable :read_shared_with_group +end diff --git a/app/policies/group_policy.rb b/app/policies/group_policy.rb index 2ab59f5a34d..ca170133105 100644 --- a/app/policies/group_policy.rb +++ b/app/policies/group_policy.rb @@ -121,6 +121,7 @@ class GroupPolicy < Namespaces::GroupProjectNamespaceSharedPolicy enable :upload_file enable :guest_access enable :read_release + enable :award_emoji end rule { admin }.policy do diff --git a/app/policies/issue_policy.rb b/app/policies/issue_policy.rb index 6114785a851..683c53d8d78 100644 --- a/app/policies/issue_policy.rb +++ b/app/policies/issue_policy.rb @@ -57,7 +57,10 @@ class IssuePolicy < IssuablePolicy prevent :read_issue end - rule { ~can?(:read_issue) }.prevent :create_note + rule { ~can?(:read_issue) }.policy do + prevent :create_note + prevent :read_note + end rule { locked }.policy do prevent :reopen_issue diff --git a/app/policies/namespaces/group_project_namespace_shared_policy.rb b/app/policies/namespaces/group_project_namespace_shared_policy.rb index b24cb5be607..81bb5d6289e 100644 --- a/app/policies/namespaces/group_project_namespace_shared_policy.rb +++ b/app/policies/namespaces/group_project_namespace_shared_policy.rb @@ -22,6 +22,7 @@ module Namespaces enable :create_work_item enable :read_work_item enable :read_issue + enable :read_note enable :read_namespace enable :read_namespace_via_membership end diff --git a/app/policies/project_group_link_policy.rb b/app/policies/project_group_link_policy.rb index 00bb246d70b..7ad2985ecc5 100644 --- a/app/policies/project_group_link_policy.rb +++ b/app/policies/project_group_link_policy.rb @@ -2,9 +2,13 @@ class ProjectGroupLinkPolicy < BasePolicy # rubocop:disable Gitlab/NamespacedClass condition(:group_owner_or_project_admin) { group_owner? || project_admin? } + condition(:can_read_group) { can?(:read_group, @subject.group) } + condition(:project_member) { @subject.project.member?(@user) } rule { group_owner_or_project_admin }.enable :admin_project_group_link + rule { can_read_group | project_member }.enable :read_shared_with_group + private def group_owner? diff --git a/app/policies/project_import_state_policy.rb b/app/policies/project_import_state_policy.rb new file mode 100644 index 00000000000..c2cd03337b7 --- /dev/null +++ b/app/policies/project_import_state_policy.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +class ProjectImportStatePolicy < ::BasePolicy # rubocop:disable Gitlab/NamespacedClass -- required by DeclarativePolicy lookup logic + delegate { @subject.project } +end diff --git a/app/policies/project_policy.rb b/app/policies/project_policy.rb index 20f88577d67..bbb0e3df500 100644 --- a/app/policies/project_policy.rb +++ b/app/policies/project_policy.rb @@ -38,9 +38,6 @@ class ProjectPolicy < BasePolicy desc "User is a project bot" condition(:project_bot) { user.project_bot? && team_member? } - desc "User is a security policy bot on the project" - condition(:security_policy_bot) { user&.security_policy_bot? && team_member? } - desc "Project is public" condition(:public_project, scope: :subject, score: 0) { project.public? } @@ -136,6 +133,29 @@ class ProjectPolicy < BasePolicy !@user&.from_ci_job_token? || @user.ci_job_token_scope.accessible?(project) end + desc "If the user is via CI job token and project container registry visibility allows access" + condition(:job_token_container_registry) { job_token_access_allowed_to?(:container_registry) } + + desc "If the user is via CI job token and project package registry visibility allows access" + condition(:job_token_package_registry) { job_token_access_allowed_to?(:package_registry) } + + desc "If the user is via CI job token and project ci/cd visibility allows access" + condition(:job_token_builds) { job_token_access_allowed_to?(:builds) } + + desc "If the user is via CI job token and project releases visibility allows access" + condition(:job_token_releases) { job_token_access_allowed_to?(:releases) } + + desc "If the user is via CI job token and project environment visibility allows access" + condition(:job_token_environments) { job_token_access_allowed_to?(:environments) } + + desc "If the project is either public or internal" + condition(:public_or_internal) do + project.public? || project.internal? + end + + with_scope :subject + condition(:restrict_job_token_enabled) { Feature.enabled?(:restrict_ci_job_token_for_public_and_internal_projects, @subject) } + with_scope :subject condition(:forking_allowed) do @subject.feature_available?(:forking, @user) @@ -303,6 +323,8 @@ class ProjectPolicy < BasePolicy enable :set_show_diff_preview_in_email enable :set_warn_about_potentially_unwanted_characters enable :manage_owners + + enable :add_catalog_resource end rule { can?(:guest_access) }.policy do @@ -469,6 +491,7 @@ class ProjectPolicy < BasePolicy enable :update_commit_status enable :create_build enable :update_build + enable :cancel_build enable :read_resource_group enable :update_resource_group enable :create_merge_request_from @@ -512,6 +535,7 @@ class ProjectPolicy < BasePolicy rule { can?(:developer_access) & user_confirmed? }.policy do enable :create_pipeline enable :update_pipeline + enable :cancel_pipeline enable :create_pipeline_schedule end @@ -640,6 +664,7 @@ class ProjectPolicy < BasePolicy rule { builds_disabled | repository_disabled }.policy do prevent(*create_read_update_admin_destroy(:build)) + prevent :cancel_build prevent(*create_read_update_admin_destroy(:pipeline_schedule)) prevent(*create_read_update_admin_destroy(:environment)) prevent(*create_read_update_admin_destroy(:deployment)) @@ -652,6 +677,7 @@ class ProjectPolicy < BasePolicy # - We prevent the user from accessing Pipelines rule { (builds_disabled & ~internal_builds_disabled) | repository_disabled }.policy do prevent(*create_read_update_admin_destroy(:pipeline)) + prevent :cancel_pipeline prevent(*create_read_update_admin_destroy(:commit_status)) end @@ -679,8 +705,42 @@ class ProjectPolicy < BasePolicy enable :read_project_for_iids end + # If the project is private rule { ~public_project & ~internal_access & ~project_allowed_for_job_token }.prevent_all + # If this project is public or internal we want to prevent all aside from a few public policies + rule { public_or_internal & ~project_allowed_for_job_token & restrict_job_token_enabled }.policy do + prevent :guest_access + prevent :public_access + prevent :public_user_access + prevent :reporter_access + prevent :developer_access + prevent :maintainer_access + prevent :owner_access + end + + rule { public_or_internal & job_token_container_registry & restrict_job_token_enabled }.policy do + enable :build_read_container_image + enable :read_container_image + end + + rule { public_or_internal & job_token_package_registry & restrict_job_token_enabled }.policy do + enable :read_package + enable :read_project + end + + rule { public_or_internal & job_token_builds & restrict_job_token_enabled }.policy do + enable :read_commit_status # this is additionally needed to download artifacts + end + + rule { public_or_internal & job_token_releases & restrict_job_token_enabled }.policy do + enable :read_release + end + + rule { public_or_internal & job_token_environments & restrict_job_token_enabled }.policy do + enable :read_environment + end + rule { can?(:public_access) }.policy do enable :read_package enable :read_project @@ -908,14 +968,14 @@ class ProjectPolicy < BasePolicy enable :read_namespace_catalog end - rule { can?(:owner_access) & namespace_catalog_available }.policy do - enable :add_catalog_resource - end - rule { model_registry_enabled }.policy do enable :read_model_registry end + rule { can?(:reporter_access) & model_registry_enabled }.policy do + enable :write_model_registry + end + rule { model_experiments_enabled }.policy do enable :read_model_experiments end @@ -1007,6 +1067,20 @@ class ProjectPolicy < BasePolicy end end + def job_token_access_allowed_to?(feature) + return false unless @user&.from_ci_job_token? + return false unless project.project_feature + + case project.project_feature.access_level(feature) + when ProjectFeature::DISABLED + false + when ProjectFeature::PRIVATE + @user.ci_job_token_scope.accessible?(project) + else + true + end + end + def resource_access_token_feature_available? true end diff --git a/app/policies/user_policy.rb b/app/policies/user_policy.rb index 2fd198b8cf4..04fbc8467c9 100644 --- a/app/policies/user_policy.rb +++ b/app/policies/user_policy.rb @@ -29,6 +29,7 @@ class UserPolicy < BasePolicy enable :read_user_personal_access_tokens enable :read_group_count enable :read_user_groups + enable :read_user_organizations enable :read_saved_replies enable :read_user_email_address enable :admin_user_email_address diff --git a/app/presenters/clusters/cluster_presenter.rb b/app/presenters/clusters/cluster_presenter.rb index ec1dc96c2e3..5765d08dfb3 100644 --- a/app/presenters/clusters/cluster_presenter.rb +++ b/app/presenters/clusters/cluster_presenter.rb @@ -61,7 +61,7 @@ module Clusters 'clusters-path': clusterable.index_path, 'dashboard-endpoint': clusterable.metrics_dashboard_path(cluster), 'documentation-path': help_page_path('user/infrastructure/clusters/manage/clusters_health'), - 'add-dashboard-documentation-path': help_page_path('operations/metrics/dashboards/index.md', anchor: 'add-a-new-dashboard-to-your-project'), + 'add-dashboard-documentation-path': help_page_path('operations/metrics/dashboards/index', anchor: 'add-a-new-dashboard-to-your-project'), 'empty-getting-started-svg-path': image_path('illustrations/monitoring/getting_started.svg'), 'empty-loading-svg-path': image_path('illustrations/monitoring/loading.svg'), 'empty-no-data-svg-path': image_path('illustrations/monitoring/no_data.svg'), diff --git a/app/presenters/commit_status_presenter.rb b/app/presenters/commit_status_presenter.rb index f6720546fab..0858fad1e1a 100644 --- a/app/presenters/commit_status_presenter.rb +++ b/app/presenters/commit_status_presenter.rb @@ -1,4 +1,5 @@ # frozen_string_literal: true + class CommitStatusPresenter < Gitlab::View::Presenter::Delegated CALLOUT_FAILURE_MESSAGES = { unknown_failure: 'There is an unknown failure, please try again', diff --git a/app/presenters/member_presenter.rb b/app/presenters/member_presenter.rb index 4cdaca3c39e..7acaa704368 100644 --- a/app/presenters/member_presenter.rb +++ b/app/presenters/member_presenter.rb @@ -15,6 +15,10 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated end end + def valid_member_roles + [] + end + def can_resend_invite? invite? && can?(current_user, admin_member_permission, source) @@ -37,6 +41,11 @@ class MemberPresenter < Gitlab::View::Presenter::Delegated false end + # This functionality is only available in EE. + def custom_permissions + [] + end + def last_owner? raise NotImplementedError end diff --git a/app/presenters/ml/model_presenter.rb b/app/presenters/ml/model_presenter.rb index 388e2b73bc1..24d30af1d4e 100644 --- a/app/presenters/ml/model_presenter.rb +++ b/app/presenters/ml/model_presenter.rb @@ -5,17 +5,31 @@ module Ml presents ::Ml::Model, as: :model def latest_version_name - model.latest_version&.version + latest_version&.version + end + + def version_count + return model.version_count if model.respond_to?(:version_count) + + model.versions.size end def latest_package_path - return unless model.latest_version&.package_id.present? + latest_version&.package_path + end - Gitlab::Routing.url_helpers.project_package_path(model.project, model.latest_version.package_id) + def latest_version_path + latest_version&.path end def path - Gitlab::Routing.url_helpers.project_ml_model_path(model.project, model.id) + project_ml_model_path(model.project, model.id) + end + + private + + def latest_version + model.latest_version&.present end end end diff --git a/app/presenters/ml/model_version_presenter.rb b/app/presenters/ml/model_version_presenter.rb new file mode 100644 index 00000000000..210b213ca2a --- /dev/null +++ b/app/presenters/ml/model_version_presenter.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +module Ml + class ModelVersionPresenter < Gitlab::View::Presenter::Delegated + presents ::Ml::ModelVersion, as: :model_version + + def display_name + "#{model_version.model.name} / #{model_version.version}" + end + + def path + project_ml_model_version_path( + model_version.model.project, + model_version.model, + model_version + ) + end + + def package_path + return unless model_version.package_id.present? + + project_package_path(model_version.project, model_version.package_id) + end + end +end diff --git a/app/presenters/project_presenter.rb b/app/presenters/project_presenter.rb index 4533ef3633d..c983d8623d2 100644 --- a/app/presenters/project_presenter.rb +++ b/app/presenters/project_presenter.rb @@ -11,6 +11,7 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated include ChecksCollaboration include Gitlab::Utils::StrongMemoize include Gitlab::Experiment::Dsl + include SafeFormatHelper delegator_override_with GitlabRoutingHelper # TODO: Remove `GitlabRoutingHelper` inclusion as it's duplicate delegator_override_with Gitlab::Utils::StrongMemoize # This module inclusion is expected. See https://gitlab.com/gitlab-org/gitlab/-/issues/352884. @@ -163,14 +164,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def storage_anchor_data can_show_quota = can?(current_user, :admin_project, project) && !empty_repo? + AnchorData.new( true, - statistic_icon('disk') + - _('%{strong_start}%{human_size}%{strong_end} Project Storage').html_safe % { - human_size: storage_counter(statistics.storage_size), - strong_start: '<strong class="project-stat-value">'.html_safe, - strong_end: '</strong>'.html_safe - }, + statistic_icon('disk') + storage_anchor_text, can_show_quota ? project_usage_quotas_path(project) : nil ) end @@ -439,6 +436,10 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated count_of_extra_topics_not_shown > 0 end + def has_review_app? + !project.environments_for_scope('review/*').empty? + end + def can_setup_review_app? strong_memoize(:can_setup_review_app) do (can_instantiate_cluster? && all_clusters_empty?) || cicd_missing? @@ -528,6 +529,16 @@ class ProjectPresenter < Gitlab::View::Presenter::Delegated def project_create_wiki_path "#{wiki_path(project.wiki)}?view=create" end + + def storage_anchor_text + safe_format( + _('%{strong_start}%{human_size}%{strong_end} Project Storage'), { + human_size: storage_counter(statistics.storage_size), + strong_start: '<strong class="project-stat-value">'.html_safe, + strong_end: '</strong>'.html_safe + } + ) + end end ProjectPresenter.prepend_mod_with('ProjectPresenter') diff --git a/app/presenters/projects/security/configuration_presenter.rb b/app/presenters/projects/security/configuration_presenter.rb index f248652befc..a0d731f0ccf 100644 --- a/app/presenters/projects/security/configuration_presenter.rb +++ b/app/presenters/projects/security/configuration_presenter.rb @@ -55,8 +55,8 @@ module Projects def gitlab_ci_history_path return '' if project.empty_repo? - gitlab_ci = ::Gitlab::FileDetector::PATTERNS[:gitlab_ci] - ::Gitlab::Routing.url_helpers.project_blame_path(project, File.join(project.default_branch_or_main, gitlab_ci)) + ::Gitlab::Routing.url_helpers.project_blame_path( + project, File.join(project.default_branch_or_main, project.ci_config_path_or_default)) end def features diff --git a/app/presenters/user_presenter.rb b/app/presenters/user_presenter.rb index 43164cca9c9..da087ce6858 100644 --- a/app/presenters/user_presenter.rb +++ b/app/presenters/user_presenter.rb @@ -21,7 +21,6 @@ class UserPresenter < Gitlab::View::Presenter::Delegated delegator_override :saved_replies def saved_replies - return ::Users::SavedReply.none unless Feature.enabled?(:saved_replies, current_user) return ::Users::SavedReply.none unless current_user.can?(:read_saved_replies, user) user.saved_replies diff --git a/app/serializers/build_details_entity.rb b/app/serializers/build_details_entity.rb index 9aee031328b..35063ceeb06 100644 --- a/app/serializers/build_details_entity.rb +++ b/app/serializers/build_details_entity.rb @@ -14,7 +14,7 @@ class BuildDetailsEntity < Ci::JobEntity expose :deployment_status, if: -> (*) { build.deployment_job? } do expose :deployment_status, as: :status expose :persisted_environment, as: :environment do |build, options| - options.merge(deployment_details: false).yield_self do |opts| + options.merge(deployment_details: false).then do |opts| EnvironmentEntity.represent(build.persisted_environment, opts) end end diff --git a/app/serializers/ci/job_entity.rb b/app/serializers/ci/job_entity.rb index 813938c2a18..828a9eb33a5 100644 --- a/app/serializers/ci/job_entity.rb +++ b/app/serializers/ci/job_entity.rb @@ -53,7 +53,7 @@ module Ci alias_method :job, :object def cancelable? - job.cancelable? && can?(request.current_user, :update_build, job) + job.cancelable? && can?(request.current_user, :cancel_build, job) end def retryable? diff --git a/app/serializers/ci/pipeline_entity.rb b/app/serializers/ci/pipeline_entity.rb index 832ca619edc..4ff56c67d13 100644 --- a/app/serializers/ci/pipeline_entity.rb +++ b/app/serializers/ci/pipeline_entity.rb @@ -106,7 +106,7 @@ class Ci::PipelineEntity < Grape::Entity end def can_cancel? - can?(request.current_user, :update_pipeline, pipeline) && + can?(request.current_user, :cancel_pipeline, pipeline) && pipeline.cancelable? end diff --git a/app/serializers/deployment_entity.rb b/app/serializers/deployment_entity.rb index 7cd913d057e..851d7a95d40 100644 --- a/app/serializers/deployment_entity.rb +++ b/app/serializers/deployment_entity.rb @@ -28,7 +28,7 @@ class DeploymentEntity < Grape::Entity expose :deployed_by, as: :user, using: UserEntity expose :deployable, if: -> (deployment) { deployment.deployable.present? } do |deployment, opts| - deployment.deployable.yield_self do |deployable| + deployment.deployable.then do |deployable| if include_details? Ci::JobEntity.represent(deployable, opts) elsif can_read_deployables? diff --git a/app/serializers/group_link/group_group_link_entity.rb b/app/serializers/group_link/group_group_link_entity.rb index d5d7eea74ea..f855d89f593 100644 --- a/app/serializers/group_link/group_group_link_entity.rb +++ b/app/serializers/group_link/group_group_link_entity.rb @@ -4,7 +4,7 @@ module GroupLink class GroupGroupLinkEntity < GroupLink::GroupLinkEntity include RequestAwareEntity - expose :source do |group_link| + expose :source, if: ->(group_link) { can_read_shared_group?(group_link) } do |group_link| GroupEntity.represent(group_link.shared_from, only: [:id, :full_name, :web_url]) end diff --git a/app/serializers/group_link/group_link_entity.rb b/app/serializers/group_link/group_link_entity.rb index 4cc7e9f3c8c..66645e736a9 100644 --- a/app/serializers/group_link/group_link_entity.rb +++ b/app/serializers/group_link/group_link_entity.rb @@ -19,16 +19,28 @@ module GroupLink group_link.class.access_options end + expose :is_shared_with_group_private do |group_link| + !can_read_shared_group?(group_link) + end + expose :shared_with_group do - expose :avatar_url do |group_link| + expose :avatar_url, if: ->(group_link) { can_read_shared_group?(group_link) } do |group_link| group_link.shared_with_group.avatar_url(only_path: false, size: Member::AVATAR_SIZE) end - expose :web_url do |group_link| + expose :web_url, if: ->(group_link) { can_read_shared_group?(group_link) } do |group_link| group_link.shared_with_group.web_url end - expose :shared_with_group, merge: true, using: GroupBasicEntity + # We have to expose shared_with_group.id because we use this to get distinct + # with ancestors + expose :shared_with_group, merge: true do |group_link| + if can_read_shared_group?(group_link) + GroupBasicEntity.represent(group_link.shared_with_group) + else + GroupBasicEntity.represent(group_link.shared_with_group, only: [:id]) + end + end end expose :can_update do |group_link, options| @@ -45,6 +57,10 @@ module GroupLink private + def can_read_shared_group?(group_link) + can?(current_user, :read_shared_with_group, group_link) + end + def current_user options[:current_user] end diff --git a/app/serializers/group_link/project_group_link_entity.rb b/app/serializers/group_link/project_group_link_entity.rb index d246bff1c58..fbad69bf2c5 100644 --- a/app/serializers/group_link/project_group_link_entity.rb +++ b/app/serializers/group_link/project_group_link_entity.rb @@ -4,7 +4,7 @@ module GroupLink class ProjectGroupLinkEntity < GroupLink::GroupLinkEntity include RequestAwareEntity - expose :source do |group_link| + expose :source, if: ->(group_link) { can_read_shared_group?(group_link) } do |group_link| ProjectEntity.represent(group_link.shared_from, only: [:id, :full_name]) end diff --git a/app/serializers/issue_entity.rb b/app/serializers/issue_entity.rb index 657af578c7f..9a55e761bf0 100644 --- a/app/serializers/issue_entity.rb +++ b/app/serializers/issue_entity.rb @@ -73,11 +73,11 @@ class IssueEntity < IssuableEntity end expose :confidential_issues_docs_path, if: -> (issue) { issue.confidential? } do |issue| - help_page_path('user/project/issues/confidential_issues.md') + help_page_path('user/project/issues/confidential_issues') end expose :locked_discussion_docs_path, if: -> (issue) { issue.discussion_locked? } do |issue| - help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue') + help_page_path('user/discussions/index', anchor: 'prevent-comments-by-locking-an-issue') end expose :is_project_archived do |issue| @@ -85,7 +85,7 @@ class IssueEntity < IssuableEntity end expose :archived_project_docs_path, if: -> (issue) { issue.project.archived? } do |issue| - help_page_path('user/project/settings/index.md', anchor: 'archive-a-project') + help_page_path('user/project/settings/index', anchor: 'archive-a-project') end expose :issue_email_participants do |issue| diff --git a/app/serializers/member_entity.rb b/app/serializers/member_entity.rb index 8e5d352e413..a710df9ce5b 100644 --- a/app/serializers/member_entity.rb +++ b/app/serializers/member_entity.rb @@ -32,8 +32,11 @@ class MemberEntity < Grape::Entity expose :access_level do expose :human_access, as: :string_value expose :access_level, as: :integer_value + expose :member_role_id end + expose :custom_permissions + expose :source do |member| GroupEntity.represent(member.source, only: [:id, :full_name, :web_url]) end @@ -42,6 +45,8 @@ class MemberEntity < Grape::Entity expose :valid_level_roles, as: :valid_roles + expose :valid_member_roles, as: :custom_roles + expose :user, if: -> (member) { member.user.present? } do |member, options| MemberUserEntity.represent(member.user, options) end diff --git a/app/serializers/merge_request_noteable_entity.rb b/app/serializers/merge_request_noteable_entity.rb index aac90c20b53..04b801e29ad 100644 --- a/app/serializers/merge_request_noteable_entity.rb +++ b/app/serializers/merge_request_noteable_entity.rb @@ -49,14 +49,10 @@ class MergeRequestNoteableEntity < IssuableEntity expose :can_update do |merge_request| can?(current_user, :update_merge_request, merge_request) end - - expose :can_approve do |merge_request| - merge_request.eligible_for_approval_by?(current_user) - end end expose :locked_discussion_docs_path, if: -> (merge_request) { merge_request.discussion_locked? } do |merge_request| - help_page_path('user/discussions/index.md', anchor: 'prevent-comments-by-locking-an-issue') + help_page_path('user/discussions/index', anchor: 'prevent-comments-by-locking-an-issue') end expose :is_project_archived do |merge_request| @@ -66,7 +62,7 @@ class MergeRequestNoteableEntity < IssuableEntity expose :project_id expose :archived_project_docs_path, if: -> (merge_request) { merge_request.project.archived? } do |merge_request| - help_page_path('user/project/settings/index.md', anchor: 'archive-a-project') + help_page_path('user/project/settings/index', anchor: 'archive-a-project') end private diff --git a/app/serializers/merge_request_widget_entity.rb b/app/serializers/merge_request_widget_entity.rb index cf984207ad1..95072ae815e 100644 --- a/app/serializers/merge_request_widget_entity.rb +++ b/app/serializers/merge_request_widget_entity.rb @@ -48,15 +48,15 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :conflicts_docs_path do |merge_request| - help_page_path('user/project/merge_requests/conflicts.md') + help_page_path('user/project/merge_requests/conflicts') end expose :reviewing_and_managing_merge_requests_docs_path do |merge_request| - help_page_path('user/project/merge_requests/reviews/index.md', anchor: "checkout-merge-requests-locally-through-the-head-ref") + help_page_path('user/project/merge_requests/reviews/index', anchor: "checkout-merge-requests-locally-through-the-head-ref") end expose :merge_request_pipelines_docs_path do |merge_request| - help_page_path('ci/pipelines/merge_request_pipelines.md') + help_page_path('ci/pipelines/merge_request_pipelines') end expose :ci_environments_status_path do |merge_request| @@ -129,7 +129,7 @@ class MergeRequestWidgetEntity < Grape::Entity end expose :security_reports_docs_path do |merge_request| - help_page_path('user/application_security/index.md', anchor: 'view-security-scan-information-in-merge-requests') + help_page_path('user/application_security/index', anchor: 'view-security-scan-information-in-merge-requests') end expose :enabled_reports do |merge_request| diff --git a/app/serializers/review_app_setup_entity.rb b/app/serializers/review_app_setup_entity.rb index 3a21fe24d9e..1fde31bc847 100644 --- a/app/serializers/review_app_setup_entity.rb +++ b/app/serializers/review_app_setup_entity.rb @@ -13,6 +13,8 @@ class ReviewAppSetupEntity < Grape::Entity YAML.safe_load(File.read(Rails.root.join('lib', 'gitlab', 'ci', 'snippets', 'review_app_default.yml'))).to_s end + expose :has_review_app?, as: :has_review_app + private def current_user diff --git a/app/services/activity_pub/accept_follow_service.rb b/app/services/activity_pub/accept_follow_service.rb new file mode 100644 index 00000000000..0ec440fa972 --- /dev/null +++ b/app/services/activity_pub/accept_follow_service.rb @@ -0,0 +1,55 @@ +# frozen_string_literal: true + +module ActivityPub + class AcceptFollowService + MissingInboxURLError = Class.new(StandardError) + + attr_reader :subscription, :actor + + def initialize(subscription, actor) + @subscription = subscription + @actor = actor + end + + def execute + return if subscription.accepted? + raise MissingInboxURLError unless subscription.subscriber_inbox_url.present? + + upload_accept_activity + subscription.accepted! + end + + private + + def upload_accept_activity + body = Gitlab::Json::LimitedEncoder.encode(payload, limit: 1.megabyte) + + begin + Gitlab::HTTP.post(subscription.subscriber_inbox_url, body: body, headers: headers) + rescue StandardError => e + raise ThirdPartyError, e.message + end + end + + def payload + follow = subscription.payload.dup + follow.delete('@context') + + { + '@context': 'https://www.w3.org/ns/activitystreams', + id: "#{actor}#follow/#{subscription.id}/accept", + type: 'Accept', + actor: actor, + object: follow + } + end + + def headers + { + 'User-Agent' => "GitLab/#{Gitlab::VERSION}", + 'Content-Type' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"', + 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + } + end + end +end diff --git a/app/services/activity_pub/inbox_resolver_service.rb b/app/services/activity_pub/inbox_resolver_service.rb new file mode 100644 index 00000000000..c2bd2112b16 --- /dev/null +++ b/app/services/activity_pub/inbox_resolver_service.rb @@ -0,0 +1,50 @@ +# frozen_string_literal: true + +module ActivityPub + class InboxResolverService + attr_reader :subscription + + def initialize(subscription) + @subscription = subscription + end + + def execute + profile = subscriber_profile + unless profile.has_key?('inbox') && profile['inbox'].is_a?(String) + raise ThirdPartyError, 'Inbox parameter absent or invalid' + end + + subscription.subscriber_inbox_url = profile['inbox'] + subscription.shared_inbox_url = profile.dig('entrypoints', 'sharedInbox') + subscription.save! + end + + private + + def subscriber_profile + raw_data = download_subscriber_profile + + begin + profile = Gitlab::Json.parse(raw_data) + rescue JSON::ParserError => e + raise ThirdPartyError, e.message + end + + profile + end + + def download_subscriber_profile + begin + response = Gitlab::HTTP.get(subscription.subscriber_url, + headers: { + 'Accept' => 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"' + } + ) + rescue StandardError => e + raise ThirdPartyError, e.message + end + + response.body + end + end +end diff --git a/app/services/activity_pub/third_party_error.rb b/app/services/activity_pub/third_party_error.rb new file mode 100644 index 00000000000..473a67984a4 --- /dev/null +++ b/app/services/activity_pub/third_party_error.rb @@ -0,0 +1,5 @@ +# frozen_string_literal: true + +module ActivityPub + ThirdPartyError = Class.new(StandardError) +end diff --git a/app/services/admin/plan_limits/update_service.rb b/app/services/admin/plan_limits/update_service.rb index 24ce3c4095f..7412f9852d1 100644 --- a/app/services/admin/plan_limits/update_service.rb +++ b/app/services/admin/plan_limits/update_service.rb @@ -51,35 +51,63 @@ module Admin def validate_notification_limit return unless parsed_params.include?(:notification_limit) - return if notification_limit >= storage_size_limit && notification_limit <= enforcement_limit + return if unlimited_value?(:notification_limit) - plan_limits.errors.add(:notification_limit, "must be greater than or equal to " \ - "storage_size_limit (Dashboard limit): #{storage_size_limit} " \ - "and less than or equal to enforcement_limit: #{enforcement_limit}") + if storage_size_limit > 0 && notification_limit < storage_size_limit + plan_limits.errors.add( + :notification_limit, "must be greater than or equal to the dashboard limit (#{storage_size_limit})" + ) + end + + return unless enforcement_limit > 0 && notification_limit > enforcement_limit + + plan_limits.errors.add( + :notification_limit, "must be less than or equal to the enforcement limit (#{enforcement_limit})" + ) end def validate_enforcement_limit return unless parsed_params.include?(:enforcement_limit) - return if enforcement_limit >= storage_size_limit && enforcement_limit >= notification_limit + return if unlimited_value?(:enforcement_limit) + + if storage_size_limit > 0 && enforcement_limit < storage_size_limit + plan_limits.errors.add( + :enforcement_limit, "must be greater than or equal to the dashboard limit (#{storage_size_limit})" + ) + end + + return unless notification_limit > 0 && enforcement_limit < notification_limit - plan_limits.errors.add(:enforcement_limit, "must be greater than or equal to " \ - "storage_size_limit (Dashboard limit): #{storage_size_limit} and " \ - "greater than or equal to notification_limit: #{notification_limit}") + plan_limits.errors.add( + :enforcement_limit, "must be greater than or equal to the notification limit (#{notification_limit})" + ) end def validate_storage_size_limit return unless parsed_params.include?(:storage_size_limit) - return if storage_size_limit <= enforcement_limit && storage_size_limit <= notification_limit + return if unlimited_value?(:storage_size_limit) - plan_limits.errors.add(:storage_size_limit, "(Dashboard limit) must be less than or equal to " \ - "enforcement_limit: #{enforcement_limit} " \ - "and notification_limit: #{notification_limit}") + if enforcement_limit > 0 && storage_size_limit > enforcement_limit + plan_limits.errors.add( + :dashboard_limit, "must be less than or equal to the enforcement limit (#{enforcement_limit})" + ) + end + + return unless notification_limit > 0 && storage_size_limit > notification_limit + + plan_limits.errors.add( + :dashboard_limit, "must be less than or equal to the notification limit (#{notification_limit})" + ) end # Overridden in EE def parsed_params params end + + def unlimited_value?(limit) + parsed_params[limit] == 0 + end end end end diff --git a/app/services/auto_merge/base_service.rb b/app/services/auto_merge/base_service.rb index d0fde43138a..467a4ed2621 100644 --- a/app/services/auto_merge/base_service.rb +++ b/app/services/auto_merge/base_service.rb @@ -61,15 +61,19 @@ module AutoMerge merge_request.can_be_merged_by?(current_user) && merge_request.open? && !merge_request.broken? && - (skip_draft_check(merge_request) || !merge_request.draft?) && - (skip_discussions_check(merge_request) || merge_request.mergeable_discussions_state?) && - (skip_blocked_check(merge_request) || !merge_request.merge_blocked_by_other_mrs?) && + overrideable_available_for_checks(merge_request) && yield end end private + def overrideable_available_for_checks(merge_request) + !merge_request.draft? && + merge_request.mergeable_discussions_state? && + !merge_request.merge_blocked_by_other_mrs? + end + # Overridden in child classes def notify(merge_request) end @@ -109,20 +113,5 @@ module AutoMerge def track_exception(error, merge_request) Gitlab::ErrorTracking.track_exception(error, merge_request_id: merge_request&.id) end - - # Will skip the draft check or not when checking if strategy is available - def skip_draft_check(merge_request) - false - end - - # Will skip the blocked check or not when checking if strategy is available - def skip_blocked_check(merge_request) - false - end - - # Will skip the discussions check or not when checking if strategy is available - def skip_discussions_check(merge_request) - false - end end end diff --git a/app/services/boards/lists/move_service.rb b/app/services/boards/lists/move_service.rb index 4bb7b4dbc6d..4715f1276e3 100644 --- a/app/services/boards/lists/move_service.rb +++ b/app/services/boards/lists/move_service.rb @@ -22,8 +22,11 @@ module Boards attr_reader :board, :old_position, :new_position def valid_move? - new_position.present? && new_position != old_position && - new_position >= 0 && new_position <= board.lists.movable.last.position + new_position.present? && new_position != old_position && new_position.between?(0, max_position) + end + + def max_position + board.lists.movable.maximum(:position) end def reorder_intermediate_lists diff --git a/app/services/bulk_imports/batched_relation_export_service.rb b/app/services/bulk_imports/batched_relation_export_service.rb index 778510f2e35..c7c01c80fbf 100644 --- a/app/services/bulk_imports/batched_relation_export_service.rb +++ b/app/services/bulk_imports/batched_relation_export_service.rb @@ -26,8 +26,6 @@ module BulkImports start_export! export.batches.destroy_all # rubocop: disable Cop/DestroyAll enqueue_batch_exports - rescue StandardError => e - fail_export!(e) ensure FinishBatchedRelationExportWorker.perform_async(export.id) end @@ -81,11 +79,5 @@ module BulkImports def find_or_create_batch(batch_number) export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord end - - def fail_export!(exception) - Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name) - - export.update!(status_event: 'fail_op', error: exception.message.truncate(255)) - end end end diff --git a/app/services/bulk_imports/file_download_service.rb b/app/services/bulk_imports/file_download_service.rb index 1f2437d783d..cc2d544198b 100644 --- a/app/services/bulk_imports/file_download_service.rb +++ b/app/services/bulk_imports/file_download_service.rb @@ -83,7 +83,7 @@ module BulkImports end def raise_error(message) - logger.warn(message: message, response_headers: response_headers, importer: 'gitlab_migration') + logger.warn(message: message, response_headers: response_headers) raise ServiceError, message end @@ -112,7 +112,7 @@ module BulkImports end def logger - @logger ||= Gitlab::Import::Logger.build + @logger ||= Logger.build end def validate_url diff --git a/app/services/bulk_imports/process_service.rb b/app/services/bulk_imports/process_service.rb index 14c5545cfd5..7a6a883f1a9 100644 --- a/app/services/bulk_imports/process_service.rb +++ b/app/services/bulk_imports/process_service.rb @@ -20,10 +20,6 @@ module BulkImports process_bulk_import re_enqueue - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, bulk_import_id: bulk_import.id) - - bulk_import.fail_op end private @@ -114,16 +110,15 @@ module BulkImports bulk_import_id: entity.bulk_import_id, bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, - pipeline_name: pipeline[:pipeline], + pipeline_class: pipeline[:pipeline], minimum_source_version: minimum_version, maximum_source_version: maximum_version, - source_version: entity.source_version.to_s, - importer: 'gitlab_migration' + source_version: entity.source_version.to_s ) end def logger - @logger ||= Gitlab::Import::Logger.build + @logger ||= Logger.build end end end diff --git a/app/services/bulk_imports/relation_batch_export_service.rb b/app/services/bulk_imports/relation_batch_export_service.rb index c7164d7c304..3f98d49aa1b 100644 --- a/app/services/bulk_imports/relation_batch_export_service.rb +++ b/app/services/bulk_imports/relation_batch_export_service.rb @@ -4,9 +4,9 @@ module BulkImports class RelationBatchExportService include Gitlab::ImportExport::CommandLineUtil - def initialize(user_id, batch_id) - @user = User.find(user_id) - @batch = BulkImports::ExportBatch.find(batch_id) + def initialize(user, batch) + @user = user + @batch = batch @config = FileTransfer.config_for(portable) end @@ -19,8 +19,6 @@ module BulkImports upload_compressed_file finish_batch! - rescue StandardError => e - fail_batch!(e) ensure FileUtils.remove_entry(export_path) end @@ -72,12 +70,6 @@ module BulkImports batch.update!(status_event: 'finish', objects_count: exported_objects_count, error: nil) end - def fail_batch!(exception) - Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name) - - batch.update!(status_event: 'fail_op', error: exception.message.truncate(255)) - end - def exported_filepath File.join(export_path, exported_filename) end diff --git a/app/services/bulk_imports/relation_export_service.rb b/app/services/bulk_imports/relation_export_service.rb index 91640496440..6db5ef3e9ec 100644 --- a/app/services/bulk_imports/relation_export_service.rb +++ b/app/services/bulk_imports/relation_export_service.rb @@ -42,8 +42,6 @@ module BulkImports yield export finish_export!(export) - rescue StandardError => e - fail_export!(export, e) end def export_service @@ -87,12 +85,6 @@ module BulkImports export.update!(status_event: 'finish', batched: false, error: nil) end - def fail_export!(export, exception) - Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name) - - export&.update(status_event: 'fail_op', error: exception.class, batched: false) - end - def exported_filepath File.join(export_path, export_service.exported_filename) end diff --git a/app/services/ci/build_cancel_service.rb b/app/services/ci/build_cancel_service.rb index a23418ed738..834d4febd10 100644 --- a/app/services/ci/build_cancel_service.rb +++ b/app/services/ci/build_cancel_service.rb @@ -21,7 +21,7 @@ module Ci attr_reader :build, :user def allowed? - user.can?(:update_build, build) + user.can?(:cancel_build, build) end def forbidden diff --git a/app/services/ci/cancel_pipeline_service.rb b/app/services/ci/cancel_pipeline_service.rb index b5c8c00273e..38053b13921 100644 --- a/app/services/ci/cancel_pipeline_service.rb +++ b/app/services/ci/cancel_pipeline_service.rb @@ -8,27 +8,23 @@ module Ci ## # @cascade_to_children - if true cancels all related child pipelines for parent child pipelines - # @auto_canceled_by_pipeline_id - store the pipeline_id of the pipeline that triggered cancellation + # @auto_canceled_by_pipeline - store the pipeline_id of the pipeline that triggered cancellation # @execute_async - if true cancel the children asyncronously def initialize( pipeline:, current_user:, cascade_to_children: true, - auto_canceled_by_pipeline_id: nil, + auto_canceled_by_pipeline: nil, execute_async: true) @pipeline = pipeline @current_user = current_user @cascade_to_children = cascade_to_children - @auto_canceled_by_pipeline_id = auto_canceled_by_pipeline_id + @auto_canceled_by_pipeline = auto_canceled_by_pipeline @execute_async = execute_async end def execute - unless can?(current_user, :update_pipeline, pipeline) - return ServiceResponse.error( - message: 'Insufficient permissions to cancel the pipeline', - reason: :insufficient_permissions) - end + return permission_error_response unless can?(current_user, :cancel_pipeline, pipeline) force_execute end @@ -45,7 +41,7 @@ module Ci log_pipeline_being_canceled - pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline_id) if @auto_canceled_by_pipeline_id + pipeline.update_column(:auto_canceled_by_id, @auto_canceled_by_pipeline.id) if @auto_canceled_by_pipeline cancel_jobs(pipeline.cancelable_statuses) return ServiceResponse.success unless cascade_to_children? @@ -65,7 +61,7 @@ module Ci Gitlab::AppJsonLogger.info( event: 'pipeline_cancel_running', pipeline_id: pipeline.id, - auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id, + auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline&.id, cascade_to_children: cascade_to_children?, execute_async: execute_async?, **Gitlab::ApplicationContext.current @@ -89,21 +85,34 @@ module Ci relation = CommitStatus.id_in(batch) Preloaders::CommitStatusPreloader.new(relation).execute(preloaded_relations) - relation.each do |job| - job.auto_canceled_by_id = @auto_canceled_by_pipeline_id if @auto_canceled_by_pipeline_id - job.cancel - end + relation.each { |job| cancel_job(job) } end end end + def cancel_job(job) + if @auto_canceled_by_pipeline + job.auto_canceled_by_id = @auto_canceled_by_pipeline.id + job.auto_canceled_by_partition_id = @auto_canceled_by_pipeline.partition_id + end + + job.cancel + end + + def permission_error_response + ServiceResponse.error( + message: 'Insufficient permissions to cancel the pipeline', + reason: :insufficient_permissions + ) + end + # For parent child-pipelines only (not multi-project) def cancel_children pipeline.all_child_pipelines.each do |child_pipeline| if execute_async? ::Ci::CancelPipelineWorker.perform_async( child_pipeline.id, - @auto_canceled_by_pipeline_id + @auto_canceled_by_pipeline&.id ) else # cascade_to_children is false because we iterate through children @@ -113,7 +122,7 @@ module Ci current_user: nil, cascade_to_children: false, execute_async: execute_async?, - auto_canceled_by_pipeline_id: @auto_canceled_by_pipeline_id + auto_canceled_by_pipeline: @auto_canceled_by_pipeline ).force_execute end end diff --git a/app/services/ci/catalog/resources/create_service.rb b/app/services/ci/catalog/resources/create_service.rb new file mode 100644 index 00000000000..89367c70e82 --- /dev/null +++ b/app/services/ci/catalog/resources/create_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class CreateService + include Gitlab::Allowable + + attr_reader :project, :current_user + + def initialize(project, user) + @current_user = user + @project = project + end + + def execute + raise Gitlab::Access::AccessDeniedError unless can?(current_user, :add_catalog_resource, project) + + catalog_resource = Ci::Catalog::Resource.new(project: project) + + if catalog_resource.valid? + catalog_resource.save! + ServiceResponse.success(payload: catalog_resource) + else + ServiceResponse.error(message: catalog_resource.errors.full_messages.join(', ')) + end + end + end + end + end +end diff --git a/app/services/ci/catalog/resources/release_service.rb b/app/services/ci/catalog/resources/release_service.rb new file mode 100644 index 00000000000..ad77bff3ef9 --- /dev/null +++ b/app/services/ci/catalog/resources/release_service.rb @@ -0,0 +1,46 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + class ReleaseService + def initialize(release) + @release = release + @project = release.project + @errors = [] + end + + def execute + validate_catalog_resource + create_version + + if errors.empty? + ServiceResponse.success + else + ServiceResponse.error(message: errors.join(', ')) + end + end + + private + + attr_reader :project, :errors, :release + + def validate_catalog_resource + response = Ci::Catalog::Resources::ValidateService.new(project, release.sha).execute + return if response.success? + + errors << response.message + end + + def create_version + return if errors.present? + + response = Ci::Catalog::Resources::Versions::CreateService.new(release).execute + return if response.success? + + errors << response.message + end + end + end + end +end diff --git a/app/services/ci/catalog/resources/validate_service.rb b/app/services/ci/catalog/resources/validate_service.rb index 9e8986ba6fc..0e842fb7405 100644 --- a/app/services/ci/catalog/resources/validate_service.rb +++ b/app/services/ci/catalog/resources/validate_service.rb @@ -4,7 +4,7 @@ module Ci module Catalog module Resources class ValidateService - attr_reader :project + MINIMUM_AMOUNT_OF_COMPONENTS = 1 def initialize(project, ref) @project = project @@ -13,30 +13,38 @@ module Ci end def execute - check_project_readme - check_project_description + verify_presence_project_readme + verify_presence_project_description + scan_directory_for_components if errors.empty? ServiceResponse.success else - ServiceResponse.error(message: errors.join(' , ')) + ServiceResponse.error(message: errors.join(', ')) end end private - attr_reader :ref, :errors + attr_reader :project, :ref, :errors - def check_project_description + def verify_presence_project_readme + return if project_has_readme? + + errors << 'Project must have a README' + end + + def verify_presence_project_description return if project.description.present? errors << 'Project must have a description' end - def check_project_readme - return if project_has_readme? + def scan_directory_for_components + return if Ci::Catalog::ComponentsProject.new(project).fetch_component_paths(ref, + limit: MINIMUM_AMOUNT_OF_COMPONENTS).any? - errors << 'Project must have a README' + errors << 'Project must contain components. Ensure you are using the correct directory structure' end def project_has_readme? diff --git a/app/services/ci/catalog/resources/versions/create_service.rb b/app/services/ci/catalog/resources/versions/create_service.rb new file mode 100644 index 00000000000..863bad43271 --- /dev/null +++ b/app/services/ci/catalog/resources/versions/create_service.rb @@ -0,0 +1,111 @@ +# frozen_string_literal: true + +module Ci + module Catalog + module Resources + module Versions + class CreateService + def initialize(release) + @project = release.project + @release = release + @errors = [] + @version = nil + @components_project = Ci::Catalog::ComponentsProject.new(project) + end + + def execute + build_catalog_resource_version + fetch_and_build_components if Feature.enabled?(:ci_catalog_create_metadata, project) + publish_catalog_resource! + + if errors.empty? + ServiceResponse.success + else + ServiceResponse.error(message: errors.flatten.first(10).join(', ')) + end + end + + private + + attr_reader :project, :errors, :release, :components_project + + def build_catalog_resource_version + return error('Project is not a catalog resource') unless project.catalog_resource + + @version = Ci::Catalog::Resources::Version.new( + release: release, + catalog_resource: project.catalog_resource, + project: project + ) + end + + def fetch_and_build_components + return if errors.present? + + max_components = Ci::Catalog::ComponentsProject::COMPONENTS_LIMIT + component_paths = components_project.fetch_component_paths(release.sha, limit: max_components + 1) + + if component_paths.size > max_components + return error("Release cannot contain more than #{max_components} components") + end + + build_components(component_paths) + end + + def build_components(component_paths) + paths_with_oids = component_paths.map { |path| [release.sha, path] } + blobs = project.repository.blobs_at(paths_with_oids) + + blobs.each do |blob| + metadata = extract_metadata(blob) + build_catalog_resource_component(metadata) + end + rescue ::Gitlab::Config::Loader::FormatError => e + error(e) + end + + def extract_metadata(blob) + { + name: components_project.extract_component_name(blob.path), + inputs: components_project.extract_inputs(blob.data), + path: blob.path + } + end + + def build_catalog_resource_component(metadata) + return if errors.present? + + component = @version.components.build( + name: metadata[:name], + project: @version.project, + inputs: metadata[:inputs], + catalog_resource: @version.catalog_resource, + path: metadata[:path], + created_at: Time.current + ) + + return if component.valid? + + error("Build component error: #{component.errors.full_messages.join(', ')}") + end + + def publish_catalog_resource! + return if errors.present? + + ::Ci::Catalog::Resources::Version.transaction do + BulkInsertableAssociations.with_bulk_insert do + @version.save! + end + + project.catalog_resource.publish! + end + end + + def error(message) + errors << message + end + end + end + end + end +end diff --git a/app/services/ci/destroy_pipeline_service.rb b/app/services/ci/destroy_pipeline_service.rb index a9d2e17657e..7adf573687a 100644 --- a/app/services/ci/destroy_pipeline_service.rb +++ b/app/services/ci/destroy_pipeline_service.rb @@ -28,3 +28,5 @@ module Ci end end end + +Ci::DestroyPipelineService.prepend_mod diff --git a/app/services/ci/enqueue_job_service.rb b/app/services/ci/enqueue_job_service.rb index 9e3bea3fd28..db616473336 100644 --- a/app/services/ci/enqueue_job_service.rb +++ b/app/services/ci/enqueue_job_service.rb @@ -11,11 +11,14 @@ module Ci end def execute(&transition) - job.user = current_user - job.job_variables_attributes = variables if variables - transition ||= ->(job) { job.enqueue! } - Gitlab::OptimisticLocking.retry_lock(job, name: 'ci_enqueue_job', &transition) + + Gitlab::OptimisticLocking.retry_lock(job, name: 'ci_enqueue_job') do |job| + job.user = current_user + job.job_variables_attributes = variables if variables + + transition.call(job) + end ResetSkippedJobsService.new(job.project, current_user).execute(job) diff --git a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb index c18984953a1..224b2d96205 100644 --- a/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb +++ b/app/services/ci/pipeline_creation/cancel_redundant_pipelines_service.rb @@ -88,7 +88,7 @@ module Ci ::Ci::CancelPipelineService.new( pipeline: cancelable_pipeline, current_user: nil, - auto_canceled_by_pipeline_id: pipeline.id, + auto_canceled_by_pipeline: pipeline, cascade_to_children: false ).force_execute end diff --git a/app/services/ci/pipelines/update_metadata_service.rb b/app/services/ci/pipelines/update_metadata_service.rb new file mode 100644 index 00000000000..2f2d648c13d --- /dev/null +++ b/app/services/ci/pipelines/update_metadata_service.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module Ci + module Pipelines + class UpdateMetadataService + def initialize(pipeline, params) + @pipeline = pipeline + @params = params + end + + def execute + metadata = pipeline.pipeline_metadata + + metadata = pipeline.build_pipeline_metadata(project: pipeline.project) if metadata.nil? + + params[:name] = params[:name].strip if params.key?(:name) + + if metadata.update(params) + ServiceResponse.success(message: 'Pipeline metadata was updated', payload: pipeline) + else + ServiceResponse.error(message: 'Failed to update pipeline', payload: metadata.errors.full_messages, + reason: :bad_request) + end + end + + private + + attr_reader :pipeline, :params + end + end +end diff --git a/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb b/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb index 319186ce030..4e9e9a2effe 100644 --- a/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb +++ b/app/services/ci/refs/enqueue_pipelines_to_unlock_service.rb @@ -7,13 +7,12 @@ module Ci BATCH_SIZE = 50 ENQUEUE_INTERVAL_SECONDS = 0.1 + EXCLUDED_IDS_LIMIT = 1000 def execute(ci_ref, before_pipeline: nil) - pipelines_scope = ci_ref.pipelines.artifacts_locked - pipelines_scope = pipelines_scope.before_pipeline(before_pipeline) if before_pipeline total_new_entries = 0 - pipelines_scope.each_batch(of: BATCH_SIZE) do |batch| + pipelines_scope(ci_ref, before_pipeline).each_batch(of: BATCH_SIZE) do |batch| pipeline_ids = batch.pluck(:id) # rubocop: disable CodeReuse/ActiveRecord total_added = Ci::UnlockPipelineRequest.enqueue(pipeline_ids) total_new_entries += total_added @@ -27,6 +26,34 @@ module Ci total_new_entries: total_new_entries ) end + + private + + def pipelines_scope(ci_ref, before_pipeline) + scope = ci_ref.pipelines.artifacts_locked + + if before_pipeline + # We use `same_family_pipeline_ids.map(&:id)` to force run the query and + # specifically pass the array of IDs to the NOT IN condition. If not, we would + # end up running the subquery for same_family_pipeline_ids on each batch instead. + excluded_ids = before_pipeline.same_family_pipeline_ids.map(&:id) + scope = scope.created_before_id(before_pipeline.id) + + # When unlocking previous pipelines, we still want to keep the + # last successful CI source pipeline locked. + # If before_pipeline is not provided, like in the case of deleting a ref, + # we want to unlock all pipelines instead. + ci_ref.last_successful_ci_source_pipeline.try do |pipeline| + excluded_ids.concat(pipeline.same_family_pipeline_ids.map(&:id)) + end + + # We add a limit to the excluded IDs just to be safe and avoid any + # arity issues with the NOT IN query. + scope = scope.where.not(id: excluded_ids.take(EXCLUDED_IDS_LIMIT)) # rubocop: disable CodeReuse/ActiveRecord + end + + scope + end end end end diff --git a/app/services/ci/retry_job_service.rb b/app/services/ci/retry_job_service.rb index d7c3e9e7f64..a8ea5ac6df0 100644 --- a/app/services/ci/retry_job_service.rb +++ b/app/services/ci/retry_job_service.rb @@ -39,10 +39,6 @@ module Ci ::Ci::CopyCrossDatabaseAssociationsService.new.execute(job, new_job) - if Feature.disabled?(:create_deployment_only_for_processable_jobs, project) - ::Deployments::CreateForJobService.new.execute(new_job) - end - ::MergeRequests::AddTodoWhenBuildFailsService .new(project: project) .close(new_job) diff --git a/app/services/concerns/update_repository_storage_methods.rb b/app/services/concerns/update_repository_storage_methods.rb index 50963cc58b2..aff36d6943e 100644 --- a/app/services/concerns/update_repository_storage_methods.rb +++ b/app/services/concerns/update_repository_storage_methods.rb @@ -32,9 +32,9 @@ module UpdateRepositoryStorageMethods end end - repository_storage_move.transaction do - repository_storage_move.finish_replication! + repository_storage_move.finish_replication! + repository_storage_move.transaction do track_repository(destination_storage_name) end diff --git a/app/services/container_registry/protection/create_rule_service.rb b/app/services/container_registry/protection/create_rule_service.rb new file mode 100644 index 00000000000..34ec6f42b19 --- /dev/null +++ b/app/services/container_registry/protection/create_rule_service.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +module ContainerRegistry + module Protection + class CreateRuleService < BaseService + ALLOWED_ATTRIBUTES = %i[ + container_path_pattern + push_protected_up_to_access_level + delete_protected_up_to_access_level + ].freeze + + def execute + unless can?(current_user, :admin_container_image, project) + error_message = _('Unauthorized to create a container registry protection rule') + return service_response_error(message: error_message) + end + + container_registry_protection_rule = + project.container_registry_protection_rules.create(params.slice(*ALLOWED_ATTRIBUTES)) + + unless container_registry_protection_rule.persisted? + return service_response_error(message: container_registry_protection_rule.errors.full_messages.to_sentence) + end + + ServiceResponse.success(payload: { container_registry_protection_rule: container_registry_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { container_registry_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/draft_notes/publish_service.rb b/app/services/draft_notes/publish_service.rb index a7a2ad63c1c..5ba7f829c8e 100644 --- a/app/services/draft_notes/publish_service.rb +++ b/app/services/draft_notes/publish_service.rb @@ -81,7 +81,9 @@ module DraftNotes end def set_reviewed - ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user).execute(merge_request) + return if Feature.enabled?(:mr_request_changes, current_user) + + ::MergeRequests::UpdateReviewerStateService.new(project: project, current_user: current_user).execute(merge_request, "reviewed") end def capture_diff_note_positions(notes) diff --git a/app/services/environments/auto_recover_service.rb b/app/services/environments/auto_recover_service.rb new file mode 100644 index 00000000000..d52f90bbe50 --- /dev/null +++ b/app/services/environments/auto_recover_service.rb @@ -0,0 +1,44 @@ +# frozen_string_literal: true + +module Environments + class AutoRecoverService + include ::Gitlab::ExclusiveLeaseHelpers + include ::Gitlab::LoopHelpers + + BATCH_SIZE = 100 + LOOP_TIMEOUT = 45.minutes + LOOP_LIMIT = 1000 + EXCLUSIVE_LOCK_KEY = 'environments:auto_recover:lock' + LOCK_TIMEOUT = 50.minutes + + ## + # Recover environments that are stuck stopping on a GitLab instance + # + # This auto stop process cannot run for more than 45 minutes. This is for + # preventing multiple `AutoStopCronWorker` CRON jobs run concurrently, + # which is scheduled at every hour. + def execute + in_lock(EXCLUSIVE_LOCK_KEY, ttl: LOCK_TIMEOUT, retries: 1) do + loop_until(timeout: LOOP_TIMEOUT, limit: LOOP_LIMIT) do + recover_in_batch + end + end + end + + private + + def recover_in_batch + environments = Environment.preload_project.select(:id, :project_id).long_stopping.limit(BATCH_SIZE) + + return false if environments.empty? + + Environments::AutoRecoverWorker.bulk_perform_async_with_contexts( + environments, + arguments_proc: ->(environment) { environment.id }, + context_proc: ->(environment) { { project: environment.project } } + ) + + true + end + end +end diff --git a/app/services/git/branch_hooks_service.rb b/app/services/git/branch_hooks_service.rb index a2eb4f1f396..c6214311692 100644 --- a/app/services/git/branch_hooks_service.rb +++ b/app/services/git/branch_hooks_service.rb @@ -110,7 +110,6 @@ module Git end def track_ci_config_change_event - return unless ::ServicePing::ServicePingSettings.enabled? return unless default_branch? commits_changing_ci_config.each do |commit| diff --git a/app/services/google_cloud/generate_pipeline_service.rb b/app/services/google_cloud/generate_pipeline_service.rb index 30c358687aa..97d008db76b 100644 --- a/app/services/google_cloud/generate_pipeline_service.rb +++ b/app/services/google_cloud/generate_pipeline_service.rb @@ -67,7 +67,7 @@ module GoogleCloud end def default_branch_gitlab_ci_yml - @default_branch_gitlab_ci_yml ||= project.repository.gitlab_ci_yml_for(project.default_branch) + @default_branch_gitlab_ci_yml ||= project.ci_config_for(project.default_branch) end def pipeline_content(include_path) diff --git a/app/services/groups/ssh_certificates/create_service.rb b/app/services/groups/ssh_certificates/create_service.rb index 6890901c306..e4570078395 100644 --- a/app/services/groups/ssh_certificates/create_service.rb +++ b/app/services/groups/ssh_certificates/create_service.rb @@ -3,9 +3,10 @@ module Groups module SshCertificates class CreateService - def initialize(group, params) + def initialize(group, params, current_user) @group = group @params = params + @current_user = current_user end def execute @@ -41,7 +42,7 @@ module Groups private - attr_reader :group, :params + attr_reader :group, :params, :current_user def generate_fingerprint(key) Gitlab::SSHPublicKey.new(key).fingerprint_sha256&.delete_prefix('SHA256:') @@ -49,3 +50,5 @@ module Groups end end end + +Groups::SshCertificates::CreateService.prepend_mod_with('Groups::SshCertificates::CreateService') diff --git a/app/services/groups/ssh_certificates/destroy_service.rb b/app/services/groups/ssh_certificates/destroy_service.rb index 7a450d5bee6..5f7bba12878 100644 --- a/app/services/groups/ssh_certificates/destroy_service.rb +++ b/app/services/groups/ssh_certificates/destroy_service.rb @@ -3,16 +3,17 @@ module Groups module SshCertificates class DestroyService - def initialize(group, params) + def initialize(group, params, current_user) @group = group @params = params + @current_user = current_user end def execute ssh_certificate = group.ssh_certificates.find(params[:ssh_certificates_id]) ssh_certificate.destroy! - ServiceResponse.success + ServiceResponse.success(payload: { ssh_certificate: ssh_certificate }) rescue ActiveRecord::RecordNotFound ServiceResponse.error( @@ -29,7 +30,9 @@ module Groups private - attr_reader :group, :params + attr_reader :group, :params, :current_user end end end + +Groups::SshCertificates::DestroyService.prepend_mod_with('Groups::SshCertificates::DestroyService') diff --git a/app/services/import/validate_remote_git_endpoint_service.rb b/app/services/import/validate_remote_git_endpoint_service.rb index a994072c4aa..8297757997f 100644 --- a/app/services/import/validate_remote_git_endpoint_service.rb +++ b/app/services/import/validate_remote_git_endpoint_service.rb @@ -13,6 +13,8 @@ module Import GIT_PROTOCOL_PKT_LEN = 4 GIT_MINIMUM_RESPONSE_LENGTH = GIT_PROTOCOL_PKT_LEN + GIT_EXPECTED_FIRST_PACKET_LINE.length EXPECTED_CONTENT_TYPE = "application/x-#{GIT_SERVICE_NAME}-advertisement" + INVALID_BODY_MESSAGE = 'Not a git repository: Invalid response body' + INVALID_CONTENT_TYPE_MESSAGE = 'Not a git repository: Invalid content-type' def initialize(params) @params = params @@ -30,32 +32,35 @@ module Import uri.fragment = nil url = Gitlab::Utils.append_path(uri.to_s, "/info/refs?service=#{GIT_SERVICE_NAME}") - response_body = '' - result = nil - Gitlab::HTTP.try_get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |fragment| - response_body += fragment - next if response_body.length < GIT_MINIMUM_RESPONSE_LENGTH - - result = if status_code_is_valid(fragment) && content_type_is_valid(fragment) && response_body_is_valid(response_body) - :success - else - :error - end - - # We are interested only in the first chunks of the response - # So we're using stream_body: true and breaking when receive enough body - break - end + response, response_body = http_get_and_extract_first_chunks(url) - if result == :success - ServiceResponse.success - else - ServiceResponse.error(message: "#{uri} is not a valid HTTP Git repository") - end + validate(uri, response, response_body) + rescue *Gitlab::HTTP::HTTP_ERRORS => err + error_result("HTTP #{err.class.name.underscore} error: #{err.message}") + rescue StandardError => err + ServiceResponse.error( + message: "Internal #{err.class.name.underscore} error: #{err.message}", + reason: 500 + ) end private + def http_get_and_extract_first_chunks(url) + # We are interested only in the first chunks of the response + # So we're using stream_body: true and breaking when receive enough body + response = nil + response_body = '' + + Gitlab::HTTP.get(url, stream_body: true, follow_redirects: false, basic_auth: auth) do |response_chunk| + response = response_chunk + response_body += response_chunk + break if GIT_MINIMUM_RESPONSE_LENGTH <= response_body.length + end + + [response, response_body] + end + def auth unless @params[:user].to_s.blank? { @@ -65,15 +70,37 @@ module Import end end - def status_code_is_valid(fragment) - fragment.http_response.code == '200' + def validate(uri, response, response_body) + return status_code_error(uri, response) unless status_code_is_valid?(response) + return error_result(INVALID_CONTENT_TYPE_MESSAGE) unless content_type_is_valid?(response) + return error_result(INVALID_BODY_MESSAGE) unless response_body_is_valid?(response_body) + + ServiceResponse.success + end + + def status_code_error(uri, response) + http_code = response.http_response.code.to_i + message = response.http_response.message || Rack::Utils::HTTP_STATUS_CODES[http_code] + + error_result( + "#{uri} endpoint error: #{http_code}#{message.presence&.prepend(' ')}", + http_code + ) + end + + def error_result(message, reason = nil) + ServiceResponse.error(message: message, reason: reason) + end + + def status_code_is_valid?(response) + response.http_response.code == '200' end - def content_type_is_valid(fragment) - fragment.http_response['content-type'] == EXPECTED_CONTENT_TYPE + def content_type_is_valid?(response) + response.http_response['content-type'] == EXPECTED_CONTENT_TYPE end - def response_body_is_valid(response_body) + def response_body_is_valid?(response_body) response_body.match?(GIT_BODY_MESSAGE_REGEXP) end end diff --git a/app/services/jira_connect_subscriptions/create_service.rb b/app/services/jira_connect_subscriptions/create_service.rb index d5ab3800dcf..f537da5c091 100644 --- a/app/services/jira_connect_subscriptions/create_service.rb +++ b/app/services/jira_connect_subscriptions/create_service.rb @@ -11,7 +11,7 @@ module JiraConnectSubscriptions return error(s_('JiraConnect|Could not fetch user information from Jira. ' \ 'Check the permissions in Jira and try again.'), 403) elsif !can_administer_jira? - return error(s_('JiraConnect|The Jira user is not a site administrator. ' \ + return error(s_('JiraConnect|The Jira user is not a site or organization administrator. ' \ 'Check the permissions in Jira and try again.'), 403) end @@ -25,7 +25,7 @@ module JiraConnectSubscriptions private def can_administer_jira? - params[:jira_user]&.site_admin? + params[:jira_user]&.jira_admin? end def create_subscription diff --git a/app/services/members/create_service.rb b/app/services/members/create_service.rb index 9cedc7ee3a5..b453098e27a 100644 --- a/app/services/members/create_service.rb +++ b/app/services/members/create_service.rb @@ -21,15 +21,16 @@ module Members def execute raise Gitlab::Access::AccessDeniedError unless can?(current_user, create_member_permission(source), source) - # rubocop:disable Layout/EmptyLineAfterGuardClause - raise Gitlab::Access::AccessDeniedError if adding_at_least_one_owner && - cannot_assign_owner_responsibilities_to_member_in_project? - # rubocop:enable Layout/EmptyLineAfterGuardClause + if adding_at_least_one_owner && cannot_assign_owner_responsibilities_to_member_in_project? + raise Gitlab::Access::AccessDeniedError + end validate_invite_source! validate_invitable! add_members + after_add_hooks + enqueue_onboarding_progress_action publish_event! @@ -73,8 +74,8 @@ module Members return unless user_limit && invites.size > user_limit - raise TooManyInvitesError, - format(s_("AddMember|Too many users specified (limit is %{user_limit})"), user_limit: user_limit) + message = format(s_("AddMember|Too many users specified (limit is %{user_limit})"), user_limit: user_limit) + raise TooManyInvitesError, message end def blank_invites_message @@ -82,16 +83,24 @@ module Members end def add_members - @members = source.add_members( - invites, - params[:access_level], - expires_at: params[:expires_at], - current_user: current_user + @members = creator_service.add_members( + source, invites, params[:access_level], **create_params ) members.each { |member| process_result(member) } end + def creator_service + "Members::#{source.class.to_s.pluralize}::CreatorService".constantize + end + + def create_params + { + expires_at: params[:expires_at], + current_user: current_user + } + end + def process_result(member) existing_errors = member.errors.full_messages @@ -116,6 +125,10 @@ module Members existing_errors.concat(member.errors.full_messages).uniq end + def after_add_hooks + # overridden in subclasses/ee + end + def after_execute(member:) super @@ -123,11 +136,13 @@ module Members end def track_invite_source(member) - Gitlab::Tracking.event(self.class.name, - 'create_member', - label: invite_source, - property: tracking_property(member), - user: current_user) + Gitlab::Tracking.event( + self.class.name, + 'create_member', + label: invite_source, + property: tracking_property(member), + user: current_user + ) end def invite_source @@ -148,11 +163,15 @@ module Members end def enqueue_onboarding_progress_action - return unless member_created_namespace_id + return unless at_least_one_member_created? Onboarding::UserAddedWorker.perform_async(member_created_namespace_id) end + def at_least_one_member_created? + member_created_namespace_id.present? + end + def result if errors.any? error(formatted_errors) @@ -166,7 +185,7 @@ module Members end def publish_event! - return unless member_created_namespace_id + return unless at_least_one_member_created? Gitlab::EventStore.publish( Members::MembersAddedEvent.new(data: { diff --git a/app/services/merge_requests/mark_reviewer_reviewed_service.rb b/app/services/merge_requests/mark_reviewer_reviewed_service.rb deleted file mode 100644 index 96747eabcf6..00000000000 --- a/app/services/merge_requests/mark_reviewer_reviewed_service.rb +++ /dev/null @@ -1,21 +0,0 @@ -# frozen_string_literal: true - -module MergeRequests - class MarkReviewerReviewedService < MergeRequests::BaseService - def execute(merge_request) - return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) - - reviewer = merge_request.find_reviewer(current_user) - - if reviewer - return error("Failed to update reviewer") unless reviewer.update(state: :reviewed) - - trigger_merge_request_reviewers_updated(merge_request) - - success - else - error("Reviewer not found") - end - end - end -end diff --git a/app/services/merge_requests/mergeability/check_base_service.rb b/app/services/merge_requests/mergeability/check_base_service.rb index e1c4d751296..b8a275b6c32 100644 --- a/app/services/merge_requests/mergeability/check_base_service.rb +++ b/app/services/merge_requests/mergeability/check_base_service.rb @@ -42,6 +42,11 @@ module MergeRequests .failed(payload: default_payload(args)) end + def inactive(**args) + Gitlab::MergeRequests::Mergeability::CheckResult + .inactive(payload: default_payload(args)) + end + def default_payload(args) args.merge(identifier: self.class.identifier) end diff --git a/app/services/merge_requests/mergeability/check_ci_status_service.rb b/app/services/merge_requests/mergeability/check_ci_status_service.rb index f7fa3259d97..b4e60e964b7 100644 --- a/app/services/merge_requests/mergeability/check_ci_status_service.rb +++ b/app/services/merge_requests/mergeability/check_ci_status_service.rb @@ -7,6 +7,8 @@ module MergeRequests end def execute + return inactive unless merge_request.only_allow_merge_if_pipeline_succeeds? + if merge_request.mergeable_ci_state? success else diff --git a/app/services/merge_requests/mergeability/check_discussions_status_service.rb b/app/services/merge_requests/mergeability/check_discussions_status_service.rb index 34db5f8a944..f9cff5d1e5f 100644 --- a/app/services/merge_requests/mergeability/check_discussions_status_service.rb +++ b/app/services/merge_requests/mergeability/check_discussions_status_service.rb @@ -7,6 +7,8 @@ module MergeRequests end def execute + return inactive unless merge_request.only_allow_merge_if_all_discussions_are_resolved? + if merge_request.mergeable_discussions_state? success else diff --git a/app/services/merge_requests/mergeability/check_rebase_status_service.rb b/app/services/merge_requests/mergeability/check_rebase_status_service.rb index 2163fec8bd6..02cd0587be0 100644 --- a/app/services/merge_requests/mergeability/check_rebase_status_service.rb +++ b/app/services/merge_requests/mergeability/check_rebase_status_service.rb @@ -8,6 +8,8 @@ module MergeRequests end def execute + return inactive unless merge_request.project.ff_merge_must_be_possible? + if merge_request.should_be_rebased? failure(reason: failure_reason) else diff --git a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb index 86c8122604c..92f0fb0429c 100644 --- a/app/services/merge_requests/mergeability/detailed_merge_status_service.rb +++ b/app/services/merge_requests/mergeability/detailed_merge_status_service.rb @@ -18,10 +18,10 @@ module MergeRequests # If everything else is mergeable, but CI is not, the frontend expects two potential states to be returned # See discussion: gitlab.com/gitlab-org/gitlab/-/merge_requests/96778#note_1093063523 - if check_ci_results.success? - :mergeable - else + if check_ci_results.failed? ci_check_failure_reason + else + :mergeable end else check_results.payload[:failure_reason] diff --git a/app/services/merge_requests/mergeability/run_checks_service.rb b/app/services/merge_requests/mergeability/run_checks_service.rb index 5150c03d0a3..92f3e5e951a 100644 --- a/app/services/merge_requests/mergeability/run_checks_service.rb +++ b/app/services/merge_requests/mergeability/run_checks_service.rb @@ -65,7 +65,7 @@ module MergeRequests end def all_results_success? - results.all?(&:success?) + results.none?(&:failed?) end def failure_reason diff --git a/app/services/merge_requests/push_options_handler_service.rb b/app/services/merge_requests/push_options_handler_service.rb index 1890addf692..3f972e747b9 100644 --- a/app/services/merge_requests/push_options_handler_service.rb +++ b/app/services/merge_requests/push_options_handler_service.rb @@ -9,7 +9,12 @@ module MergeRequests def initialize(project:, current_user:, changes:, push_options:, params: {}) super(project: project, current_user: current_user, params: params) - @target_project = @project.default_merge_request_target + @target_project = if push_options[:target_project] + Project.find_by_full_path(push_options[:target_project]) + else + @project.default_merge_request_target + end + @changes = Gitlab::ChangesList.new(changes) @push_options = push_options @errors = [] @@ -63,6 +68,10 @@ module MergeRequests return end + unless project == target_project || project.in_fork_network_of?(target_project) + errors << "Projects #{project.full_path} and #{target_project.full_path} are not in the same network" + end + unless target_project.merge_requests_enabled? errors << "Merge requests are not enabled for project #{target_project.full_path}" end diff --git a/app/services/merge_requests/update_reviewer_state_service.rb b/app/services/merge_requests/update_reviewer_state_service.rb new file mode 100644 index 00000000000..e2252f55fd3 --- /dev/null +++ b/app/services/merge_requests/update_reviewer_state_service.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module MergeRequests + class UpdateReviewerStateService < MergeRequests::BaseService + def execute(merge_request, state) + return error("Invalid permissions") unless can?(current_user, :update_merge_request, merge_request) + + reviewer = merge_request.find_reviewer(current_user) + + if reviewer + return error("Failed to update reviewer") unless reviewer.update(state: state) + + trigger_merge_request_reviewers_updated(merge_request) + + return success if state != 'requested_changes' + + if merge_request.approved_by?(current_user) && !remove_approval(merge_request) + return error("Failed to remove approval") + end + + success + else + error("Reviewer not found") + end + end + + private + + def remove_approval(merge_request) + MergeRequests::RemoveApprovalService.new(project: project, current_user: current_user) + .execute(merge_request) + end + end +end diff --git a/app/services/merge_requests/update_service.rb b/app/services/merge_requests/update_service.rb index 37a829e3014..fb6544a910a 100644 --- a/app/services/merge_requests/update_service.rb +++ b/app/services/merge_requests/update_service.rb @@ -168,6 +168,7 @@ module MergeRequests merge_request.target_branch ) + delete_approvals_on_target_branch_change(merge_request) refresh_pipelines_on_merge_requests(merge_request, allow_duplicate: true) abort_auto_merge(merge_request, 'target branch was changed') @@ -321,6 +322,10 @@ module MergeRequests def trigger_merge_request_status_updated(merge_request) GraphqlTriggers.merge_request_merge_status_updated(merge_request) end + + def delete_approvals_on_target_branch_change(_merge_request) + # Overridden in EE. No-op since we only want to delete approvals in EE. + end end end diff --git a/app/services/ml/create_candidate_service.rb b/app/services/ml/create_candidate_service.rb new file mode 100644 index 00000000000..53913c3fb19 --- /dev/null +++ b/app/services/ml/create_candidate_service.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module Ml + class CreateCandidateService + def initialize(experiment, params = {}) + @experiment = experiment + @name = params[:name] + @user = params[:user] + @start_time = params[:start_time] + @model_version = params[:model_version] + end + + def execute + Ml::Candidate.create!( + experiment: experiment, + project: experiment.project, + name: candidate_name, + start_time: start_time || 0, + user: user, + model_version: model_version + ) + end + + private + + def candidate_name + name.presence || random_candidate_name + end + + def random_candidate_name + parts = Array.new(3).map { FFaker::Animal.common_name.downcase.delete(' ') } << rand(10000) + parts.join('-').truncate(255) + end + + attr_reader :name, :user, :experiment, :start_time, :model_version + end +end diff --git a/app/services/ml/create_model_service.rb b/app/services/ml/create_model_service.rb new file mode 100644 index 00000000000..5c179d8edf7 --- /dev/null +++ b/app/services/ml/create_model_service.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module Ml + class CreateModelService + def initialize(project, name, user = nil, description = nil, metadata = []) + @project = project + @name = name + @description = description + @metadata = metadata + @user = user + end + + def execute + ApplicationRecord.transaction do + model = Ml::Model.create!( + project: @project, + name: @name, + user: (@user.is_a?(User) ? @user : nil), + description: @description, + default_experiment: default_experiment + ) + + add_metadata(model, @metadata) + + model + end + end + + private + + def default_experiment + @default_experiment ||= Ml::FindOrCreateExperimentService.new(@project, @name).execute + end + + def add_metadata(model, metadata_key_value) + return unless model.present? && metadata_key_value.present? + + entities = metadata_key_value.map do |d| + { + model_id: model.id, + name: d[:key], + value: d[:value] + } + end + + entities.each do |entry| + ::Ml::ModelMetadata.create!(entry) + end + end + end +end diff --git a/app/services/ml/experiment_tracking/candidate_repository.rb b/app/services/ml/experiment_tracking/candidate_repository.rb index 436f06e3ca5..8739379912a 100644 --- a/app/services/ml/experiment_tracking/candidate_repository.rb +++ b/app/services/ml/experiment_tracking/candidate_repository.rb @@ -15,12 +15,13 @@ module Ml end def create!(experiment, start_time, tags = nil, name = nil) - candidate = experiment.candidates.create!( + create_params = { + start_time: start_time, user: user, - name: candidate_name(name, tags), - project: project, - start_time: start_time || 0 - ) + name: candidate_name(name, tags) + } + + candidate = Ml::CreateCandidateService.new(experiment, create_params).execute add_tags(candidate, tags) @@ -103,17 +104,12 @@ module Ml end def candidate_name(name, tags) - name.presence || candidate_name_from_tags(tags) || random_candidate_name + name.presence || candidate_name_from_tags(tags) end def candidate_name_from_tags(tags) tags&.detect { |t| t[:key] == 'mlflow.runName' }&.dig(:value) end - - def random_candidate_name - parts = Array.new(3).map { FFaker::Animal.common_name.downcase.delete(' ') } << rand(10000) - parts.join('-').truncate(255) - end end end end diff --git a/app/services/ml/find_model_service.rb b/app/services/ml/find_model_service.rb new file mode 100644 index 00000000000..23ca0266629 --- /dev/null +++ b/app/services/ml/find_model_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Ml + class FindModelService + def initialize(project, name) + @project = project + @name = name + end + + def execute + Ml::Model.by_project_id_and_name(@project.id, @name) + end + end +end diff --git a/app/services/ml/find_or_create_model_service.rb b/app/services/ml/find_or_create_model_service.rb index 66dec7a6234..9199730e84b 100644 --- a/app/services/ml/find_or_create_model_service.rb +++ b/app/services/ml/find_or_create_model_service.rb @@ -2,21 +2,17 @@ module Ml class FindOrCreateModelService - def initialize(project, model_name) + def initialize(project, name, user = nil, description = nil, metadata = []) @project = project - @name = model_name + @name = name + @description = description + @metadata = metadata + @user = user end def execute - Ml::Model.find_or_create( - project, - name, - Ml::FindOrCreateExperimentService.new(project, name).execute - ) + FindModelService.new(@project, @name).execute || + CreateModelService.new(@project, @name, @user, @description, @metadata).execute end - - private - - attr_reader :name, :project end end diff --git a/app/services/ml/find_or_create_model_version_service.rb b/app/services/ml/find_or_create_model_version_service.rb index f4d3f3e72d3..a5e9bf997cc 100644 --- a/app/services/ml/find_or_create_model_version_service.rb +++ b/app/services/ml/find_or_create_model_version_service.rb @@ -7,15 +7,20 @@ module Ml @name = params[:model_name] @version = params[:version] @package = params[:package] + @description = params[:description] end def execute - model = Ml::FindOrCreateModelService.new(project, name).execute - Ml::ModelVersion.find_or_create!(model, version, package) - end + model = Ml::FindOrCreateModelService.new(@project, @name).execute + + model_version = Ml::ModelVersion.find_or_create!(model, @version, @package, @description) - private + model_version.candidate = ::Ml::CreateCandidateService.new( + model.default_experiment, + { model_version: model_version } + ).execute - attr_reader :version, :name, :project, :package + model_version + end end end diff --git a/app/services/ml/model_versions/get_model_version_service.rb b/app/services/ml/model_versions/get_model_version_service.rb new file mode 100644 index 00000000000..e8794689d73 --- /dev/null +++ b/app/services/ml/model_versions/get_model_version_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Ml + module ModelVersions + class GetModelVersionService + def initialize(project, name, version) + @project = project + @name = name + @version = version + end + + def execute + Ml::ModelVersion.by_project_id_name_and_version( + @project.id, + @name, + @version + ) + end + end + end +end diff --git a/app/services/ml/update_model_service.rb b/app/services/ml/update_model_service.rb new file mode 100644 index 00000000000..dade6c72588 --- /dev/null +++ b/app/services/ml/update_model_service.rb @@ -0,0 +1,16 @@ +# frozen_string_literal: true + +module Ml + class UpdateModelService + def initialize(model, description) + @model = model + @description = description + end + + def execute + @model.update!(description: @description) + + @model + end + end +end diff --git a/app/services/notes/create_service.rb b/app/services/notes/create_service.rb index 1af26377b71..a63b1cf375f 100644 --- a/app/services/notes/create_service.rb +++ b/app/services/notes/create_service.rb @@ -226,8 +226,10 @@ module Notes end def set_reviewed(note) - ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user) - .execute(note.noteable) + return if Feature.enabled?(:mr_request_changes, current_user) + + ::MergeRequests::UpdateReviewerStateService.new(project: project, current_user: current_user) + .execute(note.noteable, "reviewed") end end end diff --git a/app/services/notification_service.rb b/app/services/notification_service.rb index f1781b3d3c5..5099272a212 100644 --- a/app/services/notification_service.rb +++ b/app/services/notification_service.rb @@ -358,7 +358,7 @@ class NotificationService def review_requested_of_merge_request(merge_request, current_user, reviewer) recipients = NotificationRecipients::BuildService.build_requested_review_recipients(merge_request, current_user, reviewer) - deliver_option = review_request_deliver_options(merge_request.project, reviewer) + deliver_option = review_request_deliver_options(merge_request.project) recipients.each do |recipient| mailer @@ -975,7 +975,7 @@ class NotificationService {} end - def review_request_deliver_options(project, user) + def review_request_deliver_options(project) # Overridden in EE {} end diff --git a/app/services/organizations/base_service.rb b/app/services/organizations/base_service.rb new file mode 100644 index 00000000000..19bbc64ebdd --- /dev/null +++ b/app/services/organizations/base_service.rb @@ -0,0 +1,14 @@ +# frozen_string_literal: true + +module Organizations + class BaseService + include BaseServiceUtility + + attr_reader :current_user, :params + + def initialize(current_user: nil, params: {}) + @current_user = current_user + @params = params.dup + end + end +end diff --git a/app/services/organizations/create_service.rb b/app/services/organizations/create_service.rb new file mode 100644 index 00000000000..89c579032d2 --- /dev/null +++ b/app/services/organizations/create_service.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module Organizations + class CreateService < ::Organizations::BaseService + def execute + return error_no_permissions unless current_user&.can?(:create_organization) + + organization = Organization.create(params) + + return error_creating(organization) unless organization.persisted? + + ServiceResponse.success(payload: organization) + end + + private + + def error_no_permissions + ServiceResponse.error(message: [_('You have insufficient permissions to create organizations')]) + end + + def error_creating(organization) + message = organization.errors.full_messages || _('Failed to create organization') + + ServiceResponse.error(message: Array(message)) + end + end +end diff --git a/app/services/packages/ml_model/create_package_file_service.rb b/app/services/packages/ml_model/create_package_file_service.rb index b1e8e814015..ff569a8eecf 100644 --- a/app/services/packages/ml_model/create_package_file_service.rb +++ b/app/services/packages/ml_model/create_package_file_service.rb @@ -37,7 +37,8 @@ module Packages model_version_params = { model_name: package.name, version: package.version, - package: package + package: package, + user: current_user } Ml::FindOrCreateModelVersionService.new(project, model_version_params).execute diff --git a/app/services/packages/npm/create_package_service.rb b/app/services/packages/npm/create_package_service.rb index d599cecc8da..0f0dc297e9a 100644 --- a/app/services/packages/npm/create_package_service.rb +++ b/app/services/packages/npm/create_package_service.rb @@ -12,6 +12,7 @@ module Packages return error('Version is empty.', 400) if version.blank? return error('Attachment data is empty.', 400) if attachment['data'].blank? return error('Package already exists.', 403) if current_package_exists? + return error('Package protected.', 403) if current_package_protected? return error('File is too large.', 400) if file_size_exceeded? package = try_obtain_lease do @@ -56,6 +57,13 @@ module Packages .exists? end + def current_package_protected? + return false if Feature.disabled?(:packages_protected_packages, project) + + user_project_authorization_access_level = current_user.max_member_access_for_project(project.id) + project.package_protection_rules.push_protected_from?(access_level: user_project_authorization_access_level, package_name: name, package_type: :npm) + end + def name params[:name] end diff --git a/app/services/packages/nuget/check_duplicates_service.rb b/app/services/packages/nuget/check_duplicates_service.rb index 7ad9038d7c1..33a66c2bce1 100644 --- a/app/services/packages/nuget/check_duplicates_service.rb +++ b/app/services/packages/nuget/check_duplicates_service.rb @@ -49,40 +49,30 @@ module Packages strong_memoize_attr :existing_package def metadata - if remote_package_file? - ExtractMetadataContentService + if params[:remote_url].present? + ::Packages::Nuget::ExtractMetadataContentService .new(nuspec_file_content) .execute .payload else # to cover the case when package file is on disk not in object storage - MetadataExtractionService - .new(mock_package_file) - .execute - .payload + Zip::InputStream.open(params[:file]) do |zip| + ::Packages::Nuget::MetadataExtractionService + .new(zip) + .execute + .payload + end end end strong_memoize_attr :metadata - def remote_package_file? - params[:remote_url].present? - end - def nuspec_file_content - ExtractRemoteMetadataFileService + ::Packages::Nuget::ExtractRemoteMetadataFileService .new(params[:remote_url]) .execute .payload - rescue ExtractRemoteMetadataFileService::ExtractionError => e + rescue ::Packages::Nuget::ExtractRemoteMetadataFileService::ExtractionError => e raise ExtractionError, e.message end - - def mock_package_file - ::Packages::PackageFile.new( - params - .slice(:file, :file_name) - .merge(package: ::Packages::Package.nuget.build) - ) - end end end end diff --git a/app/services/packages/nuget/extract_metadata_file_service.rb b/app/services/packages/nuget/extract_metadata_file_service.rb index fd4f9b5d1c1..1daf0aba8d6 100644 --- a/app/services/packages/nuget/extract_metadata_file_service.rb +++ b/app/services/packages/nuget/extract_metadata_file_service.rb @@ -20,7 +20,7 @@ module Packages attr_reader :package_zip_file def nuspec_file_content - entry = package_zip_file.glob('*.nuspec').first + entry = extract_nuspec_file raise ExtractionError, 'nuspec file not found' unless entry raise ExtractionError, 'nuspec file too big' if MAX_FILE_SIZE < entry.size @@ -32,6 +32,16 @@ module Packages rescue Zip::EntrySizeError => e raise ExtractionError, "nuspec file has the wrong entry size: #{e.message}" end + + def extract_nuspec_file + if package_zip_file.is_a?(Zip::InputStream) + while (entry = package_zip_file.get_next_entry) # rubocop:disable Lint/AssignmentInCondition -- Following https://github.com/rubyzip/rubyzip#notes-on-zipinputstream and that's why the disable rubocop rule + break entry if entry.name.end_with?('.nuspec') + end + else + package_zip_file.glob('*.nuspec').first + end + end end end end diff --git a/app/services/packages/nuget/metadata_extraction_service.rb b/app/services/packages/nuget/metadata_extraction_service.rb index 53189063c85..813cb8e0979 100644 --- a/app/services/packages/nuget/metadata_extraction_service.rb +++ b/app/services/packages/nuget/metadata_extraction_service.rb @@ -3,8 +3,8 @@ module Packages module Nuget class MetadataExtractionService - def initialize(package_file) - @package_file = package_file + def initialize(package_zip_file) + @package_zip_file = package_zip_file end def execute @@ -13,19 +13,20 @@ module Packages private - attr_reader :package_file + attr_reader :package_zip_file def metadata - ExtractMetadataContentService + ::Packages::Nuget::ExtractMetadataContentService .new(nuspec_file_content) .execute .payload end def nuspec_file_content - ProcessPackageFileService - .new(package_file) - .execute[:nuspec_file_content] + ::Packages::Nuget::ExtractMetadataFileService + .new(package_zip_file) + .execute + .payload end end end diff --git a/app/services/packages/nuget/process_package_file_service.rb b/app/services/packages/nuget/process_package_file_service.rb index fa7a84ee3d6..99b59bd3322 100644 --- a/app/services/packages/nuget/process_package_file_service.rb +++ b/app/services/packages/nuget/process_package_file_service.rb @@ -4,7 +4,6 @@ module Packages module Nuget class ProcessPackageFileService ExtractionError = Class.new(StandardError) - NUGET_SYMBOL_FILE_EXTENSION = '.snupkg' def initialize(package_file) @package_file = package_file @@ -13,14 +12,9 @@ module Packages def execute raise ExtractionError, 'invalid package file' unless valid_package_file? - nuspec_content = nil - with_zip_file do |zip_file| - nuspec_content = nuspec_file_content(zip_file) - create_symbol_files(zip_file) if symbol_package_file? + ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file, zip_file).execute end - - ServiceResponse.success(payload: { nuspec_file_content: nuspec_content }) end private @@ -38,23 +32,6 @@ module Packages Zip::File.open(open_file.file_path, &block) # rubocop: disable Performance/Rubyzip end end - - def nuspec_file_content(zip_file) - ::Packages::Nuget::ExtractMetadataFileService - .new(zip_file) - .execute - .payload - end - - def create_symbol_files(zip_file) - ::Packages::Nuget::Symbols::CreateSymbolFilesService - .new(package_file.package, zip_file) - .execute - end - - def symbol_package_file? - package_file.file_name.end_with?(NUGET_SYMBOL_FILE_EXTENSION) - end end end end diff --git a/app/services/packages/nuget/symbols/create_symbol_files_service.rb b/app/services/packages/nuget/symbols/create_symbol_files_service.rb index 03e14ba00e1..5f0b8762054 100644 --- a/app/services/packages/nuget/symbols/create_symbol_files_service.rb +++ b/app/services/packages/nuget/symbols/create_symbol_files_service.rb @@ -18,7 +18,7 @@ module Packages process_symbol_entries rescue ExtractionError => e - Gitlab::ErrorTracking.log_exception(e, class: self.class.name, package_id: package.id) + Gitlab::ErrorTracking.track_exception(e, class: self.class.name, package_id: package.id) end private @@ -31,7 +31,7 @@ module Packages raise ExtractionError, 'too many symbol entries' if index >= SYMBOL_ENTRIES_LIMIT entry.extract(tmp_file.path) { true } - File.open(tmp_file.path) do |file| + File.open(tmp_file.path, 'rb') do |file| create_symbol(entry.name, file) end end @@ -43,25 +43,27 @@ module Packages end def create_symbol(path, file) - signature = extract_signature(file.read(1.kilobyte)) - return if signature.blank? + signature, checksum = extract_signature_and_checksum(file) + return if signature.blank? || checksum.blank? ::Packages::Nuget::Symbol.create!( package: package, file: { tempfile: file, filename: path.downcase, content_type: CONTENT_TYPE }, file_path: path, signature: signature, - size: file.size + size: file.size, + file_sha256: checksum ) rescue StandardError => e - Gitlab::ErrorTracking.log_exception(e, class: self.class.name, package_id: package.id) + Gitlab::ErrorTracking.track_exception(e, class: self.class.name, package_id: package.id) end - def extract_signature(content_fragment) - ExtractSymbolSignatureService - .new(content_fragment) + def extract_signature_and_checksum(file) + ::Packages::Nuget::Symbols::ExtractSignatureAndChecksumService + .new(file) .execute .payload + .values_at(:signature, :checksum) end end end diff --git a/app/services/packages/nuget/symbols/extract_symbol_signature_service.rb b/app/services/packages/nuget/symbols/extract_signature_and_checksum_service.rb index c2ccdb517b5..fd37d139145 100644 --- a/app/services/packages/nuget/symbols/extract_symbol_signature_service.rb +++ b/app/services/packages/nuget/symbols/extract_signature_and_checksum_service.rb @@ -3,45 +3,43 @@ module Packages module Nuget module Symbols - class ExtractSymbolSignatureService + class ExtractSignatureAndChecksumService include Gitlab::Utils::StrongMemoize # More information about the GUID format can be found here: # https://github.com/dotnet/symstore/blob/main/docs/specs/SSQP_Key_Conventions.md#key-formatting-basic-rules GUID_START_INDEX = 7 - GUID_END_INDEX = 22 + GUID_END_INDEX = 26 + SIGNATURE_LENGTH = 16 + TWENTY_ZEROED_BYTES = "\u0000" * 20 GUID_PARTS_LENGTHS = [4, 2, 2, 8].freeze GUID_AGE_PART = 'FFFFFFFF' TWO_CHARACTER_HEX_REGEX = /\h{2}/ + GUID_CHUNK_SIZE = 256.bytes + SHA_CHUNK_SIZE = 16.kilobytes # The extraction of the signature in this service is based on the following documentation: # https://github.com/dotnet/symstore/blob/main/docs/specs/SSQP_Key_Conventions.md#portable-pdb-signature - def initialize(symbol_content) - @symbol_content = symbol_content + def initialize(file) + @file = file end def execute return error_response unless signature - ServiceResponse.success(payload: signature) + ServiceResponse.success(payload: { signature: signature, checksum: checksum }) end private - attr_reader :symbol_content + attr_reader :file def signature - # Find the index of the first occurrence of 'Blob' - guid_index = symbol_content.index('Blob') - return if guid_index.nil? - - # Extract the binary GUID from the symbol content - guid = symbol_content[(guid_index + GUID_START_INDEX)..(guid_index + GUID_END_INDEX)] - return if guid.nil? + return unless pdb_id # Convert the GUID into an array of two-character hex strings - guid = guid.unpack('H*').flat_map { |el| el.scan(TWO_CHARACTER_HEX_REGEX) } + guid = pdb_id.first(SIGNATURE_LENGTH).unpack('H*').flat_map { |el| el.scan(TWO_CHARACTER_HEX_REGEX) } # Reorder the GUID parts based on arbitrary lengths guid = GUID_PARTS_LENGTHS.map { |length| guid.shift(length) } @@ -54,6 +52,36 @@ module Packages end strong_memoize_attr :signature + # https://github.com/dotnet/corefx/blob/master/src/System.Reflection.Metadata/specs/PE-COFF.md#portable-pdb-checksum + def checksum + sha = OpenSSL::Digest.new('SHA256') + count = 0 + chunk = (+'').force_encoding(Encoding::BINARY) + file.rewind + + while file.read(SHA_CHUNK_SIZE, chunk) + count += 1 + chunk[pdb_id] = TWENTY_ZEROED_BYTES if count == 1 + sha.update(chunk) + end + + sha.hexdigest + end + + def pdb_id + # The ID is located in the first 256 bytes of the symbol `.pdb` file + chunk = file.read(GUID_CHUNK_SIZE) + return unless chunk + + # Find the index of the first occurrence of 'Blob' + guid_index = chunk.index('Blob') + return unless guid_index + + # Extract the binary GUID from the symbol content + chunk[(guid_index + GUID_START_INDEX)..(guid_index + GUID_END_INDEX)] + end + strong_memoize_attr :pdb_id + def error_response ServiceResponse.error(message: 'Could not find the signature in the symbol file') end diff --git a/app/services/packages/nuget/update_package_from_metadata_service.rb b/app/services/packages/nuget/update_package_from_metadata_service.rb index 4cec4ed2fae..b7411d5f8a8 100644 --- a/app/services/packages/nuget/update_package_from_metadata_service.rb +++ b/app/services/packages/nuget/update_package_from_metadata_service.rb @@ -13,11 +13,11 @@ module Packages INVALID_METADATA_ERROR_SYMBOL_MESSAGE = 'package name, version and/or description not found in metadata' MISSING_MATCHING_PACKAGE_ERROR_MESSAGE = 'symbol package is invalid, matching package does not exist' - InvalidMetadataError = Class.new(StandardError) - ZipError = Class.new(StandardError) + InvalidMetadataError = ZipError = Class.new(StandardError) - def initialize(package_file) + def initialize(package_file, package_zip_file) @package_file = package_file + @package_zip_file = package_zip_file end def execute @@ -57,7 +57,7 @@ module Packages build_infos = package_to_destroy&.build_infos || [] update_package(target_package, build_infos) - update_symbol_files(target_package, package_to_destroy) if symbol_package? + create_symbol_files ::Packages::UpdatePackageFileService.new(@package_file, package_id: target_package.id, file_name: package_filename) .execute package_to_destroy&.destroy! @@ -79,8 +79,12 @@ module Packages raise InvalidMetadataError, e.message end - def update_symbol_files(package, package_to_destroy) - package_to_destroy.nuget_symbols.update_all(package_id: package.id) + def create_symbol_files + return unless symbol_package? + + ::Packages::Nuget::Symbols::CreateSymbolFilesService + .new(existing_package, @package_zip_file) + .execute end def valid_metadata? @@ -145,9 +149,10 @@ module Packages def symbol_package? package_types.include?(SYMBOL_PACKAGE_IDENTIFIER) end + strong_memoize_attr :symbol_package? def metadata - ::Packages::Nuget::MetadataExtractionService.new(@package_file).execute.payload + ::Packages::Nuget::MetadataExtractionService.new(@package_zip_file).execute.payload end strong_memoize_attr :metadata diff --git a/app/services/packages/protection/delete_rule_service.rb b/app/services/packages/protection/delete_rule_service.rb new file mode 100644 index 00000000000..a1fa111b57b --- /dev/null +++ b/app/services/packages/protection/delete_rule_service.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +module Packages + module Protection + class DeleteRuleService + include Gitlab::Allowable + + def initialize(package_protection_rule, current_user:) + if package_protection_rule.blank? || current_user.blank? + raise ArgumentError, + 'package_protection_rule and current_user must be set' + end + + @package_protection_rule = package_protection_rule + @current_user = current_user + end + + def execute + unless can?(current_user, :admin_package, package_protection_rule.project) + error_message = _('Unauthorized to delete a package protection rule') + return service_response_error(message: error_message) + end + + deleted_package_protection_rule = package_protection_rule.destroy! + + ServiceResponse.success(payload: { package_protection_rule: deleted_package_protection_rule }) + rescue StandardError => e + service_response_error(message: e.message) + end + + private + + attr_reader :package_protection_rule, :current_user + + def service_response_error(message:) + ServiceResponse.error( + message: message, + payload: { package_protection_rule: nil } + ) + end + end + end +end diff --git a/app/services/packages/pypi/create_package_service.rb b/app/services/packages/pypi/create_package_service.rb index 087a8e42a66..fca7b1bca37 100644 --- a/app/services/packages/pypi/create_package_service.rb +++ b/app/services/packages/pypi/create_package_service.rb @@ -9,7 +9,13 @@ module Packages ::Packages::Package.transaction do meta = Packages::Pypi::Metadatum.new( package: created_package, - required_python: params[:requires_python] || '' + required_python: params[:requires_python] || '', + metadata_version: params[:metadata_version], + author_email: params[:author_email], + description: params[:description], + description_content_type: params[:description_content_type], + summary: params[:summary], + keywords: params[:keywords] ) unless meta.valid? diff --git a/app/services/packages/update_tags_service.rb b/app/services/packages/update_tags_service.rb index cf1acc6ee19..014d5501b76 100644 --- a/app/services/packages/update_tags_service.rb +++ b/app/services/packages/update_tags_service.rb @@ -32,7 +32,8 @@ module Packages package_id: @package.id, name: tag, created_at: now, - updated_at: now + updated_at: now, + project_id: @package.project_id } end end diff --git a/app/services/pages/delete_service.rb b/app/services/pages/delete_service.rb index dcee4c5b665..96b451aeba4 100644 --- a/app/services/pages/delete_service.rb +++ b/app/services/pages/delete_service.rb @@ -3,7 +3,7 @@ module Pages class DeleteService < BaseService def execute - project.mark_pages_as_not_deployed + PagesDeployment.deactivate_all(project) # project.pages_domains.delete_all will just nullify project_id: # > If no :dependent option is given, then it will follow the default diff --git a/app/services/personal_access_tokens/rotate_service.rb b/app/services/personal_access_tokens/rotate_service.rb index b765aacef68..32710629caf 100644 --- a/app/services/personal_access_tokens/rotate_service.rb +++ b/app/services/personal_access_tokens/rotate_service.rb @@ -9,7 +9,7 @@ module PersonalAccessTokens @token = token end - def execute + def execute(params = {}) return ServiceResponse.error(message: _('token already revoked')) if token.revoked? response = ServiceResponse.success @@ -21,7 +21,7 @@ module PersonalAccessTokens end target_user = token.user - new_token = target_user.personal_access_tokens.create(create_token_params(token)) + new_token = target_user.personal_access_tokens.create(create_token_params(token, params)) if new_token.persisted? response = ServiceResponse.success(payload: { personal_access_token: new_token }) @@ -39,12 +39,13 @@ module PersonalAccessTokens attr_reader :current_user, :token - def create_token_params(token) + def create_token_params(token, params) + expires_at = params[:expires_at] || (Date.today + EXPIRATION_PERIOD) { name: token.name, previous_personal_access_token_id: token.id, impersonation: token.impersonation, scopes: token.scopes, - expires_at: Date.today + EXPIRATION_PERIOD } + expires_at: expires_at } end end end diff --git a/app/services/projects/container_repository/gitlab/delete_tags_service.rb b/app/services/projects/container_repository/gitlab/delete_tags_service.rb index a5b7f4bbb6f..6dc50dac7a4 100644 --- a/app/services/projects/container_repository/gitlab/delete_tags_service.rb +++ b/app/services/projects/container_repository/gitlab/delete_tags_service.rb @@ -34,7 +34,7 @@ module Projects @tag_names.each do |name| raise TimeoutError if timeout?(start_time) - if @container_repository.delete_tag_by_name(name) + if @container_repository.delete_tag(name) @deleted_tags.append(name) end end diff --git a/app/services/projects/container_repository/third_party/delete_tags_service.rb b/app/services/projects/container_repository/third_party/delete_tags_service.rb index 942df177bea..ae3f1cc23d6 100644 --- a/app/services/projects/container_repository/third_party/delete_tags_service.rb +++ b/app/services/projects/container_repository/third_party/delete_tags_service.rb @@ -30,7 +30,7 @@ module Projects # Deletes the dummy image # All created tag digests are the same since they all have the same dummy image. # a single delete is sufficient to remove all tags with it - if deleted_tags.any? && @container_repository.delete_tag_by_digest(deleted_tags.each_value.first) + if deleted_tags.any? && @container_repository.delete_tag(deleted_tags.each_value.first) success(deleted: deleted_tags.keys) else error("could not delete tags: #{@tag_names.join(', ')}".truncate(1000)) diff --git a/app/services/projects/destroy_service.rb b/app/services/projects/destroy_service.rb index a2a2f9d2800..8c86646ba5c 100644 --- a/app/services/projects/destroy_service.rb +++ b/app/services/projects/destroy_service.rb @@ -18,6 +18,15 @@ module Projects return false unless can?(current_user, :remove_project, project) project.update_attribute(:pending_delete, true) + + # There is a possibility of active repository move processes for + # project and snippets. An attempt to delete the project at the same time + # can lead to race condition and an inconsistent state. + # + # This validation stops the project delete process if it detects active + # repository move schedules for it. + validate_active_repositories_move! + # Flush the cache for both repositories. This has to be done _before_ # removing the physical repositories as some expiration code depends on # Git data (e.g. a list of branch names). @@ -50,6 +59,16 @@ module Projects private + def validate_active_repositories_move! + if project.repository_storage_moves.scheduled_or_started.exists? + raise_error(s_("DeleteProject|Couldn't remove the project. A project repository storage move is in progress. Try again when it's complete.")) + end + + if ::ProjectSnippet.by_project(project).with_repository_storage_moves.merge(::Snippets::RepositoryStorageMove.scheduled_or_started).exists? + raise_error(s_("DeleteProject|Couldn't remove the project. A related snippet repository storage move is in progress. Try again when it's complete.")) + end + end + def trash_project_repositories! unless remove_repository(project.repository) raise_error(s_('DeleteProject|Failed to remove project repository. Please try again or contact administrator.')) diff --git a/app/services/projects/fork_service.rb b/app/services/projects/fork_service.rb index aace8846afc..168420b17bf 100644 --- a/app/services/projects/fork_service.rb +++ b/app/services/projects/fork_service.rb @@ -17,6 +17,10 @@ module Projects @valid_fork_targets ||= ForkTargetsFinder.new(@project, current_user).execute(options) end + def valid_fork_branch?(branch) + @project.repository.branch_exists?(branch) + end + def valid_fork_target?(namespace = target_namespace) return true if current_user.admin? @@ -68,7 +72,8 @@ module Projects external_authorization_classification_label: @project.external_authorization_classification_label, suggestion_commit_message: @project.suggestion_commit_message, merge_commit_template: @project.merge_commit_template, - squash_commit_template: @project.squash_commit_template + squash_commit_template: @project.squash_commit_template, + import_data: { data: { fork_branch: branch } } } if @project.avatar.present? && @project.avatar.image? @@ -145,6 +150,12 @@ module Projects def stream_audit_event(forked_project) # Defined in EE end + + def branch + # We extract branch name from @params[:branches] because the front end + # insists on sending it as 'branches'. + @params[:branches] + end end end diff --git a/app/services/projects/group_links/destroy_service.rb b/app/services/projects/group_links/destroy_service.rb index a2307bfebf0..e0218ae087e 100644 --- a/app/services/projects/group_links/destroy_service.rb +++ b/app/services/projects/group_links/destroy_service.rb @@ -3,8 +3,10 @@ module Projects module GroupLinks class DestroyService < BaseService - def execute(group_link) - return false unless group_link + def execute(group_link, skip_authorization: false) + unless valid_to_destroy?(group_link, skip_authorization) + return ServiceResponse.error(message: 'Not found', reason: :not_found) + end if group_link.project.private? TodosDestroyer::ProjectPrivateWorker.perform_in(Todo::WAIT_FOR_DELETE, project.id) @@ -12,20 +14,29 @@ module Projects TodosDestroyer::ConfidentialIssueWorker.perform_in(Todo::WAIT_FOR_DELETE, nil, project.id) end - group_link.destroy.tap do |link| - refresh_project_authorizations_asynchronously(link.project) + link = group_link.destroy - # Until we compare the inconsistency rates of the new specialized worker and - # the old approach, we still run AuthorizedProjectsWorker - # but with some delay and lower urgency as a safety net. - link.group.refresh_members_authorized_projects( - priority: UserProjectAccessChangedService::LOW_PRIORITY - ) - end + refresh_project_authorizations_asynchronously(link.project) + + # Until we compare the inconsistency rates of the new specialized worker and + # the old approach, we still run AuthorizedProjectsWorker + # but with some delay and lower urgency as a safety net. + link.group.refresh_members_authorized_projects( + priority: UserProjectAccessChangedService::LOW_PRIORITY + ) + + ServiceResponse.success(payload: { link: link }) end private + def valid_to_destroy?(group_link, skip_authorization) + return false unless group_link + return true if skip_authorization + + current_user.can?(:admin_project_group_link, group_link) + end + def refresh_project_authorizations_asynchronously(project) AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) end diff --git a/app/services/projects/group_links/update_service.rb b/app/services/projects/group_links/update_service.rb index 9b2565adaca..04f1552d929 100644 --- a/app/services/projects/group_links/update_service.rb +++ b/app/services/projects/group_links/update_service.rb @@ -10,15 +10,23 @@ module Projects end def execute(group_link_params) + return ServiceResponse.error(message: 'Not found', reason: :not_found) unless allowed_to_update? + group_link.update!(group_link_params) refresh_authorizations if requires_authorization_refresh?(group_link_params) + + ServiceResponse.success end private attr_reader :group_link + def allowed_to_update? + current_user.can?(:admin_project_member, project) + end + def refresh_authorizations AuthorizedProjectUpdate::ProjectRecalculateWorker.perform_async(project.id) diff --git a/app/services/projects/update_pages_service.rb b/app/services/projects/update_pages_service.rb index ab38efff7c9..83b28840d39 100644 --- a/app/services/projects/update_pages_service.rb +++ b/app/services/projects/update_pages_service.rb @@ -33,7 +33,7 @@ module Projects break error('The uploaded artifact size does not match the expected value') unless deployment break error(deployment_update.errors.first.full_message) unless deployment_update.valid? - update_project_pages_deployment(deployment) + deactive_old_deployments(deployment) success end rescue StandardError => e @@ -45,7 +45,6 @@ module Projects def success commit_status.success - @project.mark_pages_as_deployed publish_deployed_event super end @@ -84,11 +83,11 @@ module Projects def create_pages_deployment(artifacts_path, build) File.open(artifacts_path) do |file| attributes = pages_deployment_attributes(file, build) - deployment = project.pages_deployments.create!(**attributes) + deployment = project.pages_deployments.build(**attributes) - break if deployment.size != file.size || deployment.file.size != file.size + break if deployment.file.size != file.size - deployment + deployment.tap(&:save!) end end @@ -103,9 +102,7 @@ module Projects } end - def update_project_pages_deployment(deployment) - project.update_pages_deployment!(deployment) - + def deactive_old_deployments(deployment) PagesDeployment.deactivate_deployments_older_than( deployment, time: OLD_DEPLOYMENTS_DESTRUCTION_DELAY.from_now) diff --git a/app/services/projects/update_repository_storage_service.rb b/app/services/projects/update_repository_storage_service.rb index 85fb1890fcd..a9f6afb26c9 100644 --- a/app/services/projects/update_repository_storage_service.rb +++ b/app/services/projects/update_repository_storage_service.rb @@ -8,7 +8,9 @@ module Projects private - def track_repository(_destination_storage_name) + def track_repository(destination_storage_name) + project.update!(repository_storage: destination_storage_name) + # Connect project to pool repository from the new shard project.swap_pool_repository! diff --git a/app/services/projects/update_service.rb b/app/services/projects/update_service.rb index e5e39247dbf..336e887c241 100644 --- a/app/services/projects/update_service.rb +++ b/app/services/projects/update_service.rb @@ -58,11 +58,11 @@ module Projects def validate! unless valid_visibility_level_change?(project, project.visibility_attribute_value(params)) - raise ValidationError, s_('UpdateProject|New visibility level not allowed!') + raise_validation_error(s_('UpdateProject|New visibility level not allowed!')) end if renaming_project_with_container_registry_tags? - raise ValidationError, s_('UpdateProject|Cannot rename project because it contains container registry tags!') + raise_validation_error(s_('UpdateProject|Cannot rename project because it contains container registry tags!')) end validate_default_branch_change @@ -78,21 +78,22 @@ module Projects params[:previous_default_branch] = previous_default_branch if !project.root_ref?(new_default_branch) && has_custom_head_branch? - raise ValidationError, + raise_validation_error( format( s_("UpdateProject|Could not set the default branch. Do you have a branch named 'HEAD' in your repository? (%{linkStart}How do I fix this?%{linkEnd})"), linkStart: ambiguous_head_documentation_link, linkEnd: '</a>' ).html_safe + ) end after_default_branch_change(previous_default_branch) else - raise ValidationError, s_("UpdateProject|Could not set the default branch") + raise_validation_error(s_("UpdateProject|Could not set the default branch")) end end def ambiguous_head_documentation_link - url = Rails.application.routes.url_helpers.help_page_path('user/project/repository/branches/index.md', anchor: 'error-ambiguous-head-branch-exists') + url = Rails.application.routes.url_helpers.help_page_path('user/project/repository/branches/index', anchor: 'error-ambiguous-head-branch-exists') format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: url) end @@ -144,6 +145,10 @@ module Projects AfterRenameService.new(project, path_before: project.path_before_last_save, full_path_before: project.full_path_before_last_save) end + def raise_validation_error(message) + raise ValidationError, message + end + def update_failed! model_errors = project.errors.full_messages.to_sentence error_message = model_errors.presence || s_('UpdateProject|Project could not be updated!') diff --git a/app/services/releases/base_service.rb b/app/services/releases/base_service.rb index 5d6cb372653..088776b896c 100644 --- a/app/services/releases/base_service.rb +++ b/app/services/releases/base_service.rb @@ -111,6 +111,10 @@ module Releases # overridden in EE def project_group_id; end + + def audit(release, action:) + # overridden in EE + end end end diff --git a/app/services/releases/create_service.rb b/app/services/releases/create_service.rb index 95e0861a37a..38c9e6d60a7 100644 --- a/app/services/releases/create_service.rb +++ b/app/services/releases/create_service.rb @@ -18,12 +18,6 @@ module Releases return tag unless tag.is_a?(Gitlab::Git::Tag) - if project.catalog_resource - response = Ci::Catalog::Resources::ValidateService.new(project, ref).execute - - return error(response.message) if response.error? - end - create_release(tag, evidence_pipeline) end @@ -56,6 +50,12 @@ module Releases def create_release(tag, evidence_pipeline) release = build_release(tag) + if project.catalog_resource && release.valid? + response = Ci::Catalog::Resources::ReleaseService.new(release).execute + + return error(response.message, 422) if response.error? + end + release.save! notify_create_release(release) @@ -64,6 +64,8 @@ module Releases create_evidence!(release, evidence_pipeline) + audit(release, action: :created) + success(tag: tag, release: release) rescue StandardError => e error(e.message, 400) diff --git a/app/services/releases/destroy_service.rb b/app/services/releases/destroy_service.rb index 78613c05ff1..1e8338651a8 100644 --- a/app/services/releases/destroy_service.rb +++ b/app/services/releases/destroy_service.rb @@ -11,6 +11,8 @@ module Releases execute_hooks(release, 'delete') + audit(release, action: :deleted) + success(tag: existing_tag, release: release) else error(release.errors.messages || '400 Bad request', 400) diff --git a/app/services/releases/update_service.rb b/app/services/releases/update_service.rb index c11d9468814..13ece1c10c8 100644 --- a/app/services/releases/update_service.rb +++ b/app/services/releases/update_service.rb @@ -19,6 +19,8 @@ module Releases ApplicationRecord.transaction do if release.update(params) execute_hooks(release, 'update') + audit(release, action: :updated) + audit(release, action: :milestones_updated) if milestones_updated?(previous_milestones) success(tag: existing_tag, release: release, milestones_updated: milestones_updated?(previous_milestones)) else error(release.errors.messages || '400 Bad request', 400) diff --git a/app/services/resource_access_tokens/create_service.rb b/app/services/resource_access_tokens/create_service.rb index 1c496aa5e77..824b1a8c377 100644 --- a/app/services/resource_access_tokens/create_service.rb +++ b/app/services/resource_access_tokens/create_service.rb @@ -17,6 +17,8 @@ module ResourceAccessTokens access_level = params[:access_level] || Gitlab::Access::MAINTAINER return error("Could not provision owner access to project access token") if do_not_allow_owner_access_level_for_project_bot?(access_level) + return error("Access level of the token can't be greater the access level of the user who created the token") unless validate_access_level(access_level) + return error(s_('AccessTokens|Access token limit reached')) if reached_access_token_limit? user = create_user @@ -125,6 +127,14 @@ module ResourceAccessTokens ServiceResponse.success(payload: { access_token: access_token }) end + def validate_access_level(access_level) + return true unless resource.is_a?(Project) + return true if current_user.bot? + return true if current_user.can?(:manage_owners, resource) + + current_user.authorized_project?(resource, access_level.to_i) + end + def do_not_allow_owner_access_level_for_project_bot?(access_level) resource.is_a?(Project) && access_level.to_i == Gitlab::Access::OWNER && diff --git a/app/services/resource_events/base_synthetic_notes_builder_service.rb b/app/services/resource_events/base_synthetic_notes_builder_service.rb index e675bb61072..9943fd4910b 100644 --- a/app/services/resource_events/base_synthetic_notes_builder_service.rb +++ b/app/services/resource_events/base_synthetic_notes_builder_service.rb @@ -44,10 +44,9 @@ module ResourceEvents end def resource_parent - strong_memoize(:resource_parent) do - resource.project || resource.group - end + resource.try(:resource_parent) || resource.project || resource.group end + strong_memoize_attr :resource_parent def table_name raise NotImplementedError diff --git a/app/services/resource_events/merge_into_notes_service.rb b/app/services/resource_events/merge_into_notes_service.rb index ea465c1e75e..eb0023937b2 100644 --- a/app/services/resource_events/merge_into_notes_service.rb +++ b/app/services/resource_events/merge_into_notes_service.rb @@ -37,4 +37,4 @@ module ResourceEvents end end -ResourceEvents::MergeIntoNotesService.prepend_mod_with('ResourceEvents::MergeIntoNotesService') +ResourceEvents::MergeIntoNotesService.prepend_mod diff --git a/app/services/security/ci_configuration/sast_parser_service.rb b/app/services/security/ci_configuration/sast_parser_service.rb index 16a9efcefdf..f466dd0b649 100644 --- a/app/services/security/ci_configuration/sast_parser_service.rb +++ b/app/services/security/ci_configuration/sast_parser_service.rb @@ -89,17 +89,15 @@ module Security def gitlab_ci_yml_attributes @gitlab_ci_yml_attributes ||= begin - config_content = @project.repository.blob_data_at(@project.repository.root_ref_sha, ci_config_file) + config_content = @project.repository.blob_data_at( + @project.repository.root_ref_sha, @project.ci_config_path_or_default + ) return {} unless config_content build_sast_attributes(config_content) end end - def ci_config_file - '.gitlab-ci.yml' - end - def build_sast_attributes(content) options = { project: @project, user: current_user, sha: @project.repository.commit.sha } yaml_result = Gitlab::Ci::YamlProcessor.new(content, options).execute diff --git a/app/services/service_desk/custom_email_verifications/update_service.rb b/app/services/service_desk/custom_email_verifications/update_service.rb index 5ef36ce0576..fbd217e3a3e 100644 --- a/app/services/service_desk/custom_email_verifications/update_service.rb +++ b/app/services/service_desk/custom_email_verifications/update_service.rb @@ -8,7 +8,7 @@ module ServiceDesk def execute return error_feature_flag_disabled unless Feature.enabled?(:service_desk_custom_email, project) return error_parameter_missing if settings.blank? || verification.blank? - return error_already_finished if already_finished_and_no_mail? + return error_already_finished if verification.finished? return error_already_failed if already_failed_and_no_mail? verification_error = verify @@ -39,10 +39,6 @@ module ServiceDesk @verification ||= settings.custom_email_verification end - def already_finished_and_no_mail? - verification.finished? && mail.blank? - end - def already_failed_and_no_mail? verification.failed? && mail.blank? end diff --git a/app/services/service_desk/custom_emails/create_service.rb b/app/services/service_desk/custom_emails/create_service.rb index 305f5b3fa11..c06c836f0fa 100644 --- a/app/services/service_desk/custom_emails/create_service.rb +++ b/app/services/service_desk/custom_emails/create_service.rb @@ -42,6 +42,8 @@ module ServiceDesk def create_credential credential = ::ServiceDesk::CustomEmailCredential.new(create_credential_params.merge(project: project)) credential.save + rescue ArgumentError + false end def create_verification @@ -53,7 +55,7 @@ module ServiceDesk end def create_credential_params - ensure_params.permit(:smtp_address, :smtp_port, :smtp_username, :smtp_password) + ensure_params.permit(:smtp_address, :smtp_port, :smtp_username, :smtp_password, :smtp_authentication) end def ensure_params diff --git a/app/services/service_desk_settings/update_service.rb b/app/services/service_desk_settings/update_service.rb index 182022beb1d..f8b825923f3 100644 --- a/app/services/service_desk_settings/update_service.rb +++ b/app/services/service_desk_settings/update_service.rb @@ -9,6 +9,8 @@ module ServiceDeskSettings params[:project_key] = nil if params[:project_key].blank? + apply_feature_flag_restrictions! + # We want to know when custom email got enabled write_log_message = params[:custom_email_enabled].present? && !settings.custom_email_enabled? @@ -20,5 +22,14 @@ module ServiceDeskSettings ServiceResponse.error(message: settings.errors.full_messages.to_sentence) end end + + private + + def apply_feature_flag_restrictions! + return if Feature.enabled?(:issue_email_participants, project) + return unless params.include?(:add_external_participants_from_cc) + + params.delete(:add_external_participants_from_cc) + end end end diff --git a/app/services/spam/spam_action_service.rb b/app/services/spam/spam_action_service.rb index 6ec8d09c37c..cca0bb709aa 100644 --- a/app/services/spam/spam_action_service.rb +++ b/app/services/spam/spam_action_service.rb @@ -78,14 +78,17 @@ module Spam when BLOCK_USER target.spam! create_spam_log + create_spam_abuse_event(result) ban_user! when DISALLOW target.spam! create_spam_log + create_spam_abuse_event(result) when CONDITIONAL_ALLOW # This means "require a CAPTCHA to be solved" target.needs_recaptcha! create_spam_log + create_spam_abuse_event(result) when OVERRIDE_VIA_ALLOW_POSSIBLE_SPAM create_spam_log when ALLOW @@ -118,6 +121,22 @@ module Spam target.spam_log = spam_log end + def create_spam_abuse_event(result) + params = { + user_id: user.id, + title: target.spam_title, + description: target.spam_description, + source_ip: spam_params&.ip_address, + user_agent: spam_params&.user_agent, + noteable_type: noteable_type, + verdict: result + } + + target.run_after_commit_or_now do + Abuse::SpamAbuseEventsWorker.perform_async(params) + end + end + def ban_user! UserCustomAttribute.set_banned_by_spam_log(target.spam_log) diff --git a/app/services/system_notes/issuables_service.rb b/app/services/system_notes/issuables_service.rb index 8442ff81d41..c584d5ccca3 100644 --- a/app/services/system_notes/issuables_service.rb +++ b/app/services/system_notes/issuables_service.rb @@ -437,7 +437,7 @@ module SystemNotes def discussion_lock action = noteable.discussion_locked? ? 'locked' : 'unlocked' - body = "#{action} this #{noteable.class.to_s.titleize.downcase}" + body = "#{action} the discussion in this #{noteable.class.to_s.titleize.downcase}" if action == 'locked' track_issue_event(:track_issue_locked_action) diff --git a/app/services/users/refresh_authorized_projects_service.rb b/app/services/users/refresh_authorized_projects_service.rb index 32acc3f170d..6ec87df9f76 100644 --- a/app/services/users/refresh_authorized_projects_service.rb +++ b/app/services/users/refresh_authorized_projects_service.rb @@ -72,6 +72,8 @@ module Users changes.remove_projects_for_user(user, remove) end.apply! + user.update!(project_authorizations_recalculated_at: Time.zone.now) if remove.any? || add.any? + # Since we batch insert authorization rows, Rails' associations may get # out of sync. As such we force a reload of the User object. user.reset diff --git a/app/services/users/upsert_credit_card_validation_service.rb b/app/services/users/upsert_credit_card_validation_service.rb index 62df676db25..e0f81971944 100644 --- a/app/services/users/upsert_credit_card_validation_service.rb +++ b/app/services/users/upsert_credit_card_validation_service.rb @@ -2,41 +2,68 @@ module Users class UpsertCreditCardValidationService < BaseService + attr_reader :params + def initialize(params) @params = params.to_h.with_indifferent_access end def execute - user_id = params.fetch(:user_id) - - @params = { - user_id: user_id, - credit_card_validated_at: params.fetch(:credit_card_validated_at), - expiration_date: get_expiration_date(params), - last_digits: Integer(params.fetch(:credit_card_mask_number), 10), - network: params.fetch(:credit_card_type), - holder_name: params.fetch(:credit_card_holder_name) - } - credit_card = Users::CreditCardValidation.find_or_initialize_by_user(user_id) - credit_card.update(@params.except(:user_id)) + credit_card_params = { + credit_card_validated_at: credit_card_validated_at, + last_digits: last_digits, + holder_name: holder_name, + network: network, + expiration_date: expiration_date + } + + credit_card.update(credit_card_params) - ServiceResponse.success(message: 'CreditCardValidation was set') - rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation => e - ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") + success + rescue ActiveRecord::InvalidForeignKey, ActiveRecord::NotNullViolation + error rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, params: @params, class: self.class.to_s) - ServiceResponse.error(message: "Could not set CreditCardValidation: #{e.message}") + Gitlab::ErrorTracking.track_exception(e) + error end private - def get_expiration_date(params) + def user_id + params.fetch(:user_id) + end + + def credit_card_validated_at + params.fetch(:credit_card_validated_at) + end + + def last_digits + Integer(params.fetch(:credit_card_mask_number), 10) + end + + def holder_name + params.fetch(:credit_card_holder_name) + end + + def network + params.fetch(:credit_card_type) + end + + def expiration_date year = params.fetch(:credit_card_expiration_year) month = params.fetch(:credit_card_expiration_month) Date.new(year, month, -1) # last day of the month end + + def success + ServiceResponse.success(message: _('Credit card validation record saved')) + end + + def error + ServiceResponse.error(message: _('Error saving credit card validation record')) + end end end diff --git a/app/services/verify_pages_domain_service.rb b/app/services/verify_pages_domain_service.rb index 59c73aa929c..f5dfe13539b 100644 --- a/app/services/verify_pages_domain_service.rb +++ b/app/services/verify_pages_domain_service.rb @@ -79,7 +79,7 @@ class VerifyPagesDomainService < BaseService # A domain is only expired until `disable!` has been called def expired? - domain.enabled_until && domain.enabled_until < Time.current + domain.enabled_until&.past? end def dns_record_present? diff --git a/app/services/vs_code/settings/delete_service.rb b/app/services/vs_code/settings/delete_service.rb new file mode 100644 index 00000000000..a2edd734eb2 --- /dev/null +++ b/app/services/vs_code/settings/delete_service.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module VsCode + module Settings + class DeleteService + def initialize(current_user:) + @current_user = current_user + end + + def execute + VsCodeSetting.by_user(current_user).delete_all + + ServiceResponse.success + end + + private + + attr_reader :current_user + end + end +end diff --git a/app/services/web_hook_service.rb b/app/services/web_hook_service.rb index 27b29feed50..035f1754cbb 100644 --- a/app/services/web_hook_service.rb +++ b/app/services/web_hook_service.rb @@ -83,13 +83,13 @@ class WebHookService log_execution( response: response, - execution_duration: Gitlab::Metrics::System.monotonic_time - start_time + execution_duration: ::Gitlab::Metrics::System.monotonic_time - start_time ) ServiceResponse.success(message: response.body, payload: { http_status: response.code }) rescue *Gitlab::HTTP::HTTP_ERRORS, Gitlab::Json::LimitedEncoder::LimitExceeded, URI::InvalidURIError => e - execution_duration = Gitlab::Metrics::System.monotonic_time - start_time + execution_duration = ::Gitlab::Metrics::System.monotonic_time - start_time error_message = e.to_s log_execution( @@ -110,10 +110,10 @@ class WebHookService break log_recursion_blocked if recursion_blocked? params = { - recursion_detection_request_uuid: Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid + "recursion_detection_request_uuid" => Gitlab::WebHooks::RecursionDetection::UUID.instance.request_uuid }.compact - WebHookWorker.perform_async(hook.id, data, hook_name, params) + WebHookWorker.perform_async(hook.id, data.deep_stringify_keys, hook_name.to_s, params) end end @@ -170,7 +170,9 @@ class WebHookService def queue_log_execution_with_retry(log_data, category) retried = false begin - ::WebHooks::LogExecutionWorker.perform_async(hook.id, log_data, category, uniqueness_token) + ::WebHooks::LogExecutionWorker.perform_async( + hook.id, log_data.deep_stringify_keys, category.to_s, uniqueness_token.to_s + ) rescue Gitlab::SidekiqMiddleware::SizeLimiter::ExceedLimitError raise if retried diff --git a/app/validators/ip_cidr_array_validator.rb b/app/validators/ip_cidr_array_validator.rb new file mode 100644 index 00000000000..fff1368508f --- /dev/null +++ b/app/validators/ip_cidr_array_validator.rb @@ -0,0 +1,25 @@ +# frozen_string_literal: true + +# IpCidrArrayValidator +# +# Validates that an array of IP are a valid IPv4 or IPv6 CIDR address. +# +# Example: +# +# class Group < ActiveRecord::Base +# validates :ip_array, presence: true, ip_cidr_array: true +# end + +class IpCidrArrayValidator < ActiveModel::EachValidator # rubocop:disable Gitlab/NamespacedClass -- This is a globally shareable validator, but it's unclear what namespace it should belong in + def validate_each(record, attribute, value) + unless value.is_a?(Array) + record.errors.add(attribute, _("must be an array of CIDR values")) + return + end + + value.each do |cidr| + single_validator = IpCidrValidator.new(attributes: attribute) + single_validator.validate_each(record, attribute, cidr) + end + end +end diff --git a/app/validators/ip_cidr_validator.rb b/app/validators/ip_cidr_validator.rb new file mode 100644 index 00000000000..b1760a99d6d --- /dev/null +++ b/app/validators/ip_cidr_validator.rb @@ -0,0 +1,45 @@ +# frozen_string_literal: true + +# IpCidrValidator +# +# Validates that an IP is a valid IPv4 or IPv6 CIDR address. +# +# Example: +# +# class Group < ActiveRecord::Base +# validates :ip, presence: true, ip_cidr: true +# end + +class IpCidrValidator < ActiveModel::EachValidator # rubocop:disable Gitlab/NamespacedClass -- This is a globally shareable validator, but it's unclear what namespace it should belong in + def validate_each(record, attribute, value) + # NOTE: We want this to be usable for nullable fields, so we don't validate presence. + # Use a separate `presence` validation for the field if needed. + return true if value.blank? + + # rubocop:disable Layout/LineLength -- The error message is bigger than the line limit + unless valid_cidr_format?(value) + record.errors.add( + attribute, + format(_( + "IP '%{value}' is not a valid CIDR: IP should be followed by a slash followed by an integer subnet mask (for example: '192.168.1.0/24')"), + value: value + ) + ) + return + end + # rubocop:enable Layout/LineLength + + IPAddress.parse(value) + rescue ArgumentError => e + record.errors.add( + attribute, + format(_("IP '%{value}' is not a valid CIDR: %{message}"), value: value, message: e.message) + ) + end + + private + + def valid_cidr_format?(cidr) + cidr.count('/') == 1 && cidr.split('/').last =~ /^\d+$/ + end +end diff --git a/app/validators/json_schemas/activity_pub_follow_payload.json b/app/validators/json_schemas/activity_pub_follow_payload.json new file mode 100644 index 00000000000..1f453ce840f --- /dev/null +++ b/app/validators/json_schemas/activity_pub_follow_payload.json @@ -0,0 +1,53 @@ +{ + "description": "ActivityPub Follow activity payload", + "type": "object", + "required": [ + "@context", + "id", + "type", + "actor", + "object" + ], + "properties": { + "@context": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "array" + } + ] + }, + "id": { + "type": "string" + }, + "type": { + "type": "string" + }, + "actor": { + "oneOf": [ + { + "type": "string" + }, + { + "type": "object", + "required": [ + "id" + ], + "id": { + "type": "string" + }, + "inbox": { + "type": "string" + }, + "additionalProperties": true + } + ] + }, + "object": { + "type": "string" + }, + "additionalProperties": true + } +} diff --git a/app/validators/json_schemas/vulnerability_cvss_vectors.json b/app/validators/json_schemas/vulnerability_cvss_vectors.json index 7ec1339e974..0da6de0a69d 100644 --- a/app/validators/json_schemas/vulnerability_cvss_vectors.json +++ b/app/validators/json_schemas/vulnerability_cvss_vectors.json @@ -9,14 +9,14 @@ "type": "string", "default": "unknown" }, - "vector_string": { + "vector": { "type": "string", "example": "CVSS:3.1/AV:N/AC:L/PR:N/UI:R/S:U/C:H/I:H/A:H" } }, "required": [ "vendor", - "vector_string" + "vector" ] } } diff --git a/app/views/admin/abuse_reports/show.html.haml b/app/views/admin/abuse_reports/show.html.haml index bd7a1054b5d..ff9ac6a052c 100644 --- a/app/views/admin/abuse_reports/show.html.haml +++ b/app/views/admin/abuse_reports/show.html.haml @@ -1,5 +1,6 @@ - add_to_breadcrumbs _('Abuse Reports'), admin_abuse_reports_path - breadcrumb_title @abuse_report.user&.name +- @content_class = "limit-container-width" unless fluid_layout - page_title @abuse_report.user&.name, _('Abuse Reports') #js-abuse-reports-detail-view{ data: abuse_report_data(@abuse_report) } diff --git a/app/views/admin/application_settings/_account_and_limit.html.haml b/app/views/admin/application_settings/_account_and_limit.html.haml index 4e55c99e445..1d58b0106c4 100644 --- a/app/views/admin/application_settings/_account_and_limit.html.haml +++ b/app/views/admin/application_settings/_account_and_limit.html.haml @@ -24,12 +24,13 @@ %span.form-text.text-muted#session_expire_delay_help_block= _('Restart GitLab to apply changes.') .form-group = f.label :remember_me_enabled, _('Remember me'), class: 'label-light' - - remember_me_help_link = help_page_path('user/profile/index.md', anchor: 'stay-signed-in-for-two-weeks') + - remember_me_help_link = help_page_path('user/profile/index', anchor: 'stay-signed-in-for-two-weeks') - remember_me_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: remember_me_help_link } = f.gitlab_ui_checkbox_component :remember_me_enabled, _('Allow users to extend their session'), help_text: _("Users can select 'Remember me' on sign-in to keep their session active beyond the session duration. %{link_start}Learn more.%{link_end}").html_safe % { link_start: remember_me_help_link_start, link_end: '</a>'.html_safe } = render_if_exists 'admin/application_settings/git_two_factor_session_expiry', form: f = render_if_exists 'admin/application_settings/personal_access_token_expiration_policy', form: f + = render_if_exists 'admin/application_settings/service_access_tokens_expiration_enforced', form: f = render_if_exists 'admin/application_settings/ssh_key_expiration_policy', form: f .form-group diff --git a/app/views/admin/application_settings/_ci_cd.html.haml b/app/views/admin/application_settings/_ci_cd.html.haml index c08270a8522..8092299fb61 100644 --- a/app/views/admin/application_settings/_ci_cd.html.haml +++ b/app/views/admin/application_settings/_ci_cd.html.haml @@ -4,7 +4,7 @@ %fieldset .form-group - - devops_help_link_url = help_page_path('topics/autodevops/index.md') + - devops_help_link_url = help_page_path('topics/autodevops/index') - devops_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: devops_help_link_url } = f.gitlab_ui_checkbox_component :auto_devops_enabled, s_('CICD|Default to Auto DevOps pipeline for all projects'), help_text: s_('CICD|The Auto DevOps pipeline runs by default in all projects with no CI/CD configuration file. %{link_start}What is Auto DevOps?%{link_end}').html_safe % { link_start: devops_help_link_start, link_end: '</a>'.html_safe } .form-group @@ -12,7 +12,7 @@ = f.text_field :auto_devops_domain, class: 'form-control gl-form-input', placeholder: 'example.com' .form-text.text-muted = s_("AdminSettings|The default domain to use for Auto Review Apps and Auto Deploy stages in all projects.") - = link_to _('Learn more.'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-review-apps'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('topics/autodevops/stages', anchor: 'auto-review-apps'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :shared_runners_enabled, s_("AdminSettings|Enable shared runners for new projects"), help_text: s_("AdminSettings|All new projects can use the instance's shared runners by default.") @@ -59,6 +59,8 @@ .form-group = f.gitlab_ui_checkbox_component :suggest_pipeline_enabled, s_('AdminSettings|Enable pipeline suggestion banner'), help_text: s_('AdminSettings|Display a banner on merge requests in projects with no pipelines to initiate steps to add a .gitlab-ci.yml file.') #js-runner-token-expiration-intervals{ data: runner_token_expiration_interval_attributes } + .form-group + = f.gitlab_ui_checkbox_component :enable_artifact_external_redirect_warning_page, s_('AdminSettings|Enable the external redirect warning page for job artifacts'), help_text: s_('AdminSettings|Show a redirect page that warns you about user-generated content in GitLab Pages.') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_diagramsnet.html.haml b/app/views/admin/application_settings/_diagramsnet.html.haml index 0cf44938881..0d44b38b0e0 100644 --- a/app/views/admin/application_settings/_diagramsnet.html.haml +++ b/app/views/admin/application_settings/_diagramsnet.html.haml @@ -7,7 +7,7 @@ = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Render diagrams in your documents using diagrams.net.') - = link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/integration/diagrams_net'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-diagramsnet-settings'), html: { class: 'fieldset-form', id: 'diagramsnet-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_email.html.haml b/app/views/admin/application_settings/_email.html.haml index 2d45391a839..a9bc8ab9d32 100644 --- a/app/views/admin/application_settings/_email.html.haml +++ b/app/views/admin/application_settings/_email.html.haml @@ -10,7 +10,7 @@ = f.label :commit_email_hostname, _('Custom hostname (for private commit emails)'), class: 'label-bold' = f.text_field :commit_email_hostname, class: 'form-control gl-form-input' .form-text.text-muted - - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('administration/settings/email.md', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer' + - commit_email_hostname_docs_link = link_to _('Learn more'), help_page_path('administration/settings/email', anchor: 'custom-hostname-for-private-commit-emails'), target: '_blank', rel: 'noopener noreferrer' = _("Hostname used in private commit emails. %{learn_more}").html_safe % { learn_more: commit_email_hostname_docs_link } = render_if_exists 'admin/application_settings/email_additional_text_setting', form: f diff --git a/app/views/admin/application_settings/_error_tracking.html.haml b/app/views/admin/application_settings/_error_tracking.html.haml index 6754dd99bbc..ab4ed9917a0 100644 --- a/app/views/admin/application_settings/_error_tracking.html.haml +++ b/app/views/admin/application_settings/_error_tracking.html.haml @@ -7,8 +7,8 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - = _('Allows projects to track errors using an Opstrace integration.').html_safe % { link: help_page_path('operations/error_tracking.md') } - = link_to _('Learn more.'), help_page_path('operations/error_tracking.md'), target: '_blank', rel: 'noopener noreferrer' + = _('Allows projects to track errors using an Opstrace integration.').html_safe % { link: help_page_path('operations/error_tracking') } + = link_to _('Learn more.'), help_page_path('operations/error_tracking'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/_floc.html.haml b/app/views/admin/application_settings/_floc.html.haml index e1576e84e66..27df417d225 100644 --- a/app/views/admin/application_settings/_floc.html.haml +++ b/app/views/admin/application_settings/_floc.html.haml @@ -7,7 +7,7 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary - - floc_link_url = help_page_path('administration/settings/floc.md') + - floc_link_url = help_page_path('administration/settings/floc') - floc_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: floc_link_url } = html_escape(s_('FloC|Configure whether you want to participate in FLoC. %{floc_link_start}What is FLoC?%{floc_link_end}')) % { floc_link_start: floc_link_start, floc_link_end: '</a>'.html_safe } diff --git a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml index 64549b97bd1..22372146ea1 100644 --- a/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml +++ b/app/views/admin/application_settings/_gitlab_shell_operation_limits.html.haml @@ -6,7 +6,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = s_('ShellOperations|Limit the number of Git operations a user can perform per minute, per repository.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limits_on_git_ssh_operations.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limits_on_git_ssh_operations'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-gitlab-shell-operation-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) @@ -15,5 +15,5 @@ .form-group = f.label :gitlab_shell_operation_limit, s_('ShellOperations|Maximum number of Git operations per minute'), class: 'gl-font-bold' = f.number_field :gitlab_shell_operation_limit, class: 'form-control gl-form-input' - + %span.form-text.text-muted= _('Set to 0 to disable the limit.') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_gitpod.html.haml b/app/views/admin/application_settings/_gitpod.html.haml index 1f56487cea4..ce8c390baa5 100644 --- a/app/views/admin/application_settings/_gitpod.html.haml +++ b/app/views/admin/application_settings/_gitpod.html.haml @@ -8,7 +8,7 @@ = expanded ? _('Collapse') : _('Expand') .gl-text-secondary.gl-mb-5 #js-gitpod-settings-help-text{ data: {"message" => gitpod_enable_description, "message-url" => "https://gitpod.io/" } } - = link_to sprite_icon('question-o'), help_page_path('integration/gitpod.md'), target: '_blank', class: 'has-tooltip', title: _('More information') + = link_to sprite_icon('question-o'), help_page_path('integration/gitpod'), target: '_blank', class: 'has-tooltip', title: _('More information') .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-gitpod-settings'), html: { class: 'fieldset-form', id: 'gitpod-settings' } do |f| diff --git a/app/views/admin/application_settings/_import_export_limits.html.haml b/app/views/admin/application_settings/_import_export_limits.html.haml index 8cb7915f847..269a1497324 100644 --- a/app/views/admin/application_settings/_import_export_limits.html.haml +++ b/app/views/admin/application_settings/_import_export_limits.html.haml @@ -2,8 +2,7 @@ = form_errors(@application_setting) %fieldset - = html_escape(_("Set any rate limit to %{code_open}0%{code_close} to disable the limit.")) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - + = html_escape(_("Set to 0 to disable the limits.")) %fieldset .form-group diff --git a/app/views/admin/application_settings/_kroki.html.haml b/app/views/admin/application_settings/_kroki.html.haml index 4dbca235a73..0f1316996fa 100644 --- a/app/views/admin/application_settings/_kroki.html.haml +++ b/app/views/admin/application_settings/_kroki.html.haml @@ -7,7 +7,7 @@ = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Users can render diagrams in AsciiDoc, Markdown, reStructuredText, and Textile documents using Kroki.') - = link_to _('Learn more.'), help_page_path('administration/integration/kroki.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/integration/kroki'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-kroki-settings'), html: { class: 'fieldset-form', id: 'kroki-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_localization.html.haml b/app/views/admin/application_settings/_localization.html.haml index 25038e6f221..62849a81633 100644 --- a/app/views/admin/application_settings/_localization.html.haml +++ b/app/views/admin/application_settings/_localization.html.haml @@ -7,11 +7,11 @@ = f.select :first_day_of_week, first_day_of_week_choices, {}, class: 'form-control' .form-text.text-muted = _('Default first day of the week in calendars and date pickers.') - = link_to _('Learn more.'), help_page_path('administration/settings/localization.md', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/localization', anchor: 'change-the-default-first-day-of-the-week'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.label :time_tracking, _('Time tracking'), class: 'label-bold' - - time_tracking_help_link = help_page_path('user/project/time_tracking.md') + - time_tracking_help_link = help_page_path('user/project/time_tracking') - time_tracking_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: time_tracking_help_link } = f.gitlab_ui_checkbox_component :time_tracking_limit_to_hours, _('Limit display of time tracking units to hours.'), help_text: _('Display time tracking in issues in total hours only. %{link_start}What is time tracking?%{link_end}').html_safe % { link_start: time_tracking_help_link_start, link_end: '</a>'.html_safe } diff --git a/app/views/admin/application_settings/_outbound.html.haml b/app/views/admin/application_settings/_outbound.html.haml index f36fbd8d68c..a4ec3a31584 100644 --- a/app/views/admin/application_settings/_outbound.html.haml +++ b/app/views/admin/application_settings/_outbound.html.haml @@ -26,7 +26,7 @@ = f.text_area :outbound_local_requests_allowlist_raw, placeholder: "example.com, 192.168.1.1, xn--itlab-j1a.com", class: 'form-control gl-form-input', rows: 8 %span.form-text.text-muted = s_('OutboundRequests|Requests can be made to these IP addresses and domains even when local requests are not allowed. IP ranges such as %{code_start}1:0:0:0:0:0:0:0/124%{code_end} and %{code_start}127.0.0.0/28%{code_end} are supported. Domain wildcards are not supported. To separate entries, use commas, semicolons, or newlines. The allowlist can have a maximum of 1000 entries. Domains must be IDNA-encoded.').html_safe % { code_start: '<code>'.html_safe, code_end: '</code>'.html_safe } - = link_to _('Learn more.'), help_page_path('security/webhooks.md', anchor: 'allow-outbound-requests-to-certain-ip-addresses-and-domains'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('security/webhooks', anchor: 'allow-outbound-requests-to-certain-ip-addresses-and-domains'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :dns_rebinding_protection_enabled, diff --git a/app/views/admin/application_settings/_performance.html.haml b/app/views/admin/application_settings/_performance.html.haml index bfa548b70e5..14c785509bd 100644 --- a/app/views/admin/application_settings/_performance.html.haml +++ b/app/views/admin/application_settings/_performance.html.haml @@ -11,16 +11,16 @@ = f.label :raw_blob_request_limit, _('Raw blob request rate limit per minute'), class: 'label-bold' = f.number_field :raw_blob_request_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum number of requests per minute for each raw path (default is `300`). Set to `0` to disable throttling.') + = _('Maximum number of requests per minute for each raw path (default is 300). Set to 0 to disable throttling.') .form-group = f.label :push_event_hooks_limit, class: 'label-bold' = f.number_field :push_event_hooks_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum number of changes (branches or tags) in a single push above which webhooks and integrations are not triggered (default is `3`). Setting to `0` does not disable throttling.') + = _('Maximum number of changes (branches or tags) in a single push above which webhooks and integrations are not triggered (default is 3). Setting to 0 does not disable throttling.') .form-group = f.label :push_event_activities_limit, class: 'label-bold' = f.number_field :push_event_activities_limit, class: 'form-control gl-form-input' .form-text.text-muted - = _('Maximum number of changes (branches or tags) in a single push above which a bulk push event is created (default is `3`). Setting to `0` does not disable throttling.') + = _('Maximum number of changes (branches or tags) in a single push above which a bulk push event is created (default is 3). Setting to 0 does not disable throttling.') = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/admin/application_settings/_plantuml.html.haml b/app/views/admin/application_settings/_plantuml.html.haml index a8b758f7324..c673bf72397 100644 --- a/app/views/admin/application_settings/_plantuml.html.haml +++ b/app/views/admin/application_settings/_plantuml.html.haml @@ -7,7 +7,7 @@ = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Render diagrams in your documents using PlantUML.') - = link_to _('Learn more.'), help_page_path('administration/integration/plantuml.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/integration/plantuml'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: general_admin_application_settings_path(anchor: 'js-plantuml-settings'), html: { class: 'fieldset-form', id: 'plantuml-settings' } do |f| = form_errors(@application_setting) if expanded diff --git a/app/views/admin/application_settings/_projects_api_limits.html.haml b/app/views/admin/application_settings/_projects_api_limits.html.haml index dde8ab07958..c9eff76916a 100644 --- a/app/views/admin/application_settings/_projects_api_limits.html.haml +++ b/app/views/admin/application_settings/_projects_api_limits.html.haml @@ -6,7 +6,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set the per-IP address rate limit applicable to unauthenticated requests for getting a list of projects via the API.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_projects_api.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_projects_api'), target: '_blank', rel: 'noopener noreferrer' .settings-content = gitlab_ui_form_for @application_setting, url: network_admin_application_settings_path(anchor: 'js-projects-api-limits-settings'), html: { class: 'fieldset-form' } do |f| = form_errors(@application_setting) @@ -16,6 +16,6 @@ = f.label :projects_api_rate_limit_unauthenticated, _('Maximum requests per 10 minutes per IP address'), class: 'label-bold' = f.number_field :projects_api_rate_limit_unauthenticated, class: 'form-control gl-form-input' .form-text.gl-text-gray-600 - = _("Set this number to 0 to disable the limit.") + = _("Set to 0 to disable the limit.") = f.submit _('Save changes'), data: { qa_selector: 'save_changes_button' }, pajamas_button: true diff --git a/app/views/admin/application_settings/_repository_check.html.haml b/app/views/admin/application_settings/_repository_check.html.haml index 5751ae9059a..cb1a0a40566 100644 --- a/app/views/admin/application_settings/_repository_check.html.haml +++ b/app/views/admin/application_settings/_repository_check.html.haml @@ -21,7 +21,7 @@ %h4= _("Housekeeping") .form-group - help_text = _("Run housekeeping tasks to automatically optimize Git repositories. Disabling this option will cause performance to degenerate over time.") - - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping.md', anchor: 'heuristical-housekeeping'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('administration/housekeeping', anchor: 'heuristical-housekeeping'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :housekeeping_enabled, _("Enable automatic repository housekeeping"), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } diff --git a/app/views/admin/application_settings/_repository_storage.html.haml b/app/views/admin/application_settings/_repository_storage.html.haml index 066d77c792b..412098cfae4 100644 --- a/app/views/admin/application_settings/_repository_storage.html.haml +++ b/app/views/admin/application_settings/_repository_storage.html.haml @@ -5,7 +5,7 @@ .sub-section %h4= _('Hashed repository storage paths') .form-group - - repository_storage_help_link_url = help_page_path('administration/repository_storage_types.md') + - repository_storage_help_link_url = help_page_path('administration/repository_storage_types') - repository_storage_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_storage_help_link_url } = f.gitlab_ui_checkbox_component :hashed_storage_enabled, _('Use hashed storage'), @@ -17,10 +17,10 @@ .form-group .form-text %p.text-secondary - - weights_link_url = help_page_path('administration/repository_storage_paths.md', anchor: 'configure-where-new-repositories-are-stored') + - weights_link_url = help_page_path('administration/repository_storage_paths', anchor: 'configure-where-new-repositories-are-stored') - weights_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: weights_link_url } = html_escape(s_('Enter %{weights_link_start}weights%{weights_link_end} for storages for new repositories. Configured storages appear below.')) % { weights_link_start: weights_link_start, weights_link_end: '</a>'.html_safe } - = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths'), target: '_blank', rel: 'noopener noreferrer' .form-check = f.fields_for :repository_storages_weighted, storage_weights do |storage_form| - Gitlab.config.repositories.storages.each_key do |storage| diff --git a/app/views/admin/application_settings/_runner_registrars_form.html.haml b/app/views/admin/application_settings/_runner_registrars_form.html.haml index b112c273aad..36bab2f6650 100644 --- a/app/views/admin/application_settings/_runner_registrars_form.html.haml +++ b/app/views/admin/application_settings/_runner_registrars_form.html.haml @@ -7,7 +7,7 @@ = s_('Runners|Runner version management') %span.form-text.gl-mb-3.gl-mt-0 - help_text = s_('Runners|Official runner version data is periodically fetched from GitLab.com to determine whether the runners need upgrades.') - - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope.md', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer' + - learn_more_link = link_to _('Learn more.'), help_page_path('ci/runners/runners_scope', anchor: 'determine-which-runners-need-to-be-upgraded'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :update_runner_versions_enabled, s_('Runners|Fetch GitLab Runner release version data from GitLab.com'), help_text: '%{help_text} %{learn_more_link}'.html_safe % { help_text: help_text, learn_more_link: learn_more_link } diff --git a/app/views/admin/application_settings/_signin.html.haml b/app/views/admin/application_settings/_signin.html.haml index 5518122b5cf..0f20864fc68 100644 --- a/app/views/admin/application_settings/_signin.html.haml +++ b/app/views/admin/application_settings/_signin.html.haml @@ -19,7 +19,7 @@ .form-group = f.label :two_factor_authentication, _('Two-factor authentication'), class: 'label-bold' - help_text = _('Enforce two-factor authentication for all user sign-ins.') - - help_link = link_to _('Learn more.'), help_page_path('security/two_factor_authentication.md'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('security/two_factor_authentication'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :require_two_factor_authentication, _('Enforce two-factor authentication'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } @@ -39,7 +39,7 @@ .form-group = f.label :unknown_sign_in, _('Email notification for unknown sign-ins'), class: 'label-bold' - help_text = _('Notify users by email when sign-in location is not recognized.') - - help_link = link_to _('Learn more.'), help_page_path('user/profile/notifications.md', anchor: 'notifications-for-unknown-sign-ins'), target: '_blank', rel: 'noopener noreferrer' + - help_link = link_to _('Learn more.'), help_page_path('user/profile/notifications', anchor: 'notifications-for-unknown-sign-ins'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :notify_on_unknown_sign_in, _('Enable email notification'), help_text: '%{help_text} %{help_link}'.html_safe % { help_text: help_text, help_link: help_link } diff --git a/app/views/admin/application_settings/_sourcegraph.html.haml b/app/views/admin/application_settings/_sourcegraph.html.haml index 61ec841bb83..e61947e3cff 100644 --- a/app/views/admin/application_settings/_sourcegraph.html.haml +++ b/app/views/admin/application_settings/_sourcegraph.html.haml @@ -12,7 +12,7 @@ - link_end = "#{sprite_icon('external-link', size: 12, css_class: 'ml-1 vertical-align-center')}</a>".html_safe = s_('SourcegraphAdmin|Enable code intelligence powered by %{link_start}Sourcegraph%{link_end} on your GitLab instance\'s code views and merge requests.').html_safe % { link_start: link_start, link_end: link_end } %span - = link_to s_('SourcegraphAdmin|Learn more.'), help_page_path('integration/sourcegraph.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('SourcegraphAdmin|Learn more.'), help_page_path('integration/sourcegraph'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/admin/application_settings/_spam.html.haml b/app/views/admin/application_settings/_spam.html.haml index abc7abe92ad..4e21717a4e6 100644 --- a/app/views/admin/application_settings/_spam.html.haml +++ b/app/views/admin/application_settings/_spam.html.haml @@ -8,7 +8,7 @@ = _('reCAPTCHA helps prevent credential stuffing.') = link_to _('Only reCAPTCHA v2 is supported:'), 'https://developers.google.com/recaptcha/docs/versions', target: '_blank', rel: 'noopener noreferrer' .form-group - - spam_help_link_url = help_page_path('integration/recaptcha.md') + - spam_help_link_url = help_page_path('integration/recaptcha') - spam_help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: spam_help_link_url } = f.gitlab_ui_checkbox_component :recaptcha_enabled, _("Enable reCAPTCHA"), help_text: _('Helps prevent bots from creating accounts. %{link_start}How do I configure it?%{link_end}').html_safe % { link_start: spam_help_link_start, link_end: '</a>'.html_safe } @@ -40,7 +40,7 @@ = _('Akismet') %p = _('Akismet helps prevent the creation of spam issues in public projects.') - = link_to _('How do I configure Akismet?'), help_page_path('integration/akismet.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('How do I configure Akismet?'), help_page_path('integration/akismet'), target: '_blank', rel: 'noopener noreferrer' .form-group = f.gitlab_ui_checkbox_component :akismet_enabled, _('Enable Akismet'), diff --git a/app/views/admin/application_settings/_terms.html.haml b/app/views/admin/application_settings/_terms.html.haml index 8da441d5245..2afcb26b43b 100644 --- a/app/views/admin/application_settings/_terms.html.haml +++ b/app/views/admin/application_settings/_terms.html.haml @@ -10,5 +10,5 @@ = f.text_area :terms, class: 'form-control gl-form-input', rows: 8 .form-text.text-muted = _("Markdown supported.") - = link_to _('What is Markdown?'), help_page_path('user/markdown.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('What is Markdown?'), help_page_path('user/markdown'), target: '_blank', rel: 'noopener noreferrer' = f.submit _("Save changes"), pajamas_button: true diff --git a/app/views/admin/application_settings/_usage.html.haml b/app/views/admin/application_settings/_usage.html.haml index 2d51dc2a6f2..dd9820d064a 100644 --- a/app/views/admin/application_settings/_usage.html.haml +++ b/app/views/admin/application_settings/_usage.html.haml @@ -13,18 +13,30 @@ .form-group - can_be_configured = @application_setting.usage_ping_can_be_configured? - service_ping_link_start = link_start % { url: help_page_path('development/internal_analytics/service_ping/index') } - - deactivating_service_ping_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'disable-usage-statistics-with-the-configuration-file') } + - deactivating_service_ping_link_start = link_start % { url: help_page_path('administration/settings/usage_statistics', anchor: 'through-the-configuration-file') } - usage_ping_help_text = s_('AdminSettings|To help improve GitLab and its user experience, GitLab periodically collects usage information. %{link_start}What information is shared with GitLab Inc.?%{link_end}').html_safe % { link_start: service_ping_link_start, link_end: link_end } - disabled_help_text = s_('AdminSettings|Service ping is disabled in your configuration file, and cannot be enabled through this form. For more information, see the documentation on %{link_start}deactivating service ping%{link_end}.').html_safe % { link_start: deactivating_service_ping_link_start, link_end: link_end } = f.gitlab_ui_checkbox_component :usage_ping_enabled, s_('AdminSettings|Enable Service Ping'), help_text: can_be_configured ? usage_ping_help_text : disabled_help_text, checkbox_options: { disabled: !can_be_configured, data: { testid: 'enable-usage-data-checkbox' } } .form-text.gl-pl-6 - - if can_be_configured - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger', data: { payload_selector: ".#{payload_class}" } }) do - = gl_loading_icon(css_class: 'js-spinner gl-display-none gl-mr-2') - .js-text.gl-display-inline= s_('AdminSettings|Preview payload') - %pre.service-data-payload-container.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } + - if @service_ping_data.present? + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } }) do + = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) + %span.js-text.gl-display-inline= s_('AdminSettings|Preview payload') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }) do + = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) + %span.js-text.gl-display-inline= s_('AdminSettings|Download payload') + %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } + - else + = render Pajamas::AlertComponent.new(variant: :warning, + dismissible: false, + title: s_('AdminSettings|Service Ping payload not found in the application cache')) do |c| + + - c.with_body do + - generate_manually_link = link_to('', help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(s_('AdminSettings|%{generate_manually_link_start}Generate%{generate_manually_link_end} Service Ping to preview and download service usage data payload.'), tag_pair(generate_manually_link, :generate_manually_link_start, :generate_manually_link_end)) + .form-group - usage_ping_enabled = @application_setting.usage_ping_enabled? - label = s_('AdminSettings|Enable Registration Features') diff --git a/app/views/admin/application_settings/general.html.haml b/app/views/admin/application_settings/general.html.haml index dad0bf08bb0..d84fbe94f65 100644 --- a/app/views/admin/application_settings/general.html.haml +++ b/app/views/admin/application_settings/general.html.haml @@ -66,7 +66,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set sign-in restrictions for all users.') - = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/sign_in_restrictions'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'signin' @@ -78,7 +78,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Add a Terms of Service agreement and Privacy Policy for users of this GitLab instance.') - = link_to _('Learn more.'), help_page_path('administration/settings/terms.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/terms'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terms' @@ -95,7 +95,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set the maximum session time for a web terminal.') - = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index.md', anchor: 'web-terminals-deprecated'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('How do I use a web terminal?'), help_page_path('ci/environments/index', anchor: 'web-terminals-deprecated'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terminal' diff --git a/app/views/admin/application_settings/metrics_and_profiling.html.haml b/app/views/admin/application_settings/metrics_and_profiling.html.haml index 188359158ef..23f536bd6d4 100644 --- a/app/views/admin/application_settings/metrics_and_profiling.html.haml +++ b/app/views/admin/application_settings/metrics_and_profiling.html.haml @@ -24,7 +24,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Link to your Grafana instance.') - = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/grafana_configuration'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'grafana' @@ -37,11 +37,11 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Enable access to the performance bar for non-administrators in a given group.') - = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar.md', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/monitoring/performance/performance_bar', anchor: 'enable-the-performance-bar-for-non-administrators'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'performance_bar' -%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'usage_statistics_settings_content' } } +%section.settings.as-usage.no-animate#js-usage-settings{ class: ('expanded' if expanded_by_default?), data: { testid: 'usage-statistics-settings-content' } } .settings-header#usage-statistics %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Usage statistics') @@ -53,7 +53,7 @@ = render 'usage' - if Feature.enabled?(:configure_sentry_in_application_settings) - %section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'sentry_settings_content' } } + %section.settings.as-sentry.no-animate#js-sentry-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Sentry') diff --git a/app/views/admin/application_settings/network.html.haml b/app/views/admin/application_settings/network.html.haml index 849c5c749e0..ae5f7a5cec3 100644 --- a/app/views/admin/application_settings/network.html.haml +++ b/app/views/admin/application_settings/network.html.haml @@ -22,11 +22,11 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set limits for web and API requests.') - = link_to _('Learn more.'), help_page_path('administration/settings/user_and_ip_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/user_and_ip_rate_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'ip_limits' -%section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'packages_limits_content' } } +%section.settings.as-packages-limits.no-animate#js-packages-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Package registry rate limits') @@ -34,7 +34,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set rate limits for package registry API requests that supersede the general user and IP rate limits.') - = link_to _('Learn more.'), help_page_path('administration/settings/package_registry_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/package_registry_rate_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-packages-limits-settings', setting_fragment: 'packages_api' } @@ -68,11 +68,11 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Configure specific limits for deprecated API requests that supersede the general user and IP rate limits.') - = link_to _('Which API requests are affected?'), help_page_path('administration/settings/deprecated_api_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Which API requests are affected?'), help_page_path('administration/settings/deprecated_api_rate_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'network_rate_limits', locals: { anchor: 'js-deprecated-limits-settings', setting_fragment: 'deprecated_api' } -%section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'git_lfs_limits_content' } } +%section.settings.as-git-lfs-limits.no-animate#js-git-lfs-limits-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Git LFS Rate Limits') @@ -80,7 +80,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Configure specific limits for Git LFS requests that supersede the general user and IP rate limits.') - = link_to _('Learn more.'), help_page_path('administration/settings/git_lfs_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/git_lfs_rate_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'git_lfs_limits' @@ -96,7 +96,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = s_('OutboundRequests|Allow requests to the local network from hooks and integrations.') - = link_to _('Learn more.'), help_page_path('security/webhooks.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('security/webhooks'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'outbound' @@ -108,7 +108,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Rate limit access to specified paths.') - = link_to _('Learn more.'), help_page_path('administration/settings/protected_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/protected_paths'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'protected_paths' @@ -121,7 +121,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Limit the number of issues and epics per minute a user can create through web and API requests.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_issues_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_issues_creation'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'issue_limits' @@ -133,7 +133,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set the per-user rate limit for notes created by web or API requests.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_notes_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_notes_creation'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'note_limits' @@ -145,7 +145,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set the per-user rate limit for getting a user by ID via the API.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_users_api.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_users_api'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'users_api_limits' @@ -159,7 +159,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set per-user rate limits for imports and exports of projects and groups.') - = link_to _('Learn more.'), help_page_path('administration/settings/import_export_rate_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/import_export_rate_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'import_export_limits' @@ -171,7 +171,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Limit the number of pipeline creation requests per minute. This limit includes pipelines created through the UI, the API, and by background processing.') - = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_pipelines_creation.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/rate_limit_on_pipelines_creation'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'pipeline_limits' diff --git a/app/views/admin/application_settings/preferences.html.haml b/app/views/admin/application_settings/preferences.html.haml index 4590b6f4586..3543e1d918a 100644 --- a/app/views/admin/application_settings/preferences.html.haml +++ b/app/views/admin/application_settings/preferences.html.haml @@ -33,7 +33,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Additional text for the sign-in and Help page.') - = link_to _('Learn more.'), help_page_path('administration/settings/help_page.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/help_page'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'help_page' @@ -56,7 +56,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Adjust how frequently the GitLab UI polls for updates.') - = link_to _('Learn more.'), help_page_path('administration/polling.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/polling'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'realtime' @@ -69,7 +69,7 @@ %p.gl-text-secondary = _('Configure Gitaly timeouts.') %span - = link_to _('Learn more.'), help_page_path('administration/settings/gitaly_timeouts.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/gitaly_timeouts'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'gitaly' @@ -93,7 +93,7 @@ %p.gl-text-secondary = _('Limit the size of Sidekiq jobs stored in Redis.') %span - = link_to _('Learn more.'), help_page_path('administration/settings/sidekiq_job_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/settings/sidekiq_job_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'sidekiq_job_limits' @@ -106,6 +106,6 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = s_('TerraformLimits|Limits for Terraform features') - = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('administration/settings/terraform_limits.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('TerraformLimits|Learn more about Terraform limits.'), help_page_path('administration/settings/terraform_limits'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'terraform_limits' diff --git a/app/views/admin/application_settings/reporting.html.haml b/app/views/admin/application_settings/reporting.html.haml index 91fabb505c2..49279c4584b 100644 --- a/app/views/admin/application_settings/reporting.html.haml +++ b/app/views/admin/application_settings/reporting.html.haml @@ -25,7 +25,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Receive notification of abuse reports by email.') - = link_to _('Learn more.'), help_page_path('administration/review_abuse_reports.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/review_abuse_reports'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'abuse' diff --git a/app/views/admin/application_settings/repository.html.haml b/app/views/admin/application_settings/repository.html.haml index c7a2fca00ef..0b31da36804 100644 --- a/app/views/admin/application_settings/repository.html.haml +++ b/app/views/admin/application_settings/repository.html.haml @@ -22,11 +22,11 @@ = expanded_by_default? ? 'Collapse' : 'Expand' %p.gl-text-secondary = _('Configure repository mirroring.') - = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render partial: 'repository_mirrors_form' -%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?), data: { qa_selector: 'repository_storage_settings_content' } } +%section.settings.as-repository-storage.no-animate#js-repository-storage-settings{ class: ('expanded' if expanded_by_default?) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = _('Repository storage') @@ -34,7 +34,7 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Configure repository storage.') - = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/repository_storage_paths'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_storage' @@ -45,9 +45,9 @@ = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary - - repository_checks_link_url = help_page_path('administration/repository_checks.md') + - repository_checks_link_url = help_page_path('administration/repository_checks') - repository_checks_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: repository_checks_link_url } - - housekeeping_link_url = help_page_path('administration/housekeeping.md') + - housekeeping_link_url = help_page_path('administration/housekeeping') - housekeeping_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: housekeeping_link_url } = html_escape(s_('Configure %{repository_checks_link_start}repository checks%{link_end} and %{housekeeping_link_start}housekeeping%{link_end} on repositories.')) % { repository_checks_link_start: repository_checks_link_start, housekeeping_link_start: housekeeping_link_start, link_end: '</a>'.html_safe } .settings-content @@ -61,6 +61,6 @@ = expanded_by_default? ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Serve repository static objects (for example, archives and blobs) from external storage.') - = link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('administration/static_objects_external_storage'), target: '_blank', rel: 'noopener noreferrer' .settings-content = render 'repository_static_objects' diff --git a/app/views/admin/application_settings/service_usage_data.html.haml b/app/views/admin/application_settings/service_usage_data.html.haml deleted file mode 100644 index 9f73099465c..00000000000 --- a/app/views/admin/application_settings/service_usage_data.html.haml +++ /dev/null @@ -1,29 +0,0 @@ -- name = _("Service usage data") - -- breadcrumb_title name -- page_title name -- add_page_specific_style 'page_bundles/settings' -- payload_class = 'js-service-ping-payload' -- @force_desktop_expanded_sidebar = true - -%section.js-search-settings-section - %h3= name - - - if @service_ping_data_present - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-preview-trigger gl-mr-2', data: { payload_selector: ".#{payload_class}" } }) do - = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) - %span.js-text.gl-display-inline= _('Preview payload') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-payload-download-trigger gl-mr-2', data: { endpoint: usage_data_admin_application_settings_path(format: :json) } }) do - = gl_loading_icon(css_class: 'js-spinner gl-display-none', inline: true) - %span.js-text.gl-display-inline= _('Download payload') - %pre.js-syntax-highlight.code.highlight.gl-mt-2.gl-display-none{ class: payload_class, data: { endpoint: usage_data_admin_application_settings_path(format: :html) } } - - else - = render Pajamas::AlertComponent.new(variant: :warning, - dismissible: false, - title: _('Service Ping payload not found in the application cache')) do |c| - - - c.with_body do - - enable_service_ping_link = link_to('', help_page_path('administration/settings/usage_statistics', anchor: 'enable-or-disable-usage-statistics'), target: '_blank', rel: 'noopener noreferrer') - - generate_manually_link = link_to('', help_page_path('development/internal_analytics/service_ping/troubleshooting', anchor: 'generate-service-ping'), target: '_blank', rel: 'noopener noreferrer') - - = safe_format(s_('%{enable_service_ping_link_start}Enable%{enable_service_ping_link_end} or %{generate_manually_link_start}generate%{generate_manually_link_end} Service Ping to preview and download service usage data payload.'), tag_pair(enable_service_ping_link, :enable_service_ping_link_start, :enable_service_ping_link_end), tag_pair(generate_manually_link, :generate_manually_link_start, :generate_manually_link_end)) diff --git a/app/views/admin/background_migrations/index.html.haml b/app/views/admin/background_migrations/index.html.haml index 9550ea2884e..e7212f00e5b 100644 --- a/app/views/admin/background_migrations/index.html.haml +++ b/app/views/admin/background_migrations/index.html.haml @@ -1,7 +1,7 @@ - page_title s_('BackgroundMigrations|Background Migrations') - @breadcrumb_link = admin_background_migrations_path(database: params[:database]) -.gl-display-flex.gl-sm-flex-direction-column.gl-sm-align-items-flex-end.gl-pb-5.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-100 +.gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-sm-align-items-flex-end.gl-pb-5.gl-border-b-1.gl-border-b-solid.gl-border-b-gray-100 .gl-flex-grow-1 %h3= s_('BackgroundMigrations|Background Migrations') %p.light.gl-mb-0 diff --git a/app/views/admin/dashboard/index.html.haml b/app/views/admin/dashboard/index.html.haml index 4973c0f985c..bf00fbfd81d 100644 --- a/app/views/admin/dashboard/index.html.haml +++ b/app/views/admin/dashboard/index.html.haml @@ -89,7 +89,7 @@ = feature_entry(_('LDAP'), enabled: Gitlab.config.ldap.enabled, - doc_href: help_page_path('administration/auth/ldap/index.md')) + doc_href: help_page_path('administration/auth/ldap/index')) = feature_entry(_('Gravatar'), href: general_admin_application_settings_path(anchor: 'js-account-settings'), diff --git a/app/views/admin/dev_ops_report/_score.html.haml b/app/views/admin/dev_ops_report/_score.html.haml index a504563ad91..59cb30e8447 100644 --- a/app/views/admin/dev_ops_report/_score.html.haml +++ b/app/views/admin/dev_ops_report/_score.html.haml @@ -1,6 +1,6 @@ - service_ping_enabled = Gitlab::CurrentSettings.usage_ping_enabled - if !service_ping_enabled - #js-devops-service-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_service_ping_path: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/internal_analytics/service_ping/index.md') } } + #js-devops-service-ping-disabled{ data: { is_admin: current_user&.admin.to_s, empty_state_svg_path: image_path('illustrations/convdev/convdev_no_index.svg'), enable_service_ping_path: metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), docs_link: help_page_path('development/internal_analytics/service_ping/index') } } - else #js-devops-score{ data: { devops_score_metrics: devops_score_metrics(@metric).to_json, no_data_image_path: image_path('dev_ops_report_no_data.svg'), devops_score_intro_image_path: image_path('dev_ops_report_overview.svg') } } diff --git a/app/views/admin/spam_logs/_spam_log.html.haml b/app/views/admin/spam_logs/_spam_log.html.haml index 6aed8508a6a..878692438d4 100644 --- a/app/views/admin/spam_logs/_spam_log.html.haml +++ b/app/views/admin/spam_logs/_spam_log.html.haml @@ -29,7 +29,7 @@ variant: :danger, method: :delete, href: admin_spam_log_path(spam_log, remove_user: true), - button_options: { data: { confirm: _("USER %{user_name} WILL BE REMOVED! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') } }) do + button_options: { data: { confirm: _("User %{user_name} will be removed! Are you sure?") % { user_name: user.name }, confirm_btn_variant: 'danger' }, aria: { label: _('Remove user') } }) do = _('Remove user') %td -# TODO: Remove conditonal once spamcheck supports this https://gitlab.com/gitlab-com/gl-security/engineering-and-research/automation-team/spam/spamcheck/-/issues/190 @@ -48,11 +48,23 @@ = render Pajamas::ButtonComponent.new(size: :small, method: :put, href: block_admin_user_path(user), - button_options: { class: 'gl-mb-3', data: {confirm: _('USER WILL BE BLOCKED! Are you sure?')} }) do + button_options: { class: 'gl-mb-3', data: {confirm: _('User will be blocked! Are you sure?')} }) do = _('Block user') - else = render Pajamas::ButtonComponent.new(size: :small, button_options: { class: 'disabled gl-mb-3'}) do = _("Already blocked") + - if user && !user.trusted? + = render Pajamas::ButtonComponent.new(size: :small, + method: :put, + href: trust_admin_user_path(user), + button_options: { class: 'gl-mb-3', data: {confirm: _('User will be allowed to create possible spam! Are you sure?')} }) do + = _('Trust user') + - else + = render Pajamas::ButtonComponent.new(size: :small, + method: :put, + href: untrust_admin_user_path(user), + button_options: { class: 'gl-mb-3', data: {confirm: _('User will not be allowed to create possible spam! Are you sure?')} }) do + = _('Untrust user') = render Pajamas::ButtonComponent.new(size: :small, method: :delete, href: [:admin, spam_log], diff --git a/app/views/admin/topics/_form.html.haml b/app/views/admin/topics/_form.html.haml index 2638e45c9eb..c61be1182e0 100644 --- a/app/views/admin/topics/_form.html.haml +++ b/app/views/admin/topics/_form.html.haml @@ -4,7 +4,7 @@ .form-group = f.label :name do = _("Topic slug (name)") - = f.text_field :name, placeholder: _('my-topic'), class: 'form-control input-lg', data: { qa_selector: 'topic_name_field' }, + = f.text_field :name, placeholder: _('my-topic'), class: 'form-control input-lg', required: true, title: _('Please fill in a name for your topic.'), autofocus: true @@ -12,7 +12,7 @@ .form-group = f.label :title do = _("Topic title") - = f.text_field :title, placeholder: _('My topic'), class: 'form-control input-lg', data: { qa_selector: 'topic_title_field' }, + = f.text_field :title, placeholder: _('My topic'), class: 'form-control input-lg', required: true, title: _('Please fill in a title for your topic.') diff --git a/app/views/admin/topics/_topic.html.haml b/app/views/admin/topics/_topic.html.haml index 3e8a023ec9f..4e8b1394e06 100644 --- a/app/views/admin/topics/_topic.html.haml +++ b/app/views/admin/topics/_topic.html.haml @@ -1,7 +1,7 @@ - topic = local_assigns.fetch(:topic) - title = topic.title || topic.name -%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'topic_row_content' } } +%li.topic-row.gl-py-3.gl-align-items-center{ class: 'gl-display-flex!' } = render Pajamas::AvatarComponent.new(topic, size: 32, alt: '') .gl-min-w-0.gl-flex-grow-1.gl-ml-3 diff --git a/app/views/admin/topics/index.html.haml b/app/views/admin/topics/index.html.haml index 6d64fa1983f..46c1b9ac5c4 100644 --- a/app/views/admin/topics/index.html.haml +++ b/app/views/admin/topics/index.html.haml @@ -6,7 +6,7 @@ = form_tag admin_topics_path, method: :get do |f| - search = params.fetch(:search, nil) .search-field-holder - = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name'), data: { qa_selector: 'topic_search_field' } + = search_field_tag :search, search, class: "form-control gl-form-input search-text-input js-search-input", autofocus: true, spellcheck: false, placeholder: _('Search by name') = sprite_icon('search', css_class: 'search-icon') .gl-flex-grow-1 .js-merge-topics{ data: { path: merge_admin_topics_path } } diff --git a/app/views/admin/users/_users.html.haml b/app/views/admin/users/_users.html.haml index d4a9009a0cf..bbb068c3680 100644 --- a/app/views/admin/users/_users.html.haml +++ b/app/views/admin/users/_users.html.haml @@ -44,6 +44,9 @@ = gl_tab_link_to admin_users_path(filter: "wop"), { item_active: active_when(params[:filter] == 'wop'), class: 'gl-border-0!' } do = s_('AdminUsers|Without projects') = gl_tab_counter_badge(limited_counter_with_delimiter(User.without_projects)) + = gl_tab_link_to admin_users_path(filter: "trusted"), { item_active: active_when(params[:filter] == 'trusted'), class: 'gl-border-0!' } do + = s_('AdminUsers|Trusted') + = gl_tab_counter_badge(limited_counter_with_delimiter(User.trusted)) .nav-controls = render_if_exists 'admin/users/admin_email_users' = render_if_exists 'admin/users/admin_export_user_permissions' diff --git a/app/views/admin/users/projects.html.haml b/app/views/admin/users/projects.html.haml index fa89c3d4b4f..bbf1e3b0b2f 100644 --- a/app/views/admin/users/projects.html.haml +++ b/app/views/admin/users/projects.html.haml @@ -1,3 +1,4 @@ +-# rubocop: disable CodeReuse/ActiveRecord - add_to_breadcrumbs _("Users"), admin_users_path - breadcrumb_title @user.name - page_title _("Groups and projects"), @user.name, _("Users") @@ -9,7 +10,7 @@ = _('Groups') - c.with_body do %ul.hover-list - - @user.group_members.includes(:source).each do |group_member| # rubocop: disable CodeReuse/ActiveRecord + - @user.group_members.includes(:source).find_each do |group_member| - group = group_member.group %li.group_member %strong= link_to group.name, admin_group_path(group) @@ -50,3 +51,4 @@ - if member.respond_to? :project = link_button_to nil, project_project_member_path(project, member), data: { confirm: remove_member_message(member), confirm_btn_variant: 'danger' }, aria: { label: _('Remove') }, remote: true, method: :delete, class: 'gl-ml-3', title: _('Remove user from project'), variant: :danger, size: :small, icon: 'remove' +-# rubocop: enable CodeReuse/ActiveRecord diff --git a/app/views/ci/status/_badge.html.haml b/app/views/ci/status/_badge.html.haml deleted file mode 100644 index e3b409dea76..00000000000 --- a/app/views/ci/status/_badge.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -- status = local_assigns.fetch(:status) -- link = local_assigns.fetch(:link, true) -- title = local_assigns.fetch(:title, nil) -- css_classes = "gl-display-inline-flex gl-align-items-center gl-gap-2 gl-line-height-0 gl-px-3 gl-py-2 gl-rounded-base ci-status ci-#{status.group} #{'has-tooltip' if title.present?}" - -- if link && status.has_details? - = link_to status.details_path, class: css_classes, title: title, data: { html: title.present? } do - = sprite_icon(status.icon) - = status.text -- else - %span{ class: css_classes, title: title, data: { html: title.present? } } - = sprite_icon(status.icon) - = status.text diff --git a/app/views/ci/status/_icon.html.haml b/app/views/ci/status/_icon.html.haml index 9fa5734d6b6..bcb83874bfb 100644 --- a/app/views/ci/status/_icon.html.haml +++ b/app/views/ci/status/_icon.html.haml @@ -1,10 +1,7 @@ - status = local_assigns.fetch(:status) -- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") - path = local_assigns.fetch(:path, status.has_details? ? status.details_path : nil) -- option_css_classes = local_assigns.fetch(:option_css_classes, '') -- css_classes = "gl-px-2 #{option_css_classes}" -- ci_css_classes = "ci-status-link ci-status-icon ci-status-icon-#{status.group} gl-line-height-1" -- title = s_("PipelineStatusTooltip|Pipeline: %{ci_status}") % {ci_status: status.label} +- tooltip_placement = local_assigns.fetch(:tooltip_placement, "left") +- option_css_classes = local_assigns.fetch(:option_css_classes, nil) +- show_status_text = local_assigns.fetch(:show_status_text, false) -= gl_badge_tag(variant: badge_variant(status), size: :md, href: path, class: css_classes, title: title, data: { toggle: 'tooltip', placement: tooltip_placement, testid: "ci-status-badge" }) do - = content_tag :span, sprite_icon(status.icon, size: 16), class: ci_css_classes += render_ci_icon(status, path, tooltip_placement: tooltip_placement, option_css_classes: option_css_classes, show_status_text: show_status_text) diff --git a/app/views/clusters/clusters/_advanced_settings.html.haml b/app/views/clusters/clusters/_advanced_settings.html.haml index a818f8a5c26..57111dd6232 100644 --- a/app/views/clusters/clusters/_advanced_settings.html.haml +++ b/app/views/clusters/clusters/_advanced_settings.html.haml @@ -30,7 +30,7 @@ selected: @cluster.management_project_id } } %p.text-muted.gl-mt-n5 = html_escape(s_('ClusterIntegration|A cluster management project can be used to run deployment jobs with Kubernetes %{code_open}cluster-admin%{code_close} privileges.')) % { code_open: '<code>'.html_safe, code_close: '</code>'.html_safe } - = link_to _('More information'), help_page_path('user/clusters/management_project.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('More information'), help_page_path('user/clusters/management_project'), target: '_blank', rel: 'noopener noreferrer' = field.submit _('Save changes'), pajamas_button: true .sub-section.form-group diff --git a/app/views/clusters/clusters/_deprecation_alert.html.haml b/app/views/clusters/clusters/_deprecation_alert.html.haml index 4f35ba78cc6..cfc3418b1b5 100644 --- a/app/views/clusters/clusters/_deprecation_alert.html.haml +++ b/app/views/clusters/clusters/_deprecation_alert.html.haml @@ -2,6 +2,6 @@ - c.with_body do - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - issue_link_start = link_start % { url: 'https://gitlab.com/gitlab-org/configure/general/-/issues/199' } - - docs_link_start = link_start % { url: help_page_path('user/clusters/agent/index.md') } + - docs_link_start = link_start % { url: help_page_path('user/clusters/agent/index') } - link_end = '</a>'.html_safe = s_('ClusterIntegration|This process is %{issue_link_start}deprecated%{issue_link_end}. Use the %{docs_link_start}the GitLab agent for Kubernetes%{docs_link_end} instead.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end, issue_link_start: issue_link_start, issue_link_end: link_end } diff --git a/app/views/clusters/clusters/_multiple_clusters_message.html.haml b/app/views/clusters/clusters/_multiple_clusters_message.html.haml index 04c1f9b6e7a..2878bb1371c 100644 --- a/app/views/clusters/clusters/_multiple_clusters_message.html.haml +++ b/app/views/clusters/clusters/_multiple_clusters_message.html.haml @@ -1,4 +1,4 @@ -- autodevops_help_url = help_page_path('topics/autodevops/multiple_clusters_auto_devops.md') +- autodevops_help_url = help_page_path('topics/autodevops/multiple_clusters_auto_devops') - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - help_link_end = '</a>'.html_safe diff --git a/app/views/clusters/clusters/_namespace.html.haml b/app/views/clusters/clusters/_namespace.html.haml index 34576b6e5af..9c20a409b18 100644 --- a/app/views/clusters/clusters/_namespace.html.haml +++ b/app/views/clusters/clusters/_namespace.html.haml @@ -1,6 +1,6 @@ - managed_namespace_help_text = s_('ClusterIntegration|Set a prefix for your namespaces. If not set, defaults to your project path. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - non_managed_namespace_help_text = s_('ClusterIntegration|The namespace associated with your project. This will be used for deploy boards, and Web terminals.') -- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' +- managed_namespace_help_link = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters'), target: '_blank', rel: 'noopener noreferrer' .js-namespace-prefixed .form-group diff --git a/app/views/clusters/clusters/_provider_details_form.html.haml b/app/views/clusters/clusters/_provider_details_form.html.haml index 4b7164f9845..f675ea5865e 100644 --- a/app/views/clusters/clusters/_provider_details_form.html.haml +++ b/app/views/clusters/clusters/_provider_details_form.html.haml @@ -1,35 +1,35 @@ = gitlab_ui_form_for cluster, url: update_cluster_url_path, html: { class: 'js-provider-details gl-show-field-errors', role: 'form' }, as: :cluster do |field| .form-group - - copy_name_btn = deprecated_clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = field.label :name, s_('ClusterIntegration|Kubernetes cluster name'), class: 'label-bold required' .input-group.gl-field-error-anchor = field.text_field :name, class: 'form-control js-select-on-focus cluster-name', required: true, title: s_('ClusterIntegration|Cluster name is required.'), - readonly: cluster.read_only_kubernetes_platform_fields?, - append: copy_name_btn + readonly: cluster.read_only_kubernetes_platform_fields? + - if cluster.read_only_kubernetes_platform_fields? + .input-group-append + = clipboard_button(text: cluster.name, title: s_('ClusterIntegration|Copy Kubernetes cluster name'), variant: :default, category: :primary, size: :medium) = field.fields_for :platform_kubernetes, platform do |platform_field| .form-group - - copy_api_url = deprecated_clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = platform_field.label :api_url, s_('ClusterIntegration|API URL'), class: 'label-bold required' .input-group.gl-field-error-anchor = platform_field.text_field :api_url, class: 'form-control js-select-on-focus', required: true, title: s_('ClusterIntegration|API URL should be a valid http/https url.'), - readonly: cluster.read_only_kubernetes_platform_fields?, - append: copy_api_url + readonly: cluster.read_only_kubernetes_platform_fields? + - if cluster.read_only_kubernetes_platform_fields? + .input-group-append + = clipboard_button(text: platform.api_url, title: s_('ClusterIntegration|Copy API URL'), variant: :default, category: :primary, size: :medium) .form-group - - copy_ca_cert_btn = deprecated_clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), - class: 'input-group-text btn-default') if cluster.read_only_kubernetes_platform_fields? = platform_field.label :ca_cert, s_('ClusterIntegration|CA Certificate'), class: 'label-bold' - .input-group.gl-field-error-anchor - = platform_field.text_area :ca_cert, class: 'form-control js-select-on-focus', rows: '10', + .input-group.gl-field-error-anchor.markdown-code-block + = platform_field.text_area :ca_cert, class: 'gl-rounded-top-right-base! gl-rounded-bottom-right-base! form-control js-select-on-focus', rows: '10', readonly: cluster.read_only_kubernetes_platform_fields?, - placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)'), - append: copy_ca_cert_btn + placeholder: s_('ClusterIntegration|Certificate Authority bundle (PEM format)') + - if cluster.read_only_kubernetes_platform_fields? + %copy-code + = clipboard_button(text: platform.ca_cert, title: s_('ClusterIntegration|Copy CA Certificate'), variant: :default, category: :primary, size: :medium, class: 'copy-code') .form-group = platform_field.label :token, s_('ClusterIntegration|Enter new Service Token'), class: 'label-bold required' @@ -51,7 +51,7 @@ = field.label :managed, s_('ClusterIntegration|GitLab-managed cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') - = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('More information'), help_page_path('user/project/clusters/gitlab_managed_clusters'), target: '_blank', rel: 'noopener noreferrer' .form-group .form-check @@ -59,7 +59,7 @@ = field.label :namespace_per_environment, s_('ClusterIntegration|Namespace per environment'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('More information'), help_page_path('user/project/clusters/deploy_to_cluster', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' - if cluster.allow_user_defined_namespace? = render('clusters/clusters/namespace', platform_field: platform_field, field: field) diff --git a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml index 49dab193da8..8ac232ac7ca 100644 --- a/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml +++ b/app/views/clusters/clusters/cloud_providers/_cloud_provider_selector.html.haml @@ -2,9 +2,9 @@ - eks_label = s_('ClusterIntegration|Amazon EKS') - civo_label = s_('ClusterIntegration|Civo Kubernetes') - create_cluster_label = s_('ClusterIntegration|Where do you want to create a cluster?') -- eks_help_path = help_page_path('user/infrastructure/clusters/connect/new_eks_cluster.md') -- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_cluster.md') -- civo_help_path = help_page_path('user/infrastructure/clusters/connect/new_civo_cluster.md') +- eks_help_path = help_page_path('user/infrastructure/clusters/connect/new_eks_cluster') +- gke_help_path = help_page_path('user/infrastructure/clusters/connect/new_gke_cluster') +- civo_help_path = help_page_path('user/infrastructure/clusters/connect/new_civo_cluster') .gl-py-5.gl-md-pl-5.gl-md-pr-5 %h4.gl-mb-5 diff --git a/app/views/clusters/clusters/connect.html.haml b/app/views/clusters/clusters/connect.html.haml index a6e1837badf..68e5fcb277b 100644 --- a/app/views/clusters/clusters/connect.html.haml +++ b/app/views/clusters/clusters/connect.html.haml @@ -5,7 +5,7 @@ = render 'deprecation_alert' .gl-md-display-flex.gl-mt-3 - .gl-w-quarter.gl-xs-w-full.gl-flex-shrink-0.gl-md-mr-5 + .gl-w-full.gl-sm-w-25p.gl-flex-shrink-0.gl-md-mr-5 = render 'sidebar', is_connect_page: true .gl-w-full #js-cluster-new diff --git a/app/views/clusters/clusters/new_cluster_docs.html.haml b/app/views/clusters/clusters/new_cluster_docs.html.haml index 72c70f35e22..d58c844382d 100644 --- a/app/views/clusters/clusters/new_cluster_docs.html.haml +++ b/app/views/clusters/clusters/new_cluster_docs.html.haml @@ -5,7 +5,7 @@ = render_gcp_signup_offer .gl-md-display-flex.gl-mt-3 - .gl-w-quarter.gl-xs-w-full.gl-flex-shrink-0.gl-md-mr-5 + .gl-w-full.gl-sm-w-25p.gl-flex-shrink-0.gl-md-mr-5 = render 'sidebar', is_connect_page: false .gl-w-full = render 'clusters/clusters/cloud_providers/cloud_provider_selector' diff --git a/app/views/clusters/clusters/show.html.haml b/app/views/clusters/clusters/show.html.haml index 1287f4e689f..22dee5876c2 100644 --- a/app/views/clusters/clusters/show.html.haml +++ b/app/views/clusters/clusters/show.html.haml @@ -12,10 +12,10 @@ cluster_status: @cluster.status_name, cluster_status_reason: @cluster.status_reason, provider_type: @cluster.provider_type, - help_path: help_page_path('user/infrastructure/clusters/index.md'), - environments_help_path: help_page_path('ci/environments/index.md', anchor: 'create-a-static-environment'), - clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster.md'), - deploy_boards_help_path: help_page_path('user/project/deploy_boards.md', anchor: 'enabling-deploy-boards'), + help_path: help_page_path('user/infrastructure/clusters/index'), + environments_help_path: help_page_path('ci/environments/index', anchor: 'create-a-static-environment'), + clusters_help_path: help_page_path('user/project/clusters/deploy_to_cluster'), + deploy_boards_help_path: help_page_path('user/project/deploy_boards', anchor: 'enabling-deploy-boards'), cluster_id: @cluster.id } } .js-cluster-application-notice diff --git a/app/views/clusters/clusters/user/_form.html.haml b/app/views/clusters/clusters/user/_form.html.haml index 4ecef4b76ce..6a5acf4f507 100644 --- a/app/views/clusters/clusters/user/_form.html.haml +++ b/app/views/clusters/clusters/user/_form.html.haml @@ -58,7 +58,7 @@ = field.label :managed, s_('ClusterIntegration|GitLab-managed cluster'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Allow GitLab to manage namespaces and service accounts for this cluster.') - = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/gitlab_managed_clusters'), target: '_blank', rel: 'noopener noreferrer' .form-group .form-check @@ -66,7 +66,7 @@ = field.label :namespace_per_environment, s_('ClusterIntegration|Namespace per environment'), class: 'form-check-label label-bold' .form-text.text-muted = s_('ClusterIntegration|Deploy each environment to its own namespace. Otherwise, environments within a project share a project-wide namespace. Note that anyone who can trigger a deployment of a namespace can read its secrets. If modified, existing environments will use their current namespaces until the cluster cache is cleared.') - = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster.md', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/clusters/deploy_to_cluster', anchor: 'custom-namespace'), target: '_blank', rel: 'noopener noreferrer' = field.fields_for :platform_kubernetes, @user_cluster.platform_kubernetes do |platform_kubernetes_field| - if @user_cluster.allow_user_defined_namespace? diff --git a/app/views/dashboard/_projects_head.html.haml b/app/views/dashboard/_projects_head.html.haml index 74dc2277f54..7527f32274a 100644 --- a/app/views/dashboard/_projects_head.html.haml +++ b/app/views/dashboard/_projects_head.html.haml @@ -1,5 +1,6 @@ -= content_for :flash_message do - = render 'shared/project_limit' +- if params[:personal] + = content_for :flash_message do + = render 'shared/project_limit' .page-title-holder.gl-display-flex.gl-align-items-center %h1.page-title.gl-font-size-h-display= _('Projects') diff --git a/app/views/dashboard/todos/_todo.html.haml b/app/views/dashboard/todos/_todo.html.haml index 181c79e7bd0..6920ad9cd83 100644 --- a/app/views/dashboard/todos/_todo.html.haml +++ b/app/views/dashboard/todos/_todo.html.haml @@ -33,7 +33,7 @@ - if todo.note.present? \: - %span.action-name{ data: { qa_selector: "todo_action_name_content" } }< + %span.action-name{ data: { testid: "todo-action-name-content" } }< - if !todo.note.present? = todo_action_name(todo) - unless todo.self_assigned? diff --git a/app/views/dashboard/todos/index.html.haml b/app/views/dashboard/todos/index.html.haml index ab97507b3c8..4f3ca9fd71b 100644 --- a/app/views/dashboard/todos/index.html.haml +++ b/app/views/dashboard/todos/index.html.haml @@ -39,26 +39,26 @@ .filter-item.gl-m-2 - if params[:group_id].present? = hidden_field_tag(:group_id, params[:group_id]) - = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static', testid: 'group-dropdown' } }) + = dropdown_tag(group_dropdown_label(params[:group_id], _("Group")), options: { toggle_class: 'js-group-search js-filter-submit gl-w-full gl-sm-w-auto', title: s_("Todos|Filter by group"), filter: true, filterInput: 'input#group-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-group js-filter-submit', placeholder: _("Search groups"), data: { default_label: _("Group"), display: 'static', testid: 'group-dropdown' } }) .filter-item.gl-m-2 - if params[:project_id].present? = hidden_field_tag(:project_id, params[:project_id]) - = dropdown_tag(project_dropdown_label(params[:project_id], _("Project")), options: { toggle_class: 'js-project-search js-filter-submit gl-xs-w-full!', title: s_("Todos|Filter by project"), filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', placeholder: _("Search projects"), data: { default_label: _("Project"), display: 'static' } }) + = dropdown_tag(project_dropdown_label(params[:project_id], _("Project")), options: { toggle_class: 'js-project-search js-filter-submit gl-w-full gl-sm-w-auto', title: s_("Todos|Filter by project"), filter: true, filterInput: 'input#project-search', dropdown_class: 'dropdown-menu-selectable dropdown-menu-project js-filter-submit', placeholder: _("Search projects"), data: { default_label: _("Project"), display: 'static' } }) .filter-item.gl-m-2 - if params[:author_id].present? = hidden_field_tag(:author_id, params[:author_id]) - = dropdown_tag(user_dropdown_label(params[:author_id], _("Author")), options: { toggle_class: 'js-user-search js-filter-submit js-author-search gl-xs-w-full!', title: s_("Todos|Filter by author"), filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', placeholder: _("Search authors"), data: { any_user: _("Any Author"), first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: _("Author"), todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) + = dropdown_tag(user_dropdown_label(params[:author_id], _("Author")), options: { toggle_class: 'js-user-search js-filter-submit js-author-search gl-w-full gl-sm-w-auto', title: s_("Todos|Filter by author"), filter: true, filterInput: 'input#author-search', dropdown_class: 'dropdown-menu-user dropdown-menu-selectable dropdown-menu-author js-filter-submit', placeholder: _("Search authors"), data: { any_user: _("Any Author"), first_user: (current_user.username if current_user), project_id: (@project.id if @project), selected: params[:author_id], field_name: 'author_id', default_label: _("Author"), todo_filter: true, todo_state_filter: params[:state] || 'pending' } }) .filter-item.gl-m-2 - if params[:type].present? = hidden_field_tag(:type, params[:type]) - = dropdown_tag(todo_types_dropdown_label(params[:type], _("Type")), options: { toggle_class: 'js-type-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', data: { data: todo_types_options, default_label: _("Type") } }) + = dropdown_tag(todo_types_dropdown_label(params[:type], _("Type")), options: { toggle_class: 'js-type-search js-filter-submit gl-w-full gl-sm-w-auto', dropdown_class: 'dropdown-menu-selectable dropdown-menu-type js-filter-submit', data: { data: todo_types_options, default_label: _("Type") } }) .filter-item.actions-filter.gl-m-2 - if params[:action_id].present? = hidden_field_tag(:action_id, params[:action_id]) - = dropdown_tag(todo_actions_dropdown_label(params[:action_id], _("Action")), options: { toggle_class: 'js-action-search js-filter-submit gl-xs-w-full!', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', data: { data: todo_actions_options, default_label: _("Action") } }) + = dropdown_tag(todo_actions_dropdown_label(params[:action_id], _("Action")), options: { toggle_class: 'js-action-search js-filter-submit gl-w-full gl-sm-w-auto', dropdown_class: 'dropdown-menu-selectable dropdown-menu-action js-filter-submit', data: { data: todo_actions_options, default_label: _("Action") } }) .filter-item.sort-filter.gl-my-2 .dropdown - %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', class: 'gl-xs-w-full!', 'data-toggle' => 'dropdown' } + %button.dropdown-menu-toggle.dropdown-menu-toggle-sort{ type: 'button', class: 'gl-w-full gl-sm-w-auto', 'data-toggle' => 'dropdown' } %span.light - if @sort.present? = sort_options_hash[@sort] @@ -78,12 +78,12 @@ .row.js-todos-all - if @allowed_todos.any? - .col.js-todos-list-container{ data: { qa_selector: "todos_list_container" } } + .col.js-todos-list-container{ data: { testid: "todos-list-container" } } .js-todos-options{ data: { per_page: @allowed_todos.count, current_page: @todos.current_page, total_pages: @todos.total_pages } } %ul.content-list.todos-list = render @allowed_todos = paginate @todos, theme: "gitlab" - .js-nothing-here-container.gl-empty-state.gl-text-center.hidden + .col.js-nothing-here-container.gl-empty-state.gl-text-center.hidden .svg-content.svg-150 = image_tag 'illustrations/empty-todos-all-done-md.svg' .text-content.gl-text-center diff --git a/app/views/devise/shared/_sign_in_link.html.haml b/app/views/devise/shared/_sign_in_link.html.haml index a1d10898c5b..a9f24e42d0b 100644 --- a/app/views/devise/shared/_sign_in_link.html.haml +++ b/app/views/devise/shared/_sign_in_link.html.haml @@ -1,4 +1,4 @@ -%p.text-center +%p{ class: local_assigns.fetch(:wrapper_class, 'gl-text-center') } %span.light = _('Already have an account?') - path_params = { redirect_to_referer: 'yes' } diff --git a/app/views/devise/shared/_signup_box.html.haml b/app/views/devise/shared/_signup_box.html.haml index bf1b604465b..fb60b8c08eb 100644 --- a/app/views/devise/shared/_signup_box.html.haml +++ b/app/views/devise/shared/_signup_box.html.haml @@ -1,77 +1,10 @@ -- max_first_name_length = max_last_name_length = 127 - borderless ||= false -- form_resource_name = "new_#{resource_name}" .gl-mb-3.gl-p-4{ class: (borderless ? '' : 'gl-border-gray-100 gl-border-1 gl-border-solid gl-rounded-base') } = yield :omniauth_providers_top if show_omniauth_providers - = gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }}, data: { testid: 'signup-form' }) do |f| - .devise-errors - = render 'devise/shared/error_messages', resource: resource - - if Gitlab::CurrentSettings.invisible_captcha_enabled - = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) - .name.form-row - .col.form-group - = f.label :first_name, _('First name'), for: 'new_user_first_name' - = f.text_field :first_name, - class: 'form-control gl-form-input top js-block-emoji js-validate-length', - data: { max_length: max_first_name_length, - max_length_message: s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, - testid: 'new-user-first-name-field' }, - required: true, - title: _('This field is required.') - .col.form-group - = f.label :last_name, _('Last name'), for: 'new_user_last_name' - = f.text_field :last_name, - class: 'form-control gl-form-input top js-block-emoji js-validate-length', - data: { max_length: max_last_name_length, - max_length_message: s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, - testid: 'new-user-last-name-field' }, - required: true, - title: _('This field is required.') - .username.form-group - = f.label :username, _('Username') - = f.text_field :username, - class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', - data: signup_username_data_attributes, - pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, - required: true, - title: _('Please create a username with only alphanumeric characters.') - %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.') - %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') - %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') - .form-group - = f.label :email, _('Email') - = f.email_field :email, - class: 'form-control gl-form-input middle js-validate-email', - data: { testid: 'new-user-email-field' }, - required: true, - title: _('Please provide a valid email address.') - %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') - %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') - -# This is used for providing entry to Jihu on email verification - = render_if_exists 'devise/shared/signup_email_additional_info' - .form-group.gl-mb-5 - = f.label :password, _('Password') - %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password", - title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }, - minimum_password_length: @minimum_password_length, - testid: 'new-user-password-field', - autocomplete: 'new-password', - name: "#{form_resource_name}[password]" } } - %p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } - = render_if_exists 'shared/password_requirements_list' - = render_if_exists 'devise/shared/phone_verification', form: f + = render 'devise/shared/signup_box_form', + button_text: button_text, + url: url, + show_omniauth_providers: omniauth_enabled? && button_based_providers_enabled? - .form-group - - if arkose_labs_enabled? - = render_if_exists 'devise/registrations/arkose_labs' - - elsif show_recaptcha_sign_up? - = recaptcha_tags nonce: content_security_policy_nonce - - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'new-user-register-button' }}) do - = button_text - - = render 'devise/shared/terms_of_service_notice', button_text: button_text - - = yield :omniauth_providers_bottom if show_omniauth_providers diff --git a/app/views/devise/shared/_signup_box_form.html.haml b/app/views/devise/shared/_signup_box_form.html.haml new file mode 100644 index 00000000000..246036b72e1 --- /dev/null +++ b/app/views/devise/shared/_signup_box_form.html.haml @@ -0,0 +1,73 @@ +- max_first_name_length = max_last_name_length = 127 +- form_resource_name = "new_#{resource_name}" + += gitlab_ui_form_for(resource, as: form_resource_name, url: url, html: { class: 'gl-show-field-errors js-arkose-labs-form', aria: { live: 'assertive' }}, data: { testid: 'signup-form' }) do |f| + .devise-errors + = render 'devise/shared/error_messages', resource: resource + - if Gitlab::CurrentSettings.invisible_captcha_enabled + = invisible_captcha nonce: true, autocomplete: SecureRandom.alphanumeric(12) + .name.form-row + .col.form-group + = f.label :first_name, _('First name'), for: 'new_user_first_name' + = f.text_field :first_name, + class: 'form-control gl-form-input top js-block-emoji js-validate-length', + data: { max_length: max_first_name_length, + max_length_message: s_('SignUp|First name is too long (maximum is %{max_length} characters).') % { max_length: max_first_name_length }, + testid: 'new-user-first-name-field' }, + required: true, + title: _('This field is required.') + .col.form-group + = f.label :last_name, _('Last name'), for: 'new_user_last_name' + = f.text_field :last_name, + class: 'form-control gl-form-input top js-block-emoji js-validate-length', + data: { max_length: max_last_name_length, + max_length_message: s_('SignUp|Last name is too long (maximum is %{max_length} characters).') % { max_length: max_last_name_length }, + testid: 'new-user-last-name-field' }, + required: true, + title: _('This field is required.') + .username.form-group + = f.label :username, _('Username') + = f.text_field :username, + class: 'form-control gl-form-input middle js-block-emoji js-validate-length js-validate-username', + data: signup_username_data_attributes, + pattern: Gitlab::PathRegex::NAMESPACE_FORMAT_REGEX_JS, + required: true, + title: _('Please create a username with only alphanumeric characters.') + %p.validation-error.gl-text-red-500.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is already taken.') + %p.validation-success.gl-text-green-600.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Username is available.') + %p.validation-pending.gl-field-error-ignore.gl-mt-2.field-validation.hide= _('Checking username availability...') + .form-group + = f.label :email, _('Email') + = f.email_field :email, + class: 'form-control gl-form-input middle js-validate-email', + data: { testid: 'new-user-email-field' }, + required: true, + title: _('Please provide a valid email address.') + %p.validation-hint.gl-field-hint.text-secondary= _('We recommend a work email address.') + %p.validation-warning.gl-field-error-ignore.text-secondary.hide= _('This email address does not look right, are you sure you typed it correctly?') + -# This is used for providing entry to Jihu on email verification + = render_if_exists 'devise/shared/signup_email_additional_info' + .form-group.gl-mb-5 + = f.label :password, _('Password') + %input.form-control.gl-form-input.js-password{ data: { id: "#{form_resource_name}_password", + title: s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length }, + minimum_password_length: @minimum_password_length, + testid: 'new-user-password-field', + autocomplete: 'new-password', + name: "#{form_resource_name}[password]" } } + %p.gl-field-hint-valid.text-secondary= s_('SignUp|Minimum length is %{minimum_password_length} characters.') % { minimum_password_length: @minimum_password_length } + = render_if_exists 'shared/password_requirements_list' + = render_if_exists 'devise/shared/phone_verification', form: f + + .form-group + - if arkose_labs_enabled? + = render_if_exists 'devise/registrations/arkose_labs' + - elsif show_recaptcha_sign_up? + = recaptcha_tags nonce: content_security_policy_nonce + + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, block: true, button_options: { data: { testid: 'new-user-register-button' }}) do + = button_text + + = render 'devise/shared/terms_of_service_notice', button_text: button_text + += yield :omniauth_providers_bottom if show_omniauth_providers diff --git a/app/views/devise/shared/_signup_omniauth_provider_list.haml b/app/views/devise/shared/_signup_omniauth_provider_list.haml index e8c82e456ae..b9efcaa11b4 100644 --- a/app/views/devise/shared/_signup_omniauth_provider_list.haml +++ b/app/views/devise/shared/_signup_omniauth_provider_list.haml @@ -14,7 +14,10 @@ = _("Create an account using:") .gl-display-flex.gl-justify-content-between.gl-flex-wrap - providers.each do |provider| - = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, id: "oauth-login-#{provider}" do + = button_to omniauth_authorize_path(:user, provider, register_omniauth_params(local_assigns)), + class: "btn gl-button btn-default gl-w-full gl-mb-4 js-oauth-login #{qa_selector_for_provider(provider)}", + data: { provider: provider, track_action: "#{provider}_sso", track_label: tracking_label }, + id: "oauth-login-#{provider}" do - if provider_has_icon?(provider) = provider_image_tag(provider) %span.gl-button-text diff --git a/app/views/discussions/_notes.html.haml b/app/views/discussions/_notes.html.haml index e34a5cebe78..5e6ebe87808 100644 --- a/app/views/discussions/_notes.html.haml +++ b/app/views/discussions/_notes.html.haml @@ -20,7 +20,7 @@ .discussion-with-resolve-btn = link_to_reply_discussion(discussion) - elsif !current_user - .disabled-comment.text-center + .disabled-comment.gl-text-center.gl-text-secondary Please = link_to "register", new_session_path(:user, redirect_to_referer: 'yes') or diff --git a/app/views/events/_event.html.haml b/app/views/events/_event.html.haml index 83f7d743755..c28fe7c8330 100644 --- a/app/views/events/_event.html.haml +++ b/app/views/events/_event.html.haml @@ -1,8 +1,8 @@ - event = event.present - if event.visible_to_user?(current_user) - .event-item - .event-item-timestamp + .event-item{ class: current_path?('users#activity') ? 'user-profile-activity gl-border-bottom-0 gl-pl-7! gl-pb-3' : '' } + .event-item-timestamp.gl-font-sm #{time_ago_with_tooltip(event.created_at)} - if event.wiki_page? diff --git a/app/views/events/_event_scope.html.haml b/app/views/events/_event_scope.html.haml index 67e4c538b4a..f3e3a304cfd 100644 --- a/app/views/events/_event_scope.html.haml +++ b/app/views/events/_event_scope.html.haml @@ -1,4 +1,4 @@ -%span.event-scope +%span.event-scope.gl-text-truncate = event_preposition(event) - if event.project = link_to_project(event.project) diff --git a/app/views/events/event/_common.html.haml b/app/views/events/event/_common.html.haml index 7ef3461a7fb..78ce24c429a 100644 --- a/app/views/events/event/_common.html.haml +++ b/app/views/events/event/_common.html.haml @@ -5,9 +5,9 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - if event.target - %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name + user_profile_activity_classes } = localized_action_name(event) - %span.event-target-type.gl-mr-2= event.target_type_name + %span.event-target-type.gl-mr-2{ class: user_profile_activity_classes }= event.target_type_name = link_to event_target_path(event), class: 'has-tooltip event-target-link gl-mr-2', title: event.target_title do = event.target.reference_link_text - unless event.milestone? diff --git a/app/views/events/event/_created_project.html.haml b/app/views/events/event/_created_project.html.haml index f0bb07d062c..390c9ec6c89 100644 --- a/app/views/events/event/_created_project.html.haml +++ b/app/views/events/event/_created_project.html.haml @@ -4,7 +4,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name + user_profile_activity_classes } = event_action_name(event) - if event.project diff --git a/app/views/events/event/_design.html.haml b/app/views/events/event/_design.html.haml index c1fa1aaca50..945c7465ea8 100644 --- a/app/views/events/event/_design.html.haml +++ b/app/views/events/event/_design.html.haml @@ -4,7 +4,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name + user_profile_activity_classes } = event.action_name = event_design_title_html(event) = render "events/event_scope", event: event diff --git a/app/views/events/event/_note.html.haml b/app/views/events/event/_note.html.haml index 53c59474d83..5bbece84e40 100644 --- a/app/views/events/event/_note.html.haml +++ b/app/views/events/event/_note.html.haml @@ -6,7 +6,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name + user_profile_activity_classes } = event.action_name = event_note_title_html(event) - title = note_target_title(event.target) diff --git a/app/views/events/event/_private.html.haml b/app/views/events/event/_private.html.haml index d91f30c07cb..5e9d6da3996 100644 --- a/app/views/events/event/_private.html.haml +++ b/app/views/events/event/_private.html.haml @@ -1,8 +1,8 @@ -.event-item - .event-item-timestamp +.event-item{ class: current_path?('users#activity') ? 'user-profile-activity gl-border-bottom-0 gl-pl-7! gl-pb-3' : '' } + .event-item-timestamp.gl-font-sm = time_ago_with_tooltip(event.created_at) - .system-note-image= sprite_icon('eye-slash', size: 24, css_class: 'icon') + .system-note-image.gl-rounded-full.gl-bg-gray-50.gl-line-height-0= sprite_icon('eye-slash', size: 14, css_class: 'icon') = event_user_info(event) diff --git a/app/views/events/event/_push.html.haml b/app/views/events/event/_push.html.haml index 0ad969116e0..ff7983a9ba4 100644 --- a/app/views/events/event/_push.html.haml +++ b/app/views/events/event/_push.html.haml @@ -6,7 +6,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.gl-mr-2.pushed= event.push_activity_description + %span.event-type.d-inline-block.gl-mr-2.pushed{ class: user_profile_activity_classes }= event.push_activity_description - unless event.batch_push? %span.gl-mr-2.text-truncate - commits_link = project_commits_path(project, event.ref_name) diff --git a/app/views/events/event/_wiki.html.haml b/app/views/events/event/_wiki.html.haml index cbd5ebcae12..a48c34f80d8 100644 --- a/app/views/events/event/_wiki.html.haml +++ b/app/views/events/event/_wiki.html.haml @@ -4,7 +4,7 @@ .event-title.d-flex.flex-wrap = inline_event_icon(event) - %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name } + %span.event-type.d-inline-block.gl-mr-2{ class: event.action_name + user_profile_activity_classes } = event.action_name = event_wiki_title_html(event) = render "events/event_scope", event: event diff --git a/app/views/explore/catalog/show.html.haml b/app/views/explore/catalog/show.html.haml new file mode 100644 index 00000000000..7c8d788f8e3 --- /dev/null +++ b/app/views/explore/catalog/show.html.haml @@ -0,0 +1,3 @@ +- page_title _('CI/CD Catalog') + +#js-ci-cd-catalog{ data: { ci_catalog_path: explore_catalog_index_path } } diff --git a/app/views/external_redirect/external_redirect/index.html.haml b/app/views/external_redirect/external_redirect/index.html.haml new file mode 100644 index 00000000000..36bf98cba02 --- /dev/null +++ b/app/views/external_redirect/external_redirect/index.html.haml @@ -0,0 +1,12 @@ +- add_page_specific_style 'page_bundles/external_redirect' +- page_title _("You're about to leave GitLab") + +- url = local_assigns.fetch(:url) + +.gl-max-w-62.gl-h-full.gl-display-flex.gl-justify-content-center.gl-align-items-center.gl-flex-direction-column.gl-mr-auto.gl-ml-auto.gl-px-3 + = sprite_icon('warning', size: 48, css_class: 'gl-text-orange-300') + %h3.gl-mt-6= _("You're about to leave GitLab") + %p= safe_format(_('This link will redirect you to %{url}. If this URL looks wrong, please go back or close this window. Do you want to continue?'), url: content_tag(:code, url)) + .gl-display-flex.gl-justify-content-center.gl-w-full + = render Pajamas::ButtonComponent.new(variant: :default, href: url) do + = _("Proceed") diff --git a/app/views/groups/_home_panel.html.haml b/app/views/groups/_home_panel.html.haml index 544acd5ae56..269a7309ec2 100644 --- a/app/views/groups/_home_panel.html.haml +++ b/app/views/groups/_home_panel.html.haml @@ -3,7 +3,7 @@ - emails_disabled = @group.emails_disabled? .group-home-panel - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-gap-3.gl-my-5 + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-md-flex-direction-row.gl-gap-3.gl-my-5 .home-panel-title-row.gl-display-flex.gl-align-items-center .avatar-container.rect-avatar.s64.home-panel-avatar.gl-flex-shrink-0.float-none{ class: 'gl-mr-3!' } = group_icon(@group, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'logo') diff --git a/app/views/groups/_import_group_from_another_instance_panel.html.haml b/app/views/groups/_import_group_from_another_instance_panel.html.haml index c35bbce6ba7..6c5a27e68c4 100644 --- a/app/views/groups/_import_group_from_another_instance_panel.html.haml +++ b/app/views/groups/_import_group_from_another_instance_panel.html.haml @@ -24,7 +24,7 @@ = render Pajamas::AlertComponent.new(dismissible: false, variant: :warning) do |c| - c.with_body do - - docs_link = link_to('', help_page_path('user/group/import/index.md', anchor: 'migrated-group-items'), target: '_blank', rel: 'noopener noreferrer') + - docs_link = link_to('', help_page_path('user/group/import/index', anchor: 'migrated-group-items'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('GroupsNew|Not all group items are migrated. %{docs_link_start}What items are migrated%{docs_link_end}?'), tag_pair(docs_link, :docs_link_start, :docs_link_end)) %p.gl-mt-3 @@ -37,12 +37,12 @@ id: 'import_gitlab_url', data: { testid: 'import-gitlab-url' } .form-group.gl-display-flex.gl-flex-direction-column - = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token' + = f.label :bulk_import_gitlab_access_token, s_('GroupsNew|Personal access token'), for: 'import_gitlab_token', class: 'col-form-label' .gl-font-weight-normal - pat_link = link_to('', help_page_path('user/profile/personal_access_tokens'), target: '_blank') - short_living_link = link_to('', help_page_path('security/token_overview', anchor: 'security-considerations'), target: '_blank') = safe_format(s_('GroupsNew|Create a token with %{code_start}api%{code_end} and %{code_start}read_repository%{code_end} scopes in the %{pat_link_start}user settings%{pat_link_end} of the source GitLab instance. For %{short_living_link_start}security reasons%{short_living_link_end}, set a short expiration date for the token. Keep in mind that large migrations take more time.'), tag_pair('<code></code>'.html_safe, :code_start , :code_end), tag_pair(pat_link, :pat_link_start, :pat_link_end), tag_pair(short_living_link, :short_living_link_start, :short_living_link_end)) - = f.text_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', + = f.password_field :bulk_import_gitlab_access_token, placeholder: s_('GroupsNew|e.g. h8d3f016698e...'), class: 'gl-form-input gl-mt-3 col-xs-12 col-sm-8', required: true, disabled: bulk_imports_disabled, autocomplete: 'off', diff --git a/app/views/groups/_import_group_from_file_panel.html.haml b/app/views/groups/_import_group_from_file_panel.html.haml index e3d54e52aab..c39f5cf87c7 100644 --- a/app/views/groups/_import_group_from_file_panel.html.haml +++ b/app/views/groups/_import_group_from_file_panel.html.haml @@ -10,7 +10,7 @@ alert_options: { class: 'gl-mb-5' }, dismissible: false) do |c| - c.with_body do - - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index.md', anchor: 'migrate-groups-by-direct-transfer-recommended') } + - docs_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/group/import/index', anchor: 'migrate-groups-by-direct-transfer-recommended') } - link_end = '</a>'.html_safe = s_('GroupsNew|This feature is deprecated and replaced by group migration by direct transfer. %{docs_link_start}Learn more%{docs_link_end}.').html_safe % { docs_link_start: docs_link_start, docs_link_end: link_end } = render 'shared/groups/group_name_and_path_fields', f: f diff --git a/app/views/groups/_invite_members_modal.html.haml b/app/views/groups/_invite_members_modal.html.haml index cd3327ba9ec..d53190948fd 100644 --- a/app/views/groups/_invite_members_modal.html.haml +++ b/app/views/groups/_invite_members_modal.html.haml @@ -3,4 +3,8 @@ .js-invite-members-modal{ data: { is_project: 'false', access_levels: group.access_level_roles.to_json, reload_page_on_submit: current_path?('group_members#index').to_s, - help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } + help_link: help_page_url('user/permissions'), + is_signup_enabled: signup_enabled?.to_s, + new_users_url: new_admin_user_url, + is_current_user_admin: current_user&.admin?.to_s, + }.merge(common_invite_modal_dataset(group)).merge(users_filter_data(group)) } diff --git a/app/views/groups/projects.html.haml b/app/views/groups/projects.html.haml index 76758769d01..2f2edec2d80 100644 --- a/app/views/groups/projects.html.haml +++ b/app/views/groups/projects.html.haml @@ -17,15 +17,15 @@ = _("New project") - c.with_body do %ul.content-list{ class: 'gl-px-3!' } - - @projects.each_with_index do |project, idx| - %li.project-row.gl-align-items-center{ class: 'gl-display-flex!', data: { qa_selector: 'project_row_container', qa_index: idx } } + - @projects.each do |project| + %li.project-row.gl-align-items-center{ class: 'gl-display-flex!' } .avatar-container.rect-avatar.s40.gl-flex-shrink-0 = project_icon(project, alt: '', class: 'avatar project-avatar s40', width: 40, height: 40) .gl-min-w-0.gl-flex-grow-1 .title = link_to project_path(project), class: 'js-prefetch-document' do - %span.project-full-name{ data: { qa_selector: 'project_fullname_content' } } - %span.namespace-name{ data: { qa_selector: 'project_namespace_content' } } + %span.project-full-name + %span.namespace-name - if project.namespace = project.namespace.human_name \/ @@ -43,13 +43,12 @@ .controls.gl-flex-shrink-0.gl-ml-5 = render Pajamas::ButtonComponent.new(href: project_project_members_path(project), variant: :link, - button_options: { class: 'gl-mr-2', data: { qa_selector: 'project_members_button' } }) do + button_options: { class: 'gl-mr-2' }) do = _('View members') = render Pajamas::ButtonComponent.new(href: edit_project_path(project), - size: :small, - button_options: { data: { qa_selector: 'project_edit_button' } }) do + size: :small) do = _('Edit') - = render 'delete_project_button', project: project, data: { qa_selector: 'project_delete_button' } + = render 'delete_project_button', project: project - if @projects.blank? .nothing-here-block= _("This group has no projects yet") diff --git a/app/views/groups/settings/_export.html.haml b/app/views/groups/settings/_export.html.haml index 8eb9f8fc5f1..059426fd596 100644 --- a/app/views/groups/settings/_export.html.haml +++ b/app/views/groups/settings/_export.html.haml @@ -31,8 +31,8 @@ - if group.export_file_exists? = render Pajamas::ButtonComponent.new(href: download_export_group_path(group), button_options: { rel: 'nofollow', data: { method: :get, qa_selector: 'download_export_link' } }) do = _('Download export') - = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'regenerate_export_group_link' } }) do + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post } }) do = _('Regenerate export') - else - = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post, qa_selector: 'export_group_link' } }) do + = render Pajamas::ButtonComponent.new(href: export_group_path(group), button_options: { data: { method: :post } }) do = _('Export group') diff --git a/app/views/groups/settings/_git_access_protocols.html.haml b/app/views/groups/settings/_git_access_protocols.html.haml index d23f72a3055..c9cbe56e6ec 100644 --- a/app/views/groups/settings/_git_access_protocols.html.haml +++ b/app/views/groups/settings/_git_access_protocols.html.haml @@ -1,7 +1,7 @@ - if group.root? .form-group = f.label _('Enabled Git access protocols'), class: 'label-bold' - = f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group(group), group.enabled_git_access_protocol), {}, class: 'form-control', data: { qa_selector: 'enabled_git_access_protocol_dropdown' }, disabled: !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? + = f.select :enabled_git_access_protocol, options_for_select(enabled_git_access_protocol_options_for_group(group), group.enabled_git_access_protocol), {}, class: 'form-control', disabled: !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? - if !::Gitlab::CurrentSettings.enabled_git_access_protocol.blank? .form-text.text-muted = _("This setting has been configured at the instance level and cannot be overridden per group") diff --git a/app/views/groups/settings/_permissions.html.haml b/app/views/groups/settings/_permissions.html.haml index 45fd98adbb9..8ea80700340 100644 --- a/app/views/groups/settings/_permissions.html.haml +++ b/app/views/groups/settings/_permissions.html.haml @@ -38,11 +38,12 @@ = render 'groups/settings/lfs', f: f = render_if_exists 'groups/settings/code_suggestions', f: f, group: @group = render_if_exists 'groups/settings/experimental_settings', f: f, group: @group - = render_if_exists 'groups/settings/ai_third_party_settings', f: f, group: @group + = render_if_exists 'groups/settings/product_analytics_settings', f: f, group: @group = render 'groups/settings/git_access_protocols', f: f, group: @group = render 'groups/settings/project_creation_level', f: f, group: @group = render 'groups/settings/subgroup_creation_level', f: f, group: @group = render_if_exists 'groups/settings/prevent_forking', f: f, group: @group + = render_if_exists 'groups/settings/service_access_tokens_expiration_enforced', f: f, group: @group = render 'groups/settings/two_factor_auth', f: f, group: @group = render_if_exists 'groups/personal_access_token_expiration_policy', f: f, group: @group = render 'groups/settings/membership', f: f, group: @group diff --git a/app/views/groups/settings/_resource_access_token_creation.html.haml b/app/views/groups/settings/_resource_access_token_creation.html.haml index d304dba3250..7d64ab84ad2 100644 --- a/app/views/groups/settings/_resource_access_token_creation.html.haml +++ b/app/views/groups/settings/_resource_access_token_creation.html.haml @@ -7,4 +7,4 @@ - link_start_group = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: group_access_tokens_link } = f.gitlab_ui_checkbox_component :resource_access_token_creation_allowed, s_('GroupSettings|Users can create %{link_start_project}project access tokens%{link_end} and %{link_start_group}group access tokens%{link_end} in this group').html_safe % { link_start_project: link_start_project, link_start_group: link_start_group, link_end: '</a>'.html_safe }, - checkbox_options: { checked: group.namespace_settings.resource_access_token_creation_allowed?, data: { qa_selector: 'resource_access_token_creation_allowed_checkbox' } } + checkbox_options: { checked: group.namespace_settings.resource_access_token_creation_allowed? } diff --git a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml index b0a5d0bd4fa..705a9704fc2 100644 --- a/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml +++ b/app/views/groups/settings/ci_cd/_auto_devops_form.html.haml @@ -4,7 +4,7 @@ .form-group = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }) do |c| - c.with_body do - - learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer' + - learn_more_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index'), target: '_blank', rel: 'noopener noreferrer' - help_text = s_('GroupSettings|The Auto DevOps pipeline runs if no alternative CI configuration file is found.') - badge = gl_badge_tag badge_for_auto_devops_scope(group), variant: :info - label = s_('GroupSettings|Default to Auto DevOps pipeline for all projects within this group') diff --git a/app/views/import/bitbucket/status.html.haml b/app/views/import/bitbucket/status.html.haml index b4b73e9e790..dc80aeb8a30 100644 --- a/app/views/import/bitbucket/status.html.haml +++ b/app/views/import/bitbucket/status.html.haml @@ -1,9 +1,9 @@ - page_title _('Bitbucket import') - header_title _('Projects'), root_path -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('bitbucket', css_class: 'gl-mr-2') + = sprite_icon('bitbucket', css_class: 'gl-mr-3', size: 48) = _('Import projects from Bitbucket') = render 'import/githubish_status', provider: 'bitbucket', default_namespace: @namespace diff --git a/app/views/import/bitbucket_server/new.html.haml b/app/views/import/bitbucket_server/new.html.haml index de94f142a40..583d312154c 100644 --- a/app/views/import/bitbucket_server/new.html.haml +++ b/app/views/import/bitbucket_server/new.html.haml @@ -2,10 +2,11 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('bitbucket', css_class: 'gl-mr-2') + = sprite_icon('bitbucket', css_class: 'gl-mr-3', size: 48) = _('Import repositories from Bitbucket Server') +%hr %p = _('Enter in your Bitbucket Server URL and personal access token below') diff --git a/app/views/import/bitbucket_server/status.html.haml b/app/views/import/bitbucket_server/status.html.haml index 7e0c7b3dd74..6994404c8c9 100644 --- a/app/views/import/bitbucket_server/status.html.haml +++ b/app/views/import/bitbucket_server/status.html.haml @@ -1,8 +1,8 @@ - page_title _('Bitbucket Server import') -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('bitbucket', css_class: 'gl-mr-2') + = sprite_icon('bitbucket', css_class: 'gl-mr-3', size: 48) = _('Import projects from Bitbucket Server') = render 'import/githubish_status', provider: 'bitbucket_server', paginatable: true, default_namespace: @namespace, extra_data: { reconfigure_path: configure_import_bitbucket_server_path } diff --git a/app/views/import/bulk_imports/details.html.haml b/app/views/import/bulk_imports/details.html.haml new file mode 100644 index 00000000000..511bf2c38a1 --- /dev/null +++ b/app/views/import/bulk_imports/details.html.haml @@ -0,0 +1,5 @@ +- add_to_breadcrumbs _('New group'), new_group_path +- add_to_breadcrumbs _('Import group'), new_group_path(anchor: 'import-group-pane') +- page_title s_('Import|GitLab Migration details') + +.js-bulk-import-details diff --git a/app/views/import/bulk_imports/history.html.haml b/app/views/import/bulk_imports/history.html.haml index 38196f97030..57e3e60a702 100644 --- a/app/views/import/bulk_imports/history.html.haml +++ b/app/views/import/bulk_imports/history.html.haml @@ -3,4 +3,4 @@ - add_page_specific_style 'page_bundles/import' - page_title _('Import history') -#import-history-mount-element{ data: { realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } } +#import-history-mount-element{ data: { details_path: details_import_bulk_imports_path, realtime_changes_path: realtime_changes_import_bulk_imports_path(format: :json) } } diff --git a/app/views/import/fogbugz/new.html.haml b/app/views/import/fogbugz/new.html.haml index 2edd9cd5592..001f6588405 100644 --- a/app/views/import/fogbugz/new.html.haml +++ b/app/views/import/fogbugz/new.html.haml @@ -2,9 +2,9 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('bug', css_class: 'gl-mr-2') + = sprite_icon('bug', css_class: 'gl-mr-3', size: 48) = _('Import projects from FogBugz') %hr diff --git a/app/views/import/fogbugz/status.html.haml b/app/views/import/fogbugz/status.html.haml index fb05e8e9724..7512e3d3935 100644 --- a/app/views/import/fogbugz/status.html.haml +++ b/app/views/import/fogbugz/status.html.haml @@ -1,7 +1,7 @@ - page_title _("FogBugz import") -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('bug', css_class: 'gl-mr-2') + = sprite_icon('bug', css_class: 'gl-mr-3', size: 48) = _('Import projects from FogBugz') %p.light diff --git a/app/views/import/gitea/new.html.haml b/app/views/import/gitea/new.html.haml index f76e9f3f6ed..dcee0c473a1 100644 --- a/app/views/import/gitea/new.html.haml +++ b/app/views/import/gitea/new.html.haml @@ -2,9 +2,11 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display - = custom_icon('gitea_logo') +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center + .gl-display-flex.gl-align-items-center.gl-justify-content-center + = sprite_icon('gitea', css_class: 'gl-mr-3', size: 48) = _('Import projects from Gitea') +%hr %p - link_to_personal_token = link_to(_('personal access token'), 'https://docs.gitea.io/en-us/api-usage/#authentication-via-the-api') @@ -17,9 +19,9 @@ .col-sm-4 = text_field_tag :gitea_host_url, nil, placeholder: 'https://gitea.com', class: 'form-control gl-form-input' .form-group.row - = label_tag :personal_access_token, _('Personal access token'), class: 'col-form-label col-sm-2' + = label_tag :personal_access_token, _('Personal access token'), for: :personal_access_token, class: 'col-form-label col-sm-2' .col-sm-4 - = text_field_tag :personal_access_token, nil, class: 'form-control gl-form-input' + = password_field_tag :personal_access_token, nil, class: 'form-control gl-form-input' .form-actions = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm) do = _('List your Gitea repositories') diff --git a/app/views/import/gitea/status.html.haml b/app/views/import/gitea/status.html.haml index 2dde642d8f0..86ab3ca85c3 100644 --- a/app/views/import/gitea/status.html.haml +++ b/app/views/import/gitea/status.html.haml @@ -1,6 +1,7 @@ - page_title _("Gitea import") -%h1.page-title.gl-font-size-h-display - = custom_icon('gitea_logo') +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center + .gl-display-flex.gl-align-items-center.gl-justify-content-center + = sprite_icon('gitea', css_class: 'gl-mr-3', size: 48) = _('Import projects from Gitea') = render 'import/githubish_status', provider: 'gitea', default_namespace: @namespace diff --git a/app/views/import/github/new.html.haml b/app/views/import/github/new.html.haml index 5293013b813..24369ff3d39 100644 --- a/app/views/import/github/new.html.haml +++ b/app/views/import/github/new.html.haml @@ -3,8 +3,11 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center + .gl-display-flex.gl-align-items-center.gl-justify-content-center + = sprite_icon('github', css_class: 'gl-mr-3', size: 48) = title +%hr %p = import_github_authorize_message @@ -23,9 +26,9 @@ = form_tag personal_access_token_import_github_path, method: :post do .form-group - %label.label-bold= _('Personal Access Token') + %label.col-form-label{ for: 'personal_access_token' }= _('Personal Access Token') = hidden_field_tag(:namespace_id, params[:namespace_id]) - = text_field_tag :personal_access_token, '', class: 'form-control gl-form-input', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { qa_selector: 'personal_access_token_field' } + = password_field_tag :personal_access_token, '', class: 'form-control gl-form-input', placeholder: _('e.g. %{token}') % { token: '8d3f016698e...' }, data: { testid: 'personal-access-token-field' } %span.form-text.gl-text-gray-600 = import_github_personal_access_token_message @@ -34,7 +37,5 @@ .form-actions.gl-display-flex.gl-justify-content-end = render Pajamas::ButtonComponent.new(href: new_project_path) do = _('Cancel') - = render Pajamas::ButtonComponent.new(variant: :confirm, - type: :submit, - button_options: { class: 'gl-ml-3', data: { qa_selector: 'authenticate_button' } }) do + = render Pajamas::ButtonComponent.new(variant: :confirm, type: :submit, button_options: { class: 'gl-ml-3', data: { testid: 'authenticate-button' } }) do = _('Authenticate') diff --git a/app/views/import/github/status.html.haml b/app/views/import/github/status.html.haml index 6f25bc75ca1..f1a61d72771 100644 --- a/app/views/import/github/status.html.haml +++ b/app/views/import/github/status.html.haml @@ -1,8 +1,8 @@ - title = has_ci_cd_only_params? ? _('Connect repositories from GitHub') : _('GitHub import') - page_title title -%h1.page-title.gl-font-size-h-display.mb-0.gl-display-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('github', css_class: 'gl-mr-2') + = sprite_icon('github', css_class: 'gl-mr-3', size: 48) = _('Import repositories from GitHub') = render 'import/githubish_status', diff --git a/app/views/import/gitlab_projects/new.html.haml b/app/views/import/gitlab_projects/new.html.haml index 079123e989e..b90d400a843 100644 --- a/app/views/import/gitlab_projects/new.html.haml +++ b/app/views/import/gitlab_projects/new.html.haml @@ -2,9 +2,9 @@ - header_title _("New project"), new_project_path - add_to_breadcrumbs s_('ProjectsNew|Import project'), new_project_path(anchor: 'import_project') -%h1.page-title.gl-font-size-h-display.d-flex +%h1.page-title.gl-font-size-h-display.d-flex.gl-align-items-center .gl-display-flex.gl-align-items-center.gl-justify-content-center - = sprite_icon('tanuki', css_class: 'gl-mr-2') + = sprite_icon('tanuki', css_class: 'gl-mr-3', size: 48) = _('Import an exported GitLab project') %hr @@ -21,7 +21,7 @@ = file_field_tag :file, class: '' .row .form-actions.col-sm-12 - = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mr-2', data: { qa_selector: 'import_project_button' }}) do + = render Pajamas::ButtonComponent.new(type: :submit, variant: :confirm, button_options: { class: 'gl-mr-2', data: { testid: 'import-project-button' }}) do = _('Import project') = render Pajamas::ButtonComponent.new(href: new_project_path) do = _('Cancel') diff --git a/app/views/import/shared/_new_project_form.html.haml b/app/views/import/shared/_new_project_form.html.haml index 6000612a285..042d94ad1b6 100644 --- a/app/views/import/shared/_new_project_form.html.haml +++ b/app/views/import/shared/_new_project_form.html.haml @@ -1,7 +1,7 @@ .row .form-group.project-name.col-sm-12 = label_tag :name, _('Project name'), class: 'label-bold' - = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true }, data: { qa_selector: 'project_name_field' } + = text_field_tag :name, @name, placeholder: "My awesome project", class: "js-project-name form-control gl-form-input input-lg", autofocus: true, required: true, aria: { required: true }, data: { testid: 'project-name-field' } .form-group.col-12.col-sm-6.gl-pr-0 = label_tag :namespace_id, _('Project URL'), class: 'label-bold' .input-group.gl-flex-nowrap @@ -21,4 +21,4 @@ .gl-align-self-center.gl-pl-5 / .form-group.col-12.col-sm-6.project-path = label_tag :path, _('Project slug'), class: 'label-bold' - = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true }, data: { qa_selector: 'project_slug_field' } + = text_field_tag :path, @path, placeholder: "my-awesome-project", class: "js-path-name form-control gl-form-input", required: true, aria: { required: true } diff --git a/app/views/invites/decline.html.haml b/app/views/invites/decline.html.haml index 4a57d70cb6e..40e4455e565 100644 --- a/app/views/invites/decline.html.haml +++ b/app/views/invites/decline.html.haml @@ -1,5 +1,5 @@ - page_title _('Invitation declined') -.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto{ class: 'gl-xs-w-full!' } +.decline-page.gl-display-flex.gl-flex-direction-column.gl-mx-auto.gl-w-full.gl-sm-w-auto .gl-align-self-center.gl-mb-4.gl-mt-7.gl-sm-mt-0= sprite_icon('check-circle', size: 48, css_class: 'gl-text-green-400') %h2.gl-font-size-h2= _('You successfully declined the invitation') %p diff --git a/app/views/layouts/_head.html.haml b/app/views/layouts/_head.html.haml index 37d03bde72e..41f663c7c06 100644 --- a/app/views/layouts/_head.html.haml +++ b/app/views/layouts/_head.html.haml @@ -2,10 +2,6 @@ - site_name = _('GitLab') - omit_og = sign_in_with_redirect? --# This is a temporary place for the page specific style migrations to be included on all pages like page_specific_files -- if Feature.disabled?(:page_specific_styles, current_user) - - add_page_specific_style('page_bundles/projects') - %head{ omit_og ? { } : { prefix: "og: http://ogp.me/ns#" } } %meta{ charset: "utf-8" } %meta{ 'http-equiv' => 'X-UA-Compatible', content: 'IE=edge' } diff --git a/app/views/layouts/_page.html.haml b/app/views/layouts/_page.html.haml index f52ea801eef..fe2c2e968e8 100644 --- a/app/views/layouts/_page.html.haml +++ b/app/views/layouts/_page.html.haml @@ -7,7 +7,7 @@ - sidebar_panel = super_sidebar_nav_panel(nav: nav, user: current_user, group: group, project: @project, current_ref: current_ref, ref_type: @ref_type, viewed_user: @user, organization: @organization) - sidebar_data = super_sidebar_context(current_user, group: group, project: @project, panel: sidebar_panel, panel_type: nav).to_json - %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, toggle_new_nav_endpoint: profile_preferences_path, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } + %aside.js-super-sidebar.super-sidebar.super-sidebar-loading{ data: { root_path: root_path, sidebar: sidebar_data, force_desktop_expanded_sidebar: @force_desktop_expanded_sidebar.to_s, command_palette: command_palette_data(project: @project).to_json } } - if display_whats_new? #whats-new-app{ data: { version_digest: whats_new_version_digest } } @@ -20,7 +20,7 @@ .mobile-overlay = dispensable_render_if_exists 'layouts/header/verification_reminder' .alert-wrapper.gl-force-block-formatting-context - = dispensable_render 'shared/new_nav_announcement' + = dispensable_render 'shared/new_nav_for_everyone_announcement' = dispensable_render 'shared/outdated_browser' = dispensable_render_if_exists "layouts/header/licensed_user_count_threshold" = dispensable_render_if_exists "layouts/header/token_expiry_notification" diff --git a/app/views/layouts/application.html.haml b/app/views/layouts/application.html.haml index 451c66b074b..5a66cc0ddb5 100644 --- a/app/views/layouts/application.html.haml +++ b/app/views/layouts/application.html.haml @@ -1,6 +1,6 @@ - page_classes = page_class << @html_class -- page_classes = page_classes.flatten.compact -- body_classes = [user_application_theme, user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] +- page_classes = [user_application_theme, page_classes.flatten.compact] +- body_classes = [user_tab_width, @body_class, client_class_list, *custom_diff_color_classes] !!! 5 %html{ lang: I18n.locale, class: page_classes } diff --git a/app/views/layouts/devise.html.haml b/app/views/layouts/devise.html.haml index 4e9ae7c7fd8..6a65b31a002 100644 --- a/app/views/layouts/devise.html.haml +++ b/app/views/layouts/devise.html.haml @@ -1,9 +1,9 @@ - add_page_specific_style 'page_bundles/login' - custom_text = custom_sign_in_description !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head", { startup_filename: 'signin' } - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page' } } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page, qa_selector: 'login_page', testid: 'login-page' } } = header_message = render "layouts/init_client_detection_flags" - if Feature.enabled?(:restyle_login_page, @project) @@ -31,7 +31,7 @@ %h1.mb-3.gl-font-size-h2 = brand_title .mb-3 - .gl-w-half.gl-xs-w-full.gl-ml-auto.gl-mr-auto.bar + .gl-w-full.gl-sm-w-half.gl-ml-auto.gl-mr-auto.bar = yield = render 'devise/shared/footer' diff --git a/app/views/layouts/devise_empty.html.haml b/app/views/layouts/devise_empty.html.haml index 3e969b866a6..6816a64ac8f 100644 --- a/app/views/layouts/devise_empty.html.haml +++ b/app/views/layouts/devise_empty.html.haml @@ -1,8 +1,8 @@ - add_page_specific_style 'page_bundles/login' !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head" - %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}" } + %body.gl-h-full.login-page.navless{ class: "#{system_message_class} #{client_class_list}" } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/empty" diff --git a/app/views/layouts/fullscreen.html.haml b/app/views/layouts/fullscreen.html.haml index da192822902..f168c742085 100644 --- a/app/views/layouts/fullscreen.html.haml +++ b/app/views/layouts/fullscreen.html.haml @@ -1,8 +1,8 @@ - minimal = local_assigns.fetch(:minimal, false) !!! 5 -%html{ lang: I18n.locale, class: page_class } +%html{ class: [user_application_theme, page_class], lang: I18n.locale } = render "layouts/head" - %body{ class: "#{user_application_theme} #{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } + %body{ class: "#{user_tab_width} #{@body_class} fullscreen-layout", data: { page: body_data_page } } = render 'peek/bar' = header_message - unless minimal diff --git a/app/views/layouts/minimal.html.haml b/app/views/layouts/minimal.html.haml index 8b6a2a2f2a7..e499b9ae240 100644 --- a/app/views/layouts/minimal.html.haml +++ b/app/views/layouts/minimal.html.haml @@ -1,17 +1,18 @@ - page_classes = page_class.push(@html_class).flatten.compact !!! 5 -%html{ lang: I18n.locale, class: page_classes } +%html.gl-h-full{ lang: I18n.locale, class: page_classes } = render "layouts/head" - %body{ data: body_data, class: system_message_class } + %body.gl-h-full{ data: body_data, class: system_message_class } = header_message = render 'peek/bar' = render 'layouts/published_experiments' = render "layouts/header/empty" - .layout-page + .layout-page.gl-h-full.borderless.gl-display-flex.gl-flex-wrap .content-wrapper.gl-pt-6{ class: 'gl-md-pt-11!' } %div{ class: container_class } %main#content-body.content = render "layouts/flash" unless @hide_flash = yield + = yield :footer = footer_message diff --git a/app/views/layouts/nav/_ask_duo_button.html.haml b/app/views/layouts/nav/_ask_duo_button.html.haml deleted file mode 100644 index e37ce50352c..00000000000 --- a/app/views/layouts/nav/_ask_duo_button.html.haml +++ /dev/null @@ -1,13 +0,0 @@ -- if Gitlab.ee? && ::Gitlab::Llm::TanukiBot.show_breadcrumbs_entry_point_for?(user: current_user) - - label = s_('TanukiBot|GitLab Duo Chat') - = render Pajamas::ButtonComponent.new(variant: :confirm, - category: :secondary, - icon: 'tanuki-ai', - size: 'small', - button_options: { class: 'js-tanuki-bot-chat-toggle gl-ml-3 gl-display-none gl-md-display-inline', data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button' }, aria: { label: label }}) do - = label - = render Pajamas::ButtonComponent.new(variant: :confirm, - category: :secondary, - icon: 'tanuki-ai', - size: 'small', - button_options: { class: 'js-tanuki-bot-chat-toggle has-tooltip gl-ml-3 gl-md-display-none', title: label, data: { track_action: 'click_button', track_label: 'tanuki_bot_breadcrumbs_button', placement: 'left' }, aria: { label: label }}) diff --git a/app/views/layouts/nav/_top_bar.html.haml b/app/views/layouts/nav/_top_bar.html.haml index ef783b688e0..c938cad5c42 100644 --- a/app/views/layouts/nav/_top_bar.html.haml +++ b/app/views/layouts/nav/_top_bar.html.haml @@ -12,4 +12,4 @@ - elsif defined?(@left_sidebar) = render Pajamas::ButtonComponent.new(icon: 'sidebar', category: :tertiary, button_options: { class: 'toggle-mobile-nav gl-ml-n3', data: { testid: 'toggle-mobile-nav-button' }, aria: { label: _('Open sidebar') } }) = render "layouts/nav/breadcrumbs/breadcrumbs" - = render "layouts/nav/ask_duo_button" + = render_if_exists "layouts/nav/ask_duo_button" diff --git a/app/views/layouts/signup_onboarding.html.haml b/app/views/layouts/signup_onboarding.html.haml index a5953021671..c8e15896b97 100644 --- a/app/views/layouts/signup_onboarding.html.haml +++ b/app/views/layouts/signup_onboarding.html.haml @@ -1,9 +1,9 @@ - add_page_specific_style 'page_bundles/signup' - add_page_specific_style 'page_bundles/login' !!! 5 -%html.html-devise-layout{ lang: I18n.locale } +%html.html-devise-layout{ class: user_application_theme, lang: I18n.locale } = render "layouts/head" - %body.signup-page.navless{ class: "#{system_message_class} #{user_application_theme} #{client_class_list}", data: { page: body_data_page, qa_selector: 'signup_page' } } + %body.signup-page.navless{ class: "#{system_message_class} #{client_class_list}", data: { page: body_data_page } } = header_message = render "layouts/init_client_detection_flags" = render "layouts/header/logo_with_title" diff --git a/app/views/layouts/terms.html.haml b/app/views/layouts/terms.html.haml index 32f00a4c0c6..09b5407ecdb 100644 --- a/app/views/layouts/terms.html.haml +++ b/app/views/layouts/terms.html.haml @@ -2,11 +2,10 @@ - add_page_specific_style 'page_bundles/terms' - @hide_top_bar = true - @hide_top_bar_padding = true -- body_classes = [user_application_theme] -%html{ lang: I18n.locale, class: page_class } +%html{ lang: I18n.locale, class: [user_application_theme, page_class] } = render "layouts/head" - %body{ class: body_classes, data: { page: body_data_page } } + %body{ data: { page: body_data_page } } .layout-page.terms{ class: page_class } .content-wrapper.gl-pb-5 .mobile-overlay diff --git a/app/views/notify/github_gists_import_errors_email.html.haml b/app/views/notify/github_gists_import_errors_email.html.haml index 07b4cfca77e..903f4bf1466 100644 --- a/app/views/notify/github_gists_import_errors_email.html.haml +++ b/app/views/notify/github_gists_import_errors_email.html.haml @@ -11,7 +11,7 @@ %li = s_("GithubImporter|Gist with id %{gist_id} failed due to error: %{error}.") % { gist_id: gist_id, error: error } - if error == Gitlab::GithubGistsImport::Importer::GistImporter::FILE_COUNT_LIMIT_MESSAGE - - import_snippets_url = help_page_url('api/import.md', anchor: 'import-github-gists-into-gitlab-snippets') + - import_snippets_url = help_page_url('api/import', anchor: 'import-github-gists-into-gitlab-snippets') - import_snippets_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: import_snippets_url } = html_escape(s_("GithubImporter|Please follow %{import_snippets_link_start}Import GitHub gists into GitLab snippets%{import_snippets_link_end} for more details.")) % { import_snippets_link_start: import_snippets_link_start, import_snippets_link_end: '</a>'.html_safe } diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml index c0b334fba94..d053fdff624 100644 --- a/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml +++ b/app/views/notify/pages_domain_auto_ssl_failed_email.html.haml @@ -5,7 +5,7 @@ %p #{_('Domain')}: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} %p - - docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting') + - docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration', anchor: 'troubleshooting') - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: docs_url } - link_end = '</a>'.html_safe = _("Please follow the %{link_start}Let's Encrypt troubleshooting instructions%{link_end} to re-obtain your Let's Encrypt certificate.").html_safe % { link_start: link_start, link_end: link_end } diff --git a/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml index feb88d2df39..ecc466d3e74 100644 --- a/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml +++ b/app/views/notify/pages_domain_auto_ssl_failed_email.text.haml @@ -3,5 +3,5 @@ #{_('Project')}: #{project_url(@project)} #{_('Domain')}: #{project_pages_domain_url(@project, @domain)} -- docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md', anchor: 'troubleshooting') +- docs_url = help_page_url('user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration', anchor: 'troubleshooting') = _("Please follow the Let's Encrypt troubleshooting instructions to re-obtain your Let's Encrypt certificate: %{docs_url}.").html_safe % { docs_url: docs_url } diff --git a/app/views/notify/pages_domain_disabled_email.html.haml b/app/views/notify/pages_domain_disabled_email.html.haml index 44f85df97b9..6b4e40780aa 100644 --- a/app/views/notify/pages_domain_disabled_email.html.haml +++ b/app/views/notify/pages_domain_disabled_email.html.haml @@ -8,6 +8,6 @@ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} %p If this domain has been disabled in error, please follow - = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership') + = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: '4-verify-the-domains-ownership') to verify and re-enable your domain. = render 'removal_notification' diff --git a/app/views/notify/pages_domain_disabled_email.text.haml b/app/views/notify/pages_domain_disabled_email.text.haml index 5a0fcab72d4..12295f9aa18 100644 --- a/app/views/notify/pages_domain_disabled_email.text.haml +++ b/app/views/notify/pages_domain_disabled_email.text.haml @@ -7,7 +7,7 @@ Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) If this domain has been disabled in error, please follow these instructions to verify and re-enable your domain: -= help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') += help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') If you no longer wish to use this domain with GitLab Pages, please remove it from your GitLab project and delete any related DNS records. diff --git a/app/views/notify/pages_domain_enabled_email.html.haml b/app/views/notify/pages_domain_enabled_email.html.haml index 103b17a87df..64155e888b7 100644 --- a/app/views/notify/pages_domain_enabled_email.html.haml +++ b/app/views/notify/pages_domain_enabled_email.html.haml @@ -7,5 +7,5 @@ Domain: #{link_to @domain.domain, project_pages_domain_url(@project, @domain)} %p Please visit - = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') + = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_enabled_email.text.haml b/app/views/notify/pages_domain_enabled_email.text.haml index bf8d2ac767a..df56dacf52c 100644 --- a/app/views/notify/pages_domain_enabled_email.text.haml +++ b/app/views/notify/pages_domain_enabled_email.text.haml @@ -5,5 +5,5 @@ Project: #{@project.human_name} (#{project_url(@project)}) Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) Please visit -= help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') += help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_verification_failed_email.html.haml b/app/views/notify/pages_domain_verification_failed_email.html.haml index a819b66f18e..4d92d8d1088 100644 --- a/app/views/notify/pages_domain_verification_failed_email.html.haml +++ b/app/views/notify/pages_domain_verification_failed_email.html.haml @@ -10,6 +10,6 @@ Until then, you can view your content at #{link_to @domain.url, @domain.url} %p Please visit - = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') + = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. = render 'removal_notification' diff --git a/app/views/notify/pages_domain_verification_failed_email.text.haml b/app/views/notify/pages_domain_verification_failed_email.text.haml index 85aa2d7a503..045fd5483b2 100644 --- a/app/views/notify/pages_domain_verification_failed_email.text.haml +++ b/app/views/notify/pages_domain_verification_failed_email.text.haml @@ -7,7 +7,7 @@ Unless you take action, it will be disabled on *#{@domain.enabled_until.strftime Until then, you can view your content at #{@domain.url} Please visit -= help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') += help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. If you no longer wish to use this domain with GitLab Pages, please remove it diff --git a/app/views/notify/pages_domain_verification_succeeded_email.html.haml b/app/views/notify/pages_domain_verification_succeeded_email.html.haml index 808b12948f9..aaf0dae597f 100644 --- a/app/views/notify/pages_domain_verification_succeeded_email.html.haml +++ b/app/views/notify/pages_domain_verification_succeeded_email.html.haml @@ -9,5 +9,5 @@ content at #{link_to @domain.url, @domain.url} %p Please visit - = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') + = link_to 'these instructions', help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. diff --git a/app/views/notify/pages_domain_verification_succeeded_email.text.haml b/app/views/notify/pages_domain_verification_succeeded_email.text.haml index 8d0694ef613..15cf9823a08 100644 --- a/app/views/notify/pages_domain_verification_succeeded_email.text.haml +++ b/app/views/notify/pages_domain_verification_succeeded_email.text.haml @@ -6,5 +6,5 @@ Domain: #{@domain.domain} (#{project_pages_domain_url(@project, @domain)}) No action is required on your part. You can view your content at #{@domain.url} Please visit -= help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: 'steps') += help_page_url('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: 'steps') for more information about custom domain verification. diff --git a/app/views/organizations/organizations/users.html.haml b/app/views/organizations/organizations/users.html.haml new file mode 100644 index 00000000000..5fb9d786e0b --- /dev/null +++ b/app/views/organizations/organizations/users.html.haml @@ -0,0 +1,4 @@ +- page_title _('Users') + +#js-organizations-users{ data: organization_user_app_data(@organization) } + diff --git a/app/views/organizations/settings/general.html.haml b/app/views/organizations/settings/general.html.haml index 94892ef9fbb..663c8fceedf 100644 --- a/app/views/organizations/settings/general.html.haml +++ b/app/views/organizations/settings/general.html.haml @@ -1 +1,4 @@ - page_title _("General settings") +- add_page_specific_style 'page_bundles/settings' + +#js-organizations-settings-general{ data: { app_data: organization_settings_general_app_data(@organization) } } diff --git a/app/views/profiles/gpg_keys/index.html.haml b/app/views/profiles/gpg_keys/index.html.haml index 982199d3d6f..031869cc60e 100644 --- a/app/views/profiles/gpg_keys/index.html.haml +++ b/app/views/profiles/gpg_keys/index.html.haml @@ -28,7 +28,7 @@ %h4.gl-mt-0 = _('Add a GPG key') %p - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/signed_commits/gpg.md') } + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/repository/signed_commits/gpg') } = _('Add a GPG key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } = render 'form' diff --git a/app/views/profiles/keys/_key.html.haml b/app/views/profiles/keys/_key.html.haml index 7ba42274f88..f80cd8cddc5 100644 --- a/app/views/profiles/keys/_key.html.haml +++ b/app/views/profiles/keys/_key.html.haml @@ -25,7 +25,7 @@ -# TODO: Remove this conditional when https://gitlab.com/gitlab-org/gitlab/-/issues/324764 is resolved. - if Feature.enabled?(:disable_ssh_key_used_tracking) = _('Unavailable') - = link_to sprite_icon('question-o'), help_page_path('user/ssh.md', anchor: 'view-your-accounts-ssh-keys') + = link_to sprite_icon('question-o'), help_page_path('user/ssh', anchor: 'view-your-accounts-ssh-keys') - else = key.last_used_at ? time_ago_with_tooltip(key.last_used_at) : _('Never') diff --git a/app/views/profiles/keys/index.html.haml b/app/views/profiles/keys/index.html.haml index 0cd41788a53..8477d87a587 100644 --- a/app/views/profiles/keys/index.html.haml +++ b/app/views/profiles/keys/index.html.haml @@ -30,7 +30,7 @@ %h4.gl-mt-0 = _('Add an SSH key') %p - - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh.md') } + - help_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/ssh') } = _('Add an SSH key for secure access to GitLab. %{help_link_start}Learn more%{help_link_end}.').html_safe % {help_link_start: help_link_start, help_link_end: '</a>'.html_safe } = render 'form' diff --git a/app/views/profiles/personal_access_tokens/index.html.haml b/app/views/profiles/personal_access_tokens/index.html.haml index c12f6907afb..0457561b283 100644 --- a/app/views/profiles/personal_access_tokens/index.html.haml +++ b/app/views/profiles/personal_access_tokens/index.html.haml @@ -35,7 +35,7 @@ path: profile_personal_access_tokens_path, token: @personal_access_token, scopes: @scopes, - help_path: help_page_path('user/profile/personal_access_tokens.md', anchor: 'personal-access-token-scopes') + help_path: help_page_path('user/profile/personal_access_tokens', anchor: 'personal-access-token-scopes') #js-access-token-table-app{ data: { access_token_type: type, access_token_type_plural: type_plural, initial_active_access_tokens: @active_access_tokens.to_json } } diff --git a/app/views/profiles/preferences/show.html.haml b/app/views/profiles/preferences/show.html.haml index a6534a16e86..96375412f94 100644 --- a/app/views/profiles/preferences/show.html.haml +++ b/app/views/profiles/preferences/show.html.haml @@ -78,6 +78,9 @@ = f.gitlab_ui_radio_component :layout, layout_choices[0][1], layout_choices[0][0], help_text: fixed_help_text = f.gitlab_ui_radio_component :layout, layout_choices[1][1], layout_choices[1][0], help_text: fluid_help_text + - if Feature.enabled?(:ui_for_organizations, current_user) + #js-home-organization-setting{ data: { app_data: home_organization_setting_app_data } } + .js-listbox-input{ data: { label: s_('Preferences|Homepage'), description: s_('Preferences|Choose what content you want to see by default on your homepage.'), name: 'user[dashboard]', items: dashboard_choices.to_json, value: current_user.dashboard, block: true.to_s, toggle_class: 'gl-form-input-xl' } } = render_if_exists 'profiles/preferences/group_overview_selector', f: f # EE-specific @@ -152,6 +155,12 @@ = f.gitlab_ui_checkbox_component :time_display_relative, s_('Preferences|Use relative times'), help_text: s_('Preferences|For example: 30 minutes ago.') + .form-group + = f.label :time_display_format, class: 'label-bold' do + = s_('Preferences|Time format') + - time_display_format_choices.each_entry do |time_display_format_option| + .gl-mb-4 + = f.gitlab_ui_radio_component :time_display_format, time_display_format_option[1], time_display_format_option[0] .settings-section.js-preferences-form.js-search-settings-section#enabled_following .settings-sticky-header .settings-sticky-header-inner diff --git a/app/views/profiles/show.html.haml b/app/views/profiles/show.html.haml index 4da48771ba3..405364b6792 100644 --- a/app/views/profiles/show.html.haml +++ b/app/views/profiles/show.html.haml @@ -122,6 +122,10 @@ allow_empty: true} %small.form-text.text-gl-muted = external_accounts_docs_link + - if Feature.enabled?(:mastodon_social_ui, @user) + .form-group.gl-form-group + = f.label :mastodon + = f.text_field :mastodon, class: 'gl-form-input form-control gl-md-form-input-lg', placeholder: "@robin@example.com" .form-group.gl-form-group = f.label :website_url, s_('Profiles|Website url') @@ -152,7 +156,7 @@ %legend.col-form-label = _('Private profile') - private_profile_label = s_("Profiles|Don't display activity-related personal information on your profile.") - - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index.md', anchor: 'make-your-user-profile-page-private') + - private_profile_help_link = link_to sprite_icon('question-o'), help_page_path('user/profile/index', anchor: 'make-your-user-profile-page-private') = f.gitlab_ui_checkbox_component :private_profile, '%{private_profile_label} %{private_profile_help_link}'.html_safe % { private_profile_label: private_profile_label, private_profile_help_link: private_profile_help_link.html_safe } %fieldset.form-group.gl-form-group %legend.col-form-label diff --git a/app/views/profiles/two_factor_auths/show.html.haml b/app/views/profiles/two_factor_auths/show.html.haml index ff0b31da022..7c42053a376 100644 --- a/app/views/profiles/two_factor_auths/show.html.haml +++ b/app/views/profiles/two_factor_auths/show.html.haml @@ -41,7 +41,7 @@ alert_options: { class: 'gl-mb-3' }, dismissible: false) do |c| - c.with_body do - = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' - if current_password_required? .form-group @@ -130,7 +130,7 @@ alert_options: { class: 'gl-mb-3' }, dismissible: false) do |c| - c.with_body do - = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication.md', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Try the troubleshooting steps here.'), help_page_path('user/profile/account/two_factor_authentication', anchor: 'troubleshooting'), target: '_blank', rel: 'noopener noreferrer' .js-manage-two-factor-form{ data: { current_password_required: current_password_required?.to_s, profile_two_factor_auth_path: profile_two_factor_auth_path, profile_two_factor_auth_method: 'delete', codes_profile_two_factor_auth_path: codes_profile_two_factor_auth_path, codes_profile_two_factor_auth_method: 'post' } } - else %p diff --git a/app/views/projects/_errors.html.haml b/app/views/projects/_errors.html.haml index 2dba22d3be6..9c478f245dc 100644 --- a/app/views/projects/_errors.html.haml +++ b/app/views/projects/_errors.html.haml @@ -1 +1 @@ -= form_errors(@project) += form_errors(@project, custom_message: [:limit_reached]) diff --git a/app/views/projects/_home_panel.html.haml b/app/views/projects/_home_panel.html.haml index 93f4fe62568..e41a0d3d262 100644 --- a/app/views/projects/_home_panel.html.haml +++ b/app/views/projects/_home_panel.html.haml @@ -3,15 +3,17 @@ - emails_disabled = @project.emails_disabled? .project-home-panel.js-show-on-project-root.gl-mt-4.gl-mb-5{ class: [("empty-project" if empty_repo)] } - .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-sm-flex-direction-column.gl-mb-3.gl-gap-5 + .gl-display-flex.gl-justify-content-space-between.gl-flex-wrap.gl-flex-direction-column.gl-md-flex-direction-row.gl-mb-3.gl-gap-5 .home-panel-title-row.gl-display-flex.gl-align-items-center %div{ class: 'avatar-container rect-avatar s64 home-panel-avatar gl-flex-shrink-0 gl-w-11 gl-h-11 gl-mr-3! float-none' } = project_icon(@project, alt: @project.name, class: 'avatar avatar-tile s64', width: 64, height: 64, itemprop: 'image') %div %h1.home-panel-title.gl-font-size-h1.gl-mt-3.gl-mb-2.gl-display-flex.gl-word-break-word{ data: { testid: 'project-name-content' }, itemprop: 'name' } = @project.name - = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-ml-2', icon_css_class: 'icon') - = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-ml-2' + = visibility_level_content(@project, css_class: 'visibility-icon gl-text-secondary gl-mx-2', icon_css_class: 'icon') + = render_if_exists 'compliance_management/compliance_framework/compliance_framework_badge', project: @project, additional_classes: 'gl-align-self-center gl-mx-2' + - if @project.catalog_resource + = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(@project, @project.catalog_resource), css_class: 'gl-mx-2' } - if @project.group = render_if_exists 'shared/tier_badge', source: @project, source_type: 'Project' .home-panel-metadata.gl-font-sm.gl-text-secondary.gl-font-base.gl-font-weight-normal.gl-line-height-normal{ data: { testid: 'project-id-content' }, itemprop: 'identifier' } diff --git a/app/views/projects/_import_project_pane.html.haml b/app/views/projects/_import_project_pane.html.haml index 6315c6dc52d..3e92ef25552 100644 --- a/app/views/projects/_import_project_pane.html.haml +++ b/app/views/projects/_import_project_pane.html.haml @@ -17,7 +17,7 @@ = html_escape(_("Importing GitLab projects? Migrating GitLab projects when migrating groups by direct transfer is in Beta. %{link_start}Learn more.%{link_end}")) % { link_start: docs_link, link_end: '</a>'.html_safe } .import-buttons - if gitlab_project_import_enabled? - .import_gitlab_project.has-tooltip{ data: { container: 'body', qa_selector: 'gitlab_import_button' } } + .import_gitlab_project.has-tooltip{ data: { container: 'body', testid: 'gitlab-import-button' } } = render Pajamas::ButtonComponent.new(href: '#', icon: 'tanuki', button_options: { class: 'btn_import_gitlab_project js-import-project-btn', data: { href: new_import_gitlab_project_path, platform: 'gitlab_export', **tracking_attrs_data(track_label, 'click_button', 'gitlab_export') } }) do = _('GitLab export') diff --git a/app/views/projects/_invite_members_empty_project.html.haml b/app/views/projects/_invite_members_empty_project.html.haml index d6cab06f773..14b0e82e021 100644 --- a/app/views/projects/_invite_members_empty_project.html.haml +++ b/app/views/projects/_invite_members_empty_project.html.haml @@ -4,6 +4,6 @@ = s_('InviteMember|Invite your team') %p= s_('InviteMember|Add members to this project and start collaborating with your team.') .js-invite-members-trigger{ data: { variant: 'confirm', - classes: 'gl-mb-8 gl-xs-w-full', + classes: 'gl-mb-8 gl-w-full gl-sm-w-auto', display_text: s_('InviteMember|Invite members'), trigger_source: 'project_empty_page' } } diff --git a/app/views/projects/_invite_members_modal.html.haml b/app/views/projects/_invite_members_modal.html.haml index a1b0bdd6c56..8713cb4990a 100644 --- a/app/views/projects/_invite_members_modal.html.haml +++ b/app/views/projects/_invite_members_modal.html.haml @@ -3,4 +3,8 @@ .js-invite-members-modal{ data: { is_project: 'true', access_levels: ProjectMember.permissible_access_level_roles(current_user, project).to_json, reload_page_on_submit: current_path?('project_members#index').to_s, - help_link: help_page_url('user/permissions') }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) } + help_link: help_page_url('user/permissions'), + is_signup_enabled: signup_enabled?.to_s, + new_users_url: new_admin_user_url, + is_current_user_admin: current_user&.admin?.to_s, + }.merge(common_invite_modal_dataset(project)).merge(users_filter_data(project.group)) } diff --git a/app/views/projects/_service_desk_settings.html.haml b/app/views/projects/_service_desk_settings.html.haml index 3dbc4c0fad7..aee61624f69 100644 --- a/app/views/projects/_service_desk_settings.html.haml +++ b/app/views/projects/_service_desk_settings.html.haml @@ -1,5 +1,5 @@ - expanded = expanded_by_default? -%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded), data: { qa_selector: 'service_desk_settings_content' } } +%section.settings.js-service-desk-setting-wrapper.no-animate#js-service-desk{ class: ('expanded' if expanded) } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Service Desk') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do @@ -18,6 +18,7 @@ selected_file_template_project_id: "#{@project.service_desk_setting&.file_template_project_id}", outgoing_name: "#{@project.service_desk_setting&.outgoing_name}", project_key: "#{@project.service_desk_setting&.project_key}", + add_external_participants_from_cc: "#{@project.service_desk_setting&.add_external_participants_from_cc}", templates: available_service_desk_templates_for(@project), public_project: "#{@project.public?}", custom_email_endpoint: project_service_desk_custom_email_path(@project) } } diff --git a/app/views/projects/artifacts/_tree_file.html.haml b/app/views/projects/artifacts/_tree_file.html.haml index e120975a8f9..19db01a2df1 100644 --- a/app/views/projects/artifacts/_tree_file.html.haml +++ b/app/views/projects/artifacts/_tree_file.html.haml @@ -1,6 +1,6 @@ - blob = file.blob - external_link = blob.external_link?(@build) -- if external_link +- if external_link && Gitlab::CurrentSettings.enable_artifact_external_redirect_warning_page - path_to_file = external_file_project_job_artifacts_path(@project, @build, path: file.path) - else - path_to_file = file_project_job_artifacts_path(@project, @build, path: file.path) diff --git a/app/views/projects/blob/_editor.html.haml b/app/views/projects/blob/_editor.html.haml index 49a29e1dcb7..0753a021f1f 100644 --- a/app/views/projects/blob/_editor.html.haml +++ b/app/views/projects/blob/_editor.html.haml @@ -12,7 +12,7 @@ = render 'filepath_form', input_options: input_options - if current_action?(:new) || current_action?(:create) - - input_options = { id: 'file_name', name: 'file_name', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : ''), required: true, placeholder: "Filename", testid: 'file_name_field', class: 'new-file-name js-file-path-name-input' } + - input_options = { id: 'file_name', name: 'file_name', value: params[:file_name] || (should_suggest_gitlab_ci_yml? ? '.gitlab-ci.yml' : ''), required: true, placeholder: "Filename", testid: 'file-name-field', class: 'new-file-name js-file-path-name-input' } = render 'filepath_form', input_options: input_options - if should_suggest_gitlab_ci_yml? .js-suggest-gitlab-ci-yml{ data: { track_label: 'suggest_gitlab_ci_yml', @@ -37,7 +37,7 @@ = _("Soft wrap") .file-editor.code - .js-edit-mode-pane#editor{ data: { 'editor-loading': true, qa_selector: 'source_editor_preview_container' } }< + .js-edit-mode-pane#editor{ data: { 'editor-loading': true, testid: 'source-editor-preview-container' } }< %pre.editor-loading-content= params[:content] || local_assigns[:blob_data] - if local_assigns[:path] .js-edit-mode-pane#preview.hide diff --git a/app/views/projects/blob/_pipeline_tour_success.html.haml b/app/views/projects/blob/_pipeline_tour_success.html.haml index f645d23aa1c..be2654c9b86 100644 --- a/app/views/projects/blob/_pipeline_tour_success.html.haml +++ b/app/views/projects/blob/_pipeline_tour_success.html.haml @@ -1,6 +1,6 @@ .js-success-pipeline-modal{ data: { 'commit-cookie': suggest_pipeline_commit_cookie_name, 'go-to-pipelines-path': project_pipelines_path(@project), 'project-merge-requests-path': project_merge_requests_path(@project), - 'example-link': help_page_path('ci/examples/index.md'), + 'example-link': help_page_path('ci/examples/index'), 'code-quality-link': help_page_path('ci/testing/code_quality'), 'human-access': @project.team.human_max_access(current_user&.id) } } diff --git a/app/views/projects/blob/show.html.haml b/app/views/projects/blob/show.html.haml index 82f517e8a84..e8b0f2a6c6f 100644 --- a/app/views/projects/blob/show.html.haml +++ b/app/views/projects/blob/show.html.haml @@ -15,3 +15,6 @@ = render partial: 'pipeline_tour_success' if show_suggest_pipeline_creation_celebration? = render 'shared/web_ide_path' + +-# https://gitlab.com/gitlab-org/gitlab/-/issues/408388#note_1578533983 +#js-ambiguous-ref-modal{ data: { ambiguous: @is_ambiguous_ref.to_s, ref: current_ref } } diff --git a/app/views/projects/blob/viewers/_route_map.html.haml b/app/views/projects/blob/viewers/_route_map.html.haml index 30182c100d5..64122b4dcd4 100644 --- a/app/views/projects/blob/viewers/_route_map.html.haml +++ b/app/views/projects/blob/viewers/_route_map.html.haml @@ -6,4 +6,4 @@ This Route Map is invalid: = viewer.validation_message -= link_to 'Learn more', help_page_path('ci/environments/index.md', anchor: 'go-from-source-files-to-public-pages') += link_to 'Learn more', help_page_path('ci/environments/index', anchor: 'go-from-source-files-to-public-pages') diff --git a/app/views/projects/blob/viewers/_route_map_loading.html.haml b/app/views/projects/blob/viewers/_route_map_loading.html.haml index d9e965246a8..0e5816a56af 100644 --- a/app/views/projects/blob/viewers/_route_map_loading.html.haml +++ b/app/views/projects/blob/viewers/_route_map_loading.html.haml @@ -1,4 +1,4 @@ = gl_loading_icon(inline: true, css_class: "gl-mr-1") Validating Route Map… -= link_to 'Learn more', help_page_path('ci/environments/index.md', anchor: 'go-from-source-files-to-public-pages') += link_to 'Learn more', help_page_path('ci/environments/index', anchor: 'go-from-source-files-to-public-pages') diff --git a/app/views/projects/branch_defaults/_branch_names_fields.html.haml b/app/views/projects/branch_defaults/_branch_names_fields.html.haml index 3e77cb51a85..982280120fa 100644 --- a/app/views/projects/branch_defaults/_branch_names_fields.html.haml +++ b/app/views/projects/branch_defaults/_branch_names_fields.html.haml @@ -10,6 +10,6 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Issue::MAX_BRANCH_TEMPLATE }) - - branch_name_help_link = help_page_path('user/project/repository/branches/index.md', anchor: 'name-your-branch') + - branch_name_help_link = help_page_path('user/project/repository/branches/index', anchor: 'name-your-branch') = link_to _('What variables can I use?'), branch_name_help_link, target: "_blank" = render_if_exists 'projects/branch_defaults/branch_names_help' diff --git a/app/views/projects/branch_defaults/_default_branch_fields.html.haml b/app/views/projects/branch_defaults/_default_branch_fields.html.haml index 2c59e187d30..78ce43ca8c9 100644 --- a/app/views/projects/branch_defaults/_default_branch_fields.html.haml +++ b/app/views/projects/branch_defaults/_default_branch_fields.html.haml @@ -11,7 +11,7 @@ .form-group - help_text = _("When merge requests and commits in the default branch close, any issues they reference also close.") - - help_icon = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues.md', anchor: 'closing-issues-automatically'), target: '_blank', rel: 'noopener noreferrer' + - help_icon = link_to sprite_icon('question-o'), help_page_path('user/project/issues/managing_issues', anchor: 'closing-issues-automatically'), target: '_blank', rel: 'noopener noreferrer' = f.gitlab_ui_checkbox_component :autoclose_referenced_issues, s_('ProjectSettings|Auto-close referenced issues on default branch'), help_text: (help_text + " " + help_icon).html_safe diff --git a/app/views/projects/branch_defaults/_show.html.haml b/app/views/projects/branch_defaults/_show.html.haml index 5906cd34c17..521d5bb9890 100644 --- a/app/views/projects/branch_defaults/_show.html.haml +++ b/app/views/projects/branch_defaults/_show.html.haml @@ -14,4 +14,4 @@ %input{ name: 'update_section', type: 'hidden', value: 'js-issue-settings' } = render 'projects/branch_defaults/default_branch_fields', f: f = render 'projects/branch_defaults/branch_names_fields', f: f - = f.submit _('Save changes'), pajamas_button: true, data: { qa_selector: 'save_changes_button' } + = f.submit _('Save changes'), pajamas_button: true diff --git a/app/views/projects/branch_rules/_show.html.haml b/app/views/projects/branch_rules/_show.html.haml index c16c03953c6..10cb91e35bd 100644 --- a/app/views/projects/branch_rules/_show.html.haml +++ b/app/views/projects/branch_rules/_show.html.haml @@ -3,7 +3,7 @@ - show_status_checks = @project.licensed_feature_available?(:external_status_checks) - show_approvers = @project.licensed_feature_available?(:merge_request_approvers) -%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded), data: { qa_selector: 'branch_rules_content' } } +%section.settings.no-animate#branch-rules{ class: ('expanded' if expanded), data: { testid: 'branch-rules-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Branch rules') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do diff --git a/app/views/projects/branches/_branch.html.haml b/app/views/projects/branches/_branch.html.haml index 61961172eb2..3b9e8e706f9 100644 --- a/app/views/projects/branches/_branch.html.haml +++ b/app/views/projects/branches/_branch.html.haml @@ -4,10 +4,10 @@ - mr_status = merge_request_status(related_merge_request) - is_default_branch = branch.name == @repository.root_ref -%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-5! gl-pr-2!", data: { name: branch.name, qa_selector: 'branch_container', qa_name: branch.name } } +%li{ class: "branch-item gl-display-flex! gl-align-items-center! js-branch-item js-branch-#{branch.name} gl-pl-5! gl-pr-2!", data: { name: branch.name, testid: 'branch-container', qa_name: branch.name } } .branch-info .gl-display-flex.gl-align-items-center - = link_to project_tree_path(@project, branch.name, ref_type: 'heads'), class: 'item-title str-truncated-100 ref-name', data: { qa_selector: 'branch_link' } do + = link_to project_tree_path(@project, branch.name, ref_type: 'heads'), class: 'item-title str-truncated-100 ref-name', data: { testid: 'branch-link' } do = branch.name = clipboard_button(text: branch.name, title: _("Copy branch name")) - if is_default_branch @@ -28,7 +28,7 @@ .pipeline-status.d-none.d-md-block< - if commit_status - = render 'ci/status/icon', size: 16, status: commit_status + = render 'ci/status/icon', status: commit_status - elsif show_commit_status .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-3 %svg.s16 diff --git a/app/views/projects/branches/_panel.html.haml b/app/views/projects/branches/_panel.html.haml index 8ef7d435420..8952ba75568 100644 --- a/app/views/projects/branches/_panel.html.haml +++ b/app/views/projects/branches/_panel.html.haml @@ -12,7 +12,7 @@ %h3.gl-new-card-title.h5 = panel_title - c.with_body do - %ul.content-list.branches-list.all-branches{ data: { qa_selector: 'all_branches_container' } } + %ul.content-list.branches-list.all-branches{ data: { testid: 'all-branches-container' } } - branches.first(overview_max_branches).each do |branch| = render "projects/branches/branch", branch: branch, merged: project.repository.merged_to_root_ref?(branch), commit_status: @branch_pipeline_statuses[branch.name], show_commit_status: @branch_pipeline_statuses.any? - if branches.size > overview_max_branches diff --git a/app/views/projects/ci/builds/_build.html.haml b/app/views/projects/ci/builds/_build.html.haml index 4017db459a9..76d6b0a042d 100644 --- a/app/views/projects/ci/builds/_build.html.haml +++ b/app/views/projects/ci/builds/_build.html.haml @@ -14,7 +14,7 @@ %td.status -# Sending 'status' prevents calling the user relation inside the presenter, generating N+1, -# see https://gitlab.com/gitlab-org/gitlab/-/merge_requests/68743 - = render "ci/status/badge", status: status, title: job.status_title(status) + = render "ci/status/icon", status: status, show_status_text: true %td - if can?(current_user, :read_build, job) @@ -104,10 +104,10 @@ .btn-group - if can?(current_user, :read_job_artifacts, job) && job.artifacts? = link_button_to nil, download_project_job_artifacts_path(job.project, job), rel: 'nofollow', download: '', title: _('Download artifacts'), icon: 'download' + - if can?(current_user, :cancel_build, job) && job.active? + = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel' - if can?(current_user, :update_build, job) - - if job.active? - = link_button_to nil, cancel_project_job_path(job.project, job, continue: { to: request.fullpath }), method: :post, title: _('Cancel'), icon: 'cancel' - - elsif job.scheduled? + - if job.scheduled? = render Pajamas::ButtonComponent.new(disabled: true, icon: 'planning') do %time.js-remaining-time{ datetime: job.scheduled_at.utc.iso8601 } = duration_in_numbers(job.execute_in) @@ -124,7 +124,7 @@ class: 'has-tooltip', icon: 'time-out' - elsif allow_retry - - if job.playable? && !admin && can?(current_user, :update_build, job) + - if job.playable? && !admin = link_button_to nil, play_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Play'), icon: 'play' - elsif job.retryable? = link_button_to nil, retry_project_job_path(job.project, job, return_to: request.original_url), method: :post, title: _('Retry'), icon: 'retry' diff --git a/app/views/projects/cleanup/_show.html.haml b/app/views/projects/cleanup/_show.html.haml index fffa1ff36b9..1d365dbceb8 100644 --- a/app/views/projects/cleanup/_show.html.haml +++ b/app/views/projects/cleanup/_show.html.haml @@ -11,7 +11,7 @@ - link_end = '</a>'.html_safe = _("Clean up after running %{link_start}git filter-repo%{link_end} on the repository.").html_safe % { link_start: link_start, link_end: link_end } = link_to sprite_icon('question-o'), - help_page_path('user/project/repository/reducing_the_repo_size_using_git.md'), + help_page_path('user/project/repository/reducing_the_repo_size_using_git'), target: '_blank', rel: 'noopener noreferrer' .settings-content diff --git a/app/views/projects/commit/_commit_box.html.haml b/app/views/projects/commit/_commit_box.html.haml index e79a91eddaf..42482a773be 100644 --- a/app/views/projects/commit/_commit_box.html.haml +++ b/app/views/projects/commit/_commit_box.html.haml @@ -3,7 +3,7 @@ = render partial: 'signature', object: @commit.signature %strong #{ s_('CommitBoxTitle|Commit') } - %span.commit-sha{ data: { qa_selector: 'commit_sha_content' } }= @commit.short_id + %span.commit-sha{ data: { testid: 'commit-sha-content' } }= @commit.short_id = clipboard_button(text: @commit.id, title: _('Copy commit SHA')) %span.d-none.d-sm-inline= _('authored') #{time_ago_with_tooltip(@commit.authored_date)} @@ -19,7 +19,7 @@ #{time_ago_with_tooltip(@commit.committed_date)} #js-commit-comments-button{ data: { comments_count: @notes_count.to_i } } - = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-xs-w-full gl-xs-mb-3' + = link_button_to _('Browse files'), project_tree_path(@project, @commit), class: 'gl-mr-3 gl-w-full gl-sm-w-auto gl-xs-mb-3' #js-commit-options-dropdown{ data: commit_options_dropdown_data(@project, @commit) } .commit-box{ data: { project_path: project_path(@project) } } diff --git a/app/views/projects/commit/_signature_badge.html.haml b/app/views/projects/commit/_signature_badge.html.haml index 6aefc2eaa8b..d4a775728e3 100644 --- a/app/views/projects/commit/_signature_badge.html.haml +++ b/app/views/projects/commit/_signature_badge.html.haml @@ -17,17 +17,17 @@ - if signature.x509? = render partial: "projects/commit/x509/certificate_details", locals: { signature: signature } - = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/signed_commits/x509.md'), class: 'gl-link gl-display-block') + = link_to(_('Learn more about X.509 signed commits'), help_page_path('user/project/repository/signed_commits/x509'), class: 'gl-link gl-display-block') - elsif signature.ssh? = _('SSH key fingerprint:') %span.gl-font-monospace= signature.key_fingerprint_sha256 || _('Unknown') - = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/signed_commits/ssh.md'), class: 'gl-link gl-display-block gl-mt-3') + = link_to(_('Learn about signing commits with SSH keys.'), help_page_path('user/project/repository/signed_commits/ssh'), class: 'gl-link gl-display-block gl-mt-3') - else = _('GPG Key ID:') %span.gl-font-monospace= signature.gpg_key_primary_keyid - = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/signed_commits/index.md'), class: 'gl-link gl-display-block gl-mt-3') + = link_to(_('Learn about signing commits'), help_page_path('user/project/repository/signed_commits/index'), class: 'gl-link gl-display-block gl-mt-3') %a.signature-badge.gl-display-inline-block.gl-ml-4{ role: 'button', tabindex: 0, data: { toggle: 'popover', html: 'true', placement: 'top', title: title, content: content } } = gl_badge_tag label, variant: variant diff --git a/app/views/projects/commits/_commit.html.haml b/app/views/projects/commits/_commit.html.haml index c42d0fe9931..9f0c910c1c0 100644 --- a/app/views/projects/commits/_commit.html.haml +++ b/app/views/projects/commits/_commit.html.haml @@ -26,7 +26,7 @@ = author_avatar(commit, size: 40, has_tooltip: false) .commit-detail.flex-list.gl-display-flex.gl-justify-content-space-between.gl-align-items-center.gl-flex-grow-1.gl-min-w-0 - .commit-content{ data: { qa_selector: 'commit_content' } } + .commit-content{ data: { testid: 'commit-content' } } - if view_details && merge_request = link_to commit.title, project_commit_path(project, commit.id, merge_request_iid: merge_request.iid), class: ["commit-row-message item-title js-onboarding-commit-item", ("font-italic" if commit.message.empty?)] - else @@ -36,7 +36,7 @@ = commit.short_id - if commit.description? && collapsible = render Pajamas::ButtonComponent.new(icon: 'ellipsis_h', - button_options: { class: 'button-ellipsis-horizontal text-expander js-toggle-button', data: { toggle: 'tooltip', container: 'body' }, :title => _("Toggle commit description"), aria: { label: _("Toggle commit description") }}) + button_options: { class: 'button-ellipsis-horizontal text-expander js-toggle-button', data: { toggle: 'tooltip', container: 'body', collapse_title: _("Toggle commit description"), expand_title: _("Toggle commit description") }, :title => _("Toggle commit description"), aria: { label: _("Toggle commit description") }}) .committer - commit_author_link = commit_author_link(commit, avatar: false, size: 24) diff --git a/app/views/projects/diffs/viewers/_collapsed.html.haml b/app/views/projects/diffs/viewers/_collapsed.html.haml index 578b0af3241..6cffae44084 100644 --- a/app/views/projects/diffs/viewers/_collapsed.html.haml +++ b/app/views/projects/diffs/viewers/_collapsed.html.haml @@ -1,3 +1,4 @@ .nothing-here-block.diff-collapsed{ data: { diff_for_path: collapsed_diff_url(viewer.diff_file) } } = _("This diff is collapsed.") - %button.click-to-expand.gl-button.btn.btn-link= _("Click to expand it.") + = render Pajamas::ButtonComponent.new(variant: :link, button_options: { class: 'click-to-expand' }) do + = _("Click to expand it.") diff --git a/app/views/projects/edit.html.haml b/app/views/projects/edit.html.haml index 4e84a6ef7e7..fd0dc1178f7 100644 --- a/app/views/projects/edit.html.haml +++ b/app/views/projects/edit.html.haml @@ -5,116 +5,119 @@ - reduce_visibility_form_id = 'reduce-visibility-form' - @force_desktop_expanded_sidebar = true -= render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), +- if can?(current_user, :admin_project, @project) + = render Pajamas::AlertComponent.new(title: _('GitLab Pages has moved'), alert_options: { class: 'gl-my-5', data: { feature_id: Users::CalloutsHelper::PAGES_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - - c.with_body do - = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe} - -%section.settings.general-settings.no-animate.expanded#js-general-settings - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = _('Collapse') - %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') - .settings-content= render 'projects/settings/general' - -%section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { testid: 'visibility-features-permissions-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.') - - .settings-content - = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| - %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } - %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe - .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } -- if show_merge_request_settings_callout?(@project) - %section.settings.expanded - = render Pajamas::AlertComponent.new(variant: :info, + - c.with_body do + = _('To go to GitLab Pages, on the left sidebar, select %{pages_link}.').html_safe % {pages_link: link_to('Deploy > Pages', project_pages_path(@project)).html_safe} + + %section.settings.general-settings.no-animate.expanded#js-general-settings + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Naming, topics, avatar') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = _('Collapse') + %p.gl-text-secondary= _('Update your project name, topics, description, and avatar.') + .settings-content= render 'projects/settings/general' + + %section.settings.sharing-permissions.no-animate#js-shared-permissions{ class: ('expanded' if expanded), data: { testid: 'visibility-features-permissions-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Visibility, project features, permissions') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary= _('Choose visibility level, enable/disable project features and their permissions, disable email notifications, and show default emoji reactions.') + + .settings-content + = form_for @project, html: { multipart: true, class: "sharing-permissions-form", id: reduce_visibility_form_id }, authenticity_token: true do |f| + %input{ name: 'update_section', type: 'hidden', value: 'js-shared-permissions' } + %template.js-project-permissions-form-data{ type: "application/json" }= project_permissions_panel_data(@project).to_json.html_safe + .js-project-permissions-form{ data: visibility_confirm_modal_data(@project, reduce_visibility_form_id) } + - if show_merge_request_settings_callout?(@project) + %section.settings.expanded + = render Pajamas::AlertComponent.new(variant: :info, title: _('Merge requests and approvals settings have moved.'), alert_options: { class: 'js-merge-request-settings-callout gl-my-5', data: { feature_id: Users::CalloutsHelper::MERGE_REQUEST_SETTINGS_MOVED_CALLOUT, dismiss_endpoint: callouts_path, defer_links: 'true' } }) do |c| - - c.with_body do - = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } - -%section.settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'badges-settings-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only - = s_('ProjectSettings|Badges') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary - = s_('ProjectSettings|Customize this project\'s badges.') - = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') - .settings-content - = render 'shared/badges/badge_settings' - -= render_if_exists 'compliance_management/compliance_framework/project_settings', expanded: expanded - -= render_if_exists 'projects/settings/default_issue_template' - -= render 'projects/service_desk_settings' - -%section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } - .settings-header - %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do - = expanded ? _('Collapse') : _('Expand') - %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') - - .settings-content - = render_if_exists 'projects/settings/restore', project: @project - - = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - - c.with_header do - .gl-new-card-title-wrapper - %h4.gl-new-card-title= _('Housekeeping') - %p.gl-new-card-description - = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') - = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' - - - c.with_body do - .gl-display-flex.gl-flex-wrap.gl-gap-3 - = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do - = _('Run housekeeping') - #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } - - = render 'export', project: @project - - = render_if_exists 'projects/settings/archive' - - = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| - - c.with_header do - .gl-new-card-title-wrapper - %h4.gl-new-card-title.warning-title= _('Change path') - %p.gl-new-card-description - - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') - = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) - - - c.with_body do - = render 'projects/errors' - = gitlab_ui_form_for @project do |f| - .form-group - %p - %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") - = _('You will need to update your local repositories to point to the new location.') - - if @project.deployment_platform.present? - %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') - = f.label :path, _('Path'), class: 'label-bold' + - c.with_body do + = _('On the left sidebar, select %{merge_requests_link} to view them.').html_safe % { merge_requests_link: link_to('Settings > Merge requests', project_settings_merge_requests_path(@project)).html_safe } + + %section.settings.no-animate{ class: ('expanded' if expanded), data: { testid: 'badges-settings-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only + = s_('ProjectSettings|Badges') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary + = s_('ProjectSettings|Customize this project\'s badges.') + = link_to s_('ProjectSettings|What are badges?'), help_page_path('user/project/badges') + .settings-content + = render 'shared/badges/badge_settings' + + = render_if_exists 'compliance_management/compliance_framework/project_settings', expanded: expanded + + = render_if_exists 'projects/settings/default_issue_template' + + = render 'projects/service_desk_settings' + + %section.settings.advanced-settings.no-animate#js-project-advanced-settings{ class: ('expanded' if expanded), data: { testid: 'advanced-settings-content' } } + .settings-header + %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Advanced') + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do + = expanded ? _('Collapse') : _('Expand') + %p.gl-text-secondary= s_('ProjectSettings|Housekeeping, export, archive, change path, transfer, and delete.') + + .settings-content + = render_if_exists 'projects/settings/restore', project: @project + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card gl-mt-0' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title= _('Housekeeping') + %p.gl-new-card-description + = _('Runs a number of housekeeping tasks within the current repository, such as compressing file revisions and removing unreachable objects.') + = link_to _('Learn more.'), help_page_path('administration/housekeeping'), target: '_blank', rel: 'noopener noreferrer' + + - c.with_body do + .gl-display-flex.gl-flex-wrap.gl-gap-3 + = render Pajamas::ButtonComponent.new(method: :post, href: housekeeping_project_path(@project)) do + = _('Run housekeeping') + #js-project-prune-unreachable-objects-button{ data: { prune_objects_path: housekeeping_project_path(@project, prune: true), prune_objects_doc_path: help_page_path('administration/housekeeping', anchor: 'prune-unreachable-objects') } } + + = render 'export', project: @project + + = render_if_exists 'projects/settings/archive' + + = render Pajamas::CardComponent.new(card_options: { class: 'gl-new-card rename-repository' }, header_options: { class: 'gl-new-card-header gl-flex-direction-column' }, body_options: { class: 'gl-new-card-body gl-px-5 gl-py-4' }) do |c| + - c.with_header do + .gl-new-card-title-wrapper + %h4.gl-new-card-title.warning-title= _('Change path') + %p.gl-new-card-description + - link = link_to('', help_page_path('user/project/settings/index', anchor: 'rename-a-repository'), target: '_blank', rel: 'noopener noreferrer') + = safe_format(_("A project’s repository name defines its URL (the one you use to access the project via a browser) and its place on the file disk where GitLab is installed. %{link_start}Learn more.%{link_end}"), tag_pair(link, :link_start, :link_end)) + + - c.with_body do + = render 'projects/errors' + = gitlab_ui_form_for @project do |f| .form-group - .input-group - .input-group-prepend - .input-group-text - #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ - = f.text_field :path, class: 'form-control gl-form-input-xl', data: { testid: 'project-path-field' } - = f.submit _('Change path'), class: "btn-danger", data: { testid: 'change-path-button' }, pajamas_button: true - - = render 'transfer', project: @project - - = render 'remove_fork', project: @project - - = render 'remove', project: @project + %p + %span.gl-font-weight-bold= _("Be careful. Renaming a project's repository can have unintended side effects.") + = _('You will need to update your local repositories to point to the new location.') + - if @project.deployment_platform.present? + %p= _('Your deployment services will be broken, you will need to manually fix the services after renaming.') + = f.label :path, _('Path'), class: 'label-bold' + .form-group + .input-group + .input-group-prepend + .input-group-text + #{Gitlab::Utils.append_path(root_url, @project.namespace.full_path)}/ + = f.text_field :path, class: 'form-control gl-form-input-xl', data: { testid: 'project-path-field' } + = f.submit _('Change path'), class: "btn-danger", data: { testid: 'change-path-button' }, pajamas_button: true + + = render 'transfer', project: @project + + = render 'remove_fork', project: @project + + = render 'remove', project: @project +- elsif can?(current_user, :archive_project, @project) + = render_if_exists 'projects/settings/archive' .save-project-loader.hide .center diff --git a/app/views/projects/environments/index.html.haml b/app/views/projects/environments/index.html.haml index 7ddaf868a35..c2bea4bf43c 100644 --- a/app/views/projects/environments/index.html.haml +++ b/app/views/projects/environments/index.html.haml @@ -5,7 +5,7 @@ "can-read-environment" => can?(current_user, :read_environment, @project).to_s, "can-create-environment" => can?(current_user, :create_environment, @project).to_s, "new-environment-path" => new_project_environment_path(@project), - "help-page-path" => help_page_path("ci/environments/index.md"), + "help-page-path" => help_page_path("ci/environments/index"), "project-path" => @project.full_path, "project-id" => @project.id, "default-branch-name" => @project.default_branch_or_main, diff --git a/app/views/projects/feature_flags/new.html.haml b/app/views/projects/feature_flags/new.html.haml index 3a32a249d1e..1f723cb96b0 100644 --- a/app/views/projects/feature_flags/new.html.haml +++ b/app/views/projects/feature_flags/new.html.haml @@ -10,5 +10,5 @@ user_callout_id: Users::CalloutsHelper::FEATURE_FLAGS_NEW_VERSION, show_user_callout: show_feature_flags_new_version?.to_s, strategy_type_docs_page_path: help_page_path('operations/feature_flags', anchor: 'feature-flag-strategies'), - environments_scope_docs_path: help_page_path('ci/environments/index.md', anchor: 'limit-the-environment-scope-of-a-cicd-variable'), + environments_scope_docs_path: help_page_path('ci/environments/index', anchor: 'limit-the-environment-scope-of-a-cicd-variable'), project_id: @project.id } } diff --git a/app/views/projects/feature_flags_user_lists/edit.html.haml b/app/views/projects/feature_flags_user_lists/edit.html.haml index 417b6354ec0..70d614fc327 100644 --- a/app/views/projects/feature_flags_user_lists/edit.html.haml +++ b/app/views/projects/feature_flags_user_lists/edit.html.haml @@ -3,6 +3,6 @@ - breadcrumb_title s_('FeatureFlags|Edit User List') - page_title s_('FeatureFlags|Edit User List') -#js-edit-user-list{ data: { 'user-lists-docs-path' => help_page_path('operations/feature_flags.md', anchor: 'user-list'), +#js-edit-user-list{ data: { 'user-lists-docs-path' => help_page_path('operations/feature_flags', anchor: 'user-list'), 'user-list-iid' => @user_list.iid, 'project-id' => @project.id } } diff --git a/app/views/projects/feature_flags_user_lists/new.html.haml b/app/views/projects/feature_flags_user_lists/new.html.haml index cea55c0ca2a..7f20fc4a9ec 100644 --- a/app/views/projects/feature_flags_user_lists/new.html.haml +++ b/app/views/projects/feature_flags_user_lists/new.html.haml @@ -4,6 +4,6 @@ - breadcrumb_title s_('FeatureFlags|New User List') - page_title s_('FeatureFlags|New User List') -#js-new-user-list{ data: { 'user-lists-docs-path' => help_page_path('operations/feature_flags.md', anchor: 'user-list'), +#js-new-user-list{ data: { 'user-lists-docs-path' => help_page_path('operations/feature_flags', anchor: 'user-list'), 'feature-flags-path' => project_feature_flags_path(@project), 'project-id' => @project.id } } diff --git a/app/views/projects/find_file/show.html.haml b/app/views/projects/find_file/show.html.haml index 0c760ab82c9..997e7b7f24d 100644 --- a/app/views/projects/find_file/show.html.haml +++ b/app/views/projects/find_file/show.html.haml @@ -6,7 +6,7 @@ - blob_path = project_blob_path(@project, @ref) .file-finder-holder.tree-holder.clearfix.js-file-finder.gl-pt-4{ data: { file_find_url: "#{escape_javascript(project_files_path(@project, @ref, ref_type: @ref_type, format: :json))}", find_tree_url: escape_javascript(tree_path), blob_url_template: escape_javascript(blob_path), ref_type: @ref_type } } .nav-block.gl-xs-mr-0 - .tree-ref-holder.gl-xs-mb-3.gl-xs-w-full.gl-max-w-26 + .tree-ref-holder.gl-xs-mb-3.gl-max-w-26 #js-blob-ref-switcher{ data: { project_id: @project.id, ref: @ref, ref_type: @ref_type, namespace: "/-/find_file" } } %ul.breadcrumb.repo-breadcrumb.gl-flex-nowrap %li.breadcrumb-item.gl-white-space-nowrap diff --git a/app/views/projects/forks/index.html.haml b/app/views/projects/forks/index.html.haml index 49047749b71..fe7d2c9d198 100644 --- a/app/views/projects/forks/index.html.haml +++ b/app/views/projects/forks/index.html.haml @@ -8,7 +8,7 @@ - full_count_title = "#{@public_forks_count} public, #{@internal_forks_count} internal, and #{@private_forks_count} private" #{pluralize(@total_forks_count, 'fork')}: #{full_count_title} - .gl-display-flex.gl-sm-flex-direction-column.gl-md-align-items-center + .gl-display-flex.gl-flex-direction-column.gl-md-flex-direction-row.gl-md-align-items-center = form_tag request.original_url, method: :get, class: 'project-filter-form gl-display-flex gl-mt-3 gl-md-mt-0', id: 'project-filter-form' do |f| = search_field_tag :filter_projects, nil, placeholder: _('Search forks'), class: 'projects-list-filter project-filter-form-field form-control input-short gl-flex-grow-1', spellcheck: false, data: { 'filter-selector' => 'span.namespace-name' } diff --git a/app/views/projects/forks/new.html.haml b/app/views/projects/forks/new.html.haml index e9c6b3fcd22..1194a361753 100644 --- a/app/views/projects/forks/new.html.haml +++ b/app/views/projects/forks/new.html.haml @@ -9,6 +9,7 @@ project_id: @project.id, project_name: @project.name, project_path: @project.path, + project_default_branch: @project.default_branch, project_description: @project.description, project_visibility: @project.visibility, restricted_visibility_levels: Gitlab::CurrentSettings.restricted_visibility_levels.to_json } } diff --git a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml index a3569d41714..e766536f12b 100644 --- a/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml +++ b/app/views/projects/generic_commit_statuses/_generic_commit_status.html.haml @@ -7,7 +7,7 @@ %tr.generic-commit-status{ class: ('retried' if retried) } %td.status - = render 'ci/status/badge', status: generic_commit_status.detailed_status(current_user) + = render 'ci/status/icon', status: generic_commit_status.detailed_status(current_user), show_status_text: true %td = generic_commit_status.name diff --git a/app/views/projects/issues/_new_branch.html.haml b/app/views/projects/issues/_new_branch.html.haml index 90d99d51d29..68de9c44e38 100644 --- a/app/views/projects/issues/_new_branch.html.haml +++ b/app/views/projects/issues/_new_branch.html.haml @@ -43,7 +43,7 @@ %li.droplab-item-ignore.gl-ml-3.gl-mr-3.gl-mt-5 - if can_create_confidential_merge_request? - #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index.md') } } + #js-forked-project{ data: { namespace_path: @project.namespace.full_path, project_path: @project.full_path, new_fork_path: new_project_fork_path(@project), help_page_path: help_page_path('user/project/merge_requests/index') } } .form-group %label{ for: 'new-branch-name' } = _('Branch name') diff --git a/app/views/projects/issues/_related_branches.html.haml b/app/views/projects/issues/_related_branches.html.haml index 21f1a4d19fa..1a6edb288b5 100644 --- a/app/views/projects/issues/_related_branches.html.haml +++ b/app/views/projects/issues/_related_branches.html.haml @@ -16,9 +16,9 @@ %li.list-item{ class: "gl-py-0! gl-border-0!" } .item-body.gl-display-flex.align-items-center.gl-px-3.gl-pr-2.gl-mx-n2 .item-contents.gl-display-flex.gl-align-items-center.gl-flex-wrap.gl-flex-grow-1.gl-min-h-7 - .item-title.gl-display-flex.mb-xl-0.gl-min-w-0 + .item-title.gl-display-flex.mb-xl-0.gl-min-w-0.gl-align-items-center - if branch[:pipeline_status].present? - %span.related-branch-ci-status + %span.gl-mt-n2.gl-mb-n2.gl-mr-3 = render 'ci/status/icon', status: branch[:pipeline_status] %span.related-branch-info %strong diff --git a/app/views/projects/issues/service_desk/_issue.html.haml b/app/views/projects/issues/service_desk/_issue.html.haml index 66b2eabac9d..dbc6e613e8b 100644 --- a/app/views/projects/issues/service_desk/_issue.html.haml +++ b/app/views/projects/issues/service_desk/_issue.html.haml @@ -1,4 +1,4 @@ -%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id, qa_selector: 'issue_container', qa_issue_title: issue.title } } +%li{ id: dom_id(issue), class: issue_css_classes(issue), url: issue_path(issue), data: { labels: issue.label_ids, id: issue.id } } .issuable-info-container .issuable-main-info .issue-title.title diff --git a/app/views/projects/issues/service_desk/_issue_estimate.html.haml b/app/views/projects/issues/service_desk/_issue_estimate.html.haml index c49bf626f4e..c6fa8b64dec 100644 --- a/app/views/projects/issues/service_desk/_issue_estimate.html.haml +++ b/app/views/projects/issues/service_desk/_issue_estimate.html.haml @@ -1,7 +1,7 @@ - issue = local_assigns.fetch(:issue) - if issue.time_estimate > 0 - %span.issuable-estimate.d-none.d-sm-inline-block.has-tooltip{ data: { container: 'body', qa_selector: 'issuable_estimate' }, title: _('Estimate') } + %span.issuable-estimate.d-none.d-sm-inline-block.has-tooltip{ data: { container: 'body' }, title: _('Estimate') } = sprite_icon('timer', css_class: 'issue-estimate-icon') = Gitlab::TimeTrackingFormatter.output(issue.time_estimate) diff --git a/app/views/projects/jobs/_header.html.haml b/app/views/projects/jobs/_header.html.haml index 018ff093475..a77e8f2d0b4 100644 --- a/app/views/projects/jobs/_header.html.haml +++ b/app/views/projects/jobs/_header.html.haml @@ -2,7 +2,7 @@ .content-block.build-header.top-area.page-content-header .header-content - = render 'ci/status/badge', status: @build.detailed_status(current_user), link: false, title: @build.status_title + = render 'ci/status/icon', status: @build.detailed_status(current_user), show_status_text: true %strong Job = link_to "##{@build.id}", project_job_path(@project, @build), class: 'js-build-id' diff --git a/app/views/projects/merge_requests/_nav_btns.html.haml b/app/views/projects/merge_requests/_nav_btns.html.haml index 80085cc6a34..2c0a8d831e4 100644 --- a/app/views/projects/merge_requests/_nav_btns.html.haml +++ b/app/views/projects/merge_requests/_nav_btns.html.haml @@ -9,12 +9,9 @@ = _("New merge request") .dropdown.gl-dropdown - = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md gl-button gl-dropdown gl-dropdown-toggle btn-default-tertiary dropdown-icon-only dropdown-toggle-no-caret has-tooltip gl-display-none! gl-md-display-inline-flex!", data: { toggle: 'dropdown', title: _('Actions') } do - = sprite_icon "ellipsis_v", size: 16, css_class: "dropdown-icon gl-icon" - %span.gl-sr-only - = _('Actions') - = button_tag type: 'button', class: "btn dropdown-toggle btn-default btn-md btn-block gl-button gl-dropdown-toggle gl-md-display-none!", data: { 'toggle' => 'dropdown' } do - %span.gl-dropdown-button-text= _('Actions') + = render Pajamas::ButtonComponent.new(type: :button, category: :tertiary, variant: :default, icon: 'ellipsis_v', button_options: { data: { toggle: 'dropdown' }, class: 'has-tooltip gl-display-none! gl-md-display-inline-flex!', title: _("Actions")}) + = render Pajamas::ButtonComponent.new(type: :button, variant: :default, button_options: { data: { 'toggle' => 'dropdown' }, class: 'gl-md-display-none!'}) do + = _('Actions') = sprite_icon "chevron-down", size: 16, css_class: "dropdown-icon gl-icon" .dropdown-menu.dropdown-menu-right .gl-dropdown-inner diff --git a/app/views/projects/merge_requests/_page.html.haml b/app/views/projects/merge_requests/_page.html.haml index 637980bd2f8..03a1f2f3179 100644 --- a/app/views/projects/merge_requests/_page.html.haml +++ b/app/views/projects/merge_requests/_page.html.haml @@ -6,7 +6,7 @@ - page_title "#{@merge_request.title} (#{@merge_request.to_reference})", _("Merge requests") - page_description @merge_request.description_html - page_card_attributes @merge_request.card_attributes -- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions.md') +- suggest_changes_help_path = help_page_path('user/project/merge_requests/reviews/suggestions') - mr_action = j(params[:tab].presence || 'show') - add_page_specific_style 'page_bundles/issuable' - add_page_specific_style 'page_bundles/design_management' diff --git a/app/views/projects/mirrors/_authentication_method.html.haml b/app/views/projects/mirrors/_authentication_method.html.haml index c4cf128a62a..f72b0d582b7 100644 --- a/app/views/projects/mirrors/_authentication_method.html.haml +++ b/app/views/projects/mirrors/_authentication_method.html.haml @@ -5,7 +5,7 @@ = f.label :auth_method, _('Authentication method'), class: 'label-bold' = f.select :auth_method, options_for_select(auth_options, mirror.auth_method), - {}, { class: "custom-select gl-form-select js-mirror-auth-type gl-max-w-34 gl-display-block", data: { qa_selector: 'authentication_method_field' } } + {}, { class: "custom-select gl-form-select js-mirror-auth-type gl-max-w-34 gl-display-block", data: { testid: 'authentication-method-field' } } = f.hidden_field :auth_method, value: "password", class: "js-hidden-mirror-auth-type" .form-group diff --git a/app/views/projects/mirrors/_branch_filter.html.haml b/app/views/projects/mirrors/_branch_filter.html.haml index 7d90906bfe8..39e82fd5711 100644 --- a/app/views/projects/mirrors/_branch_filter.html.haml +++ b/app/views/projects/mirrors/_branch_filter.html.haml @@ -6,4 +6,4 @@ = _('Mirror only protected branches') - c.with_help_text do = _('If enabled, only protected branches will be mirrored.') - = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index.md', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('user/project/repository/mirror/index', anchor: 'mirror-only-protected-branches'), target: '_blank', rel: 'noopener noreferrer' diff --git a/app/views/projects/mirrors/_mirror_repos.html.haml b/app/views/projects/mirrors/_mirror_repos.html.haml index 00837ce1c73..7b27062f782 100644 --- a/app/views/projects/mirrors/_mirror_repos.html.haml +++ b/app/views/projects/mirrors/_mirror_repos.html.haml @@ -3,14 +3,14 @@ - mirror_settings_enabled = can?(current_user, :admin_remote_mirror, @project) - mirror_settings_class = "#{'expanded' if expanded} #{'js-mirror-settings' if mirror_settings_enabled}".strip -%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { qa_selector: 'mirroring_repositories_settings_content' } } +%section.settings.project-mirror-settings.no-animate#js-push-remote-settings{ class: mirror_settings_class, data: { testid: 'mirroring-repositories-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= _('Mirroring repositories') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do = expanded ? _('Collapse') : _('Expand') %p.gl-text-secondary = _('Set up your project to automatically push and/or pull changes to/from another repository. Branches, tags, and commits will be synced automatically.') - = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('How do I mirror repositories?'), help_page_path('user/project/repository/mirror/index'), target: '_blank', rel: 'noopener noreferrer' .settings-content @@ -35,7 +35,7 @@ %div= form_errors(@project) .form-group.has-feedback = label_tag :url, _('Git repository URL'), class: 'label-light' - = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url gl-form-input-xl', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { qa_selector: 'mirror_repository_url_field' } + = text_field_tag :url, nil, class: 'form-control gl-form-input js-mirror-url js-repo-url gl-form-input-xl', placeholder: _('Input the remote repository URL'), required: true, pattern: "(#{protocols}):\/\/.+", autocomplete: 'new-password', data: { testid: 'mirror-repository-url-field' } = render 'projects/mirrors/instructions' @@ -43,7 +43,7 @@ = render 'projects/mirrors/branch_filter' - = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { qa_selector: 'mirror_repository_button' } + = f.submit _('Mirror repository'), class: 'js-mirror-submit', name: :update_remote_mirror, pajamas_button: true, data: { testid: 'mirror-repository-button' } = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/projects/mirrors/_mirror_repos_form.html.haml b/app/views/projects/mirrors/_mirror_repos_form.html.haml index 8378a74311f..24cda3445de 100644 --- a/app/views/projects/mirrors/_mirror_repos_form.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_form.html.haml @@ -1,7 +1,7 @@ .form-group = label_tag :mirror_direction, _('Mirror direction'), class: 'label-light' .select-wrapper - = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction gl-max-w-34 gl-display-block', disabled: true, data: { qa_selector: 'mirror_direction_field' } + = select_tag :mirror_direction, options_for_select([[_('Push'), 'push']]), class: 'form-control gl-form-select select-control js-mirror-direction gl-max-w-34 gl-display-block', disabled: true, data: { testid: 'mirror-direction-field' } = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") = render partial: "projects/mirrors/mirror_repos_push", locals: { f: f } diff --git a/app/views/projects/mirrors/_mirror_repos_list.html.haml b/app/views/projects/mirrors/_mirror_repos_list.html.haml index 59611db941f..5e3c4889d1d 100644 --- a/app/views/projects/mirrors/_mirror_repos_list.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_list.html.haml @@ -17,24 +17,24 @@ = render_if_exists 'projects/mirrors/table_pull_row' - @project.remote_mirrors.each_with_index do |mirror, index| - next if mirror.new_record? - %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { qa_selector: 'mirrored_repository_row_container' } } - %td{ data: { qa_selector: 'mirror_repository_url_content' } } + %tr.rspec-mirrored-repository-row{ class: ('bg-secondary' if mirror.disabled?), data: { testid: 'mirrored-repository-row-container' } } + %td{ data: { testid: 'mirror-repository-url-content' } } = mirror.safe_url || _('Invalid URL') = render_if_exists 'projects/mirrors/mirror_branches_setting_badge', record: mirror %td= _('Push') %td = mirror.last_update_started_at.present? ? time_ago_with_tooltip(mirror.last_update_started_at) : _('Never') - %td{ data: { qa_selector: 'mirror_last_update_at_content' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') + %td{ data: { testid: 'mirror-last-update-at-content' } }= mirror.last_update_at.present? ? time_ago_with_tooltip(mirror.last_update_at) : _('Never') %td - if mirror.disabled? = render 'projects/mirrors/disabled_mirror_badge' - if mirror.last_error.present? - = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', qa_selector: 'mirror_error_badge_content' }, title: html_escape(mirror.last_error.try(:strip)) } + = gl_badge_tag _('Error'), { variant: :danger }, { data: { toggle: 'tooltip', html: 'true', testid: 'mirror-error-badge-content' }, title: html_escape(mirror.last_error.try(:strip)) } %td - if mirror_settings_enabled .btn-group.mirror-actions-group{ role: 'group' } - if mirror.ssh_key_auth? - = clipboard_button(text: mirror.ssh_public_key, variant: :default, category: :primary, size: :medium, title: _('Copy SSH public key'), testid: 'copy_public_key_button') + = clipboard_button(text: mirror.ssh_public_key, variant: :default, category: :primary, size: :medium, title: _('Copy SSH public key'), testid: 'copy-public-key-button') = render 'shared/remote_mirror_update_button', remote_mirror: mirror = render Pajamas::ButtonComponent.new(variant: :danger, icon: 'remove', diff --git a/app/views/projects/mirrors/_mirror_repos_push.html.haml b/app/views/projects/mirrors/_mirror_repos_push.html.haml index 5b02d650989..7f0298191cd 100644 --- a/app/views/projects/mirrors/_mirror_repos_push.html.haml +++ b/app/views/projects/mirrors/_mirror_repos_push.html.haml @@ -16,4 +16,4 @@ = _('Keep divergent refs') - c.with_help_text do - link_opening_tag = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe - = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push.md', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } + = html_escape(_('Do not force push over diverged refs. After the mirror is created, this setting can only be modified using the API. %{mirroring_docs_link_start}Learn more about this option%{link_closing_tag} and %{mirroring_api_docs_link_start}the API.%{link_closing_tag}')) % { mirroring_docs_link_start: link_opening_tag % {url: help_page_path('user/project/repository/mirror/push', anchor: 'keep-divergent-refs')}, mirroring_api_docs_link_start: link_opening_tag % {url: help_page_path('api/remote_mirrors')}, link_closing_tag: '</a>'.html_safe } diff --git a/app/views/projects/mirrors/_ssh_host_keys.html.haml b/app/views/projects/mirrors/_ssh_host_keys.html.haml index d367f383e5a..cd9580d15e9 100644 --- a/app/views/projects/mirrors/_ssh_host_keys.html.haml +++ b/app/views/projects/mirrors/_ssh_host_keys.html.haml @@ -3,13 +3,13 @@ - verified_at = mirror.ssh_known_hosts_verified_at .form-group.js-ssh-host-keys-section{ class: ('collapse' unless mirror.ssh_mirror_url?) } - = render Pajamas::ButtonComponent.new(button_options: { class: 'js-detect-host-keys gl-mr-3', data: { qa_selector: 'detect_host_keys' } }) do + = render Pajamas::ButtonComponent.new(button_options: { class: 'js-detect-host-keys gl-mr-3', data: { testid: 'detect-host-keys' } }) do = gl_loading_icon(inline: true, css_class: 'js-spinner gl-display-none gl-mr-2') = _('Detect host keys') .fingerprint-ssh-info.js-fingerprint-ssh-info.gl-mt-3.gl-mb-3{ class: ('collapse' unless mirror.ssh_mirror_url?) } %label.label-bold = _('Fingerprints') - .fingerprints-list.js-fingerprints-list{ data: { qa_selector: 'fingerprints_list' } } + .fingerprints-list.js-fingerprints-list{ data: { testid: 'fingerprints-list' } } - mirror.ssh_known_hosts_fingerprints.each do |fp| %code= fp.fingerprint_sha256 || fp.fingerprint - if verified_at diff --git a/app/views/projects/ml/model_versions/show.html.haml b/app/views/projects/ml/model_versions/show.html.haml new file mode 100644 index 00000000000..0b3d5462a89 --- /dev/null +++ b/app/views/projects/ml/model_versions/show.html.haml @@ -0,0 +1,6 @@ +- add_to_breadcrumbs s_('ModelRegistry|Model registry'), project_ml_models_path(@project) +- add_to_breadcrumbs @model_version.name, project_ml_model_path(@project, @model) +- breadcrumb_title @model_version.version +- page_title "#{@model_version.name} / #{@model_version.version}" + += render(Projects::Ml::ShowMlModelVersionComponent.new(model_version: @model_version)) diff --git a/app/views/projects/ml/models/index.html.haml b/app/views/projects/ml/models/index.html.haml index 08f0db257ae..ffe7ee3397e 100644 --- a/app/views/projects/ml/models/index.html.haml +++ b/app/views/projects/ml/models/index.html.haml @@ -1,4 +1,4 @@ - breadcrumb_title s_('ModelRegistry|Model registry') - page_title s_('ModelRegistry|Model registry') -= render(Projects::Ml::ModelsIndexComponent.new(paginator: @paginator)) += render(Projects::Ml::ModelsIndexComponent.new(paginator: @paginator, model_count: @model_count)) diff --git a/app/views/projects/pages/_access.html.haml b/app/views/projects/pages/_access.html.haml index 6eab31075d4..1e18e528665 100644 --- a/app/views/projects/pages/_access.html.haml +++ b/app/views/projects/pages/_access.html.haml @@ -1,7 +1,7 @@ - if @project.pages_deployed? - pages_url = build_pages_url(@project, with_unique_domain: true) - = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { qa_selector: 'access_page_container' } }, footer_options: { class: 'gl-alert-warning' }) do |c| + = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-5', data: { testid: 'access-page-container' } }, footer_options: { class: 'gl-alert-warning' }) do |c| - c.with_header do = s_('GitLabPages|Access pages') - c.with_body do diff --git a/app/views/projects/pages/_waiting.html.haml b/app/views/projects/pages/_waiting.html.haml index 0613ffc4809..7aad6d6e0d2 100644 --- a/app/views/projects/pages/_waiting.html.haml +++ b/app/views/projects/pages/_waiting.html.haml @@ -5,7 +5,7 @@ .row.gl-align-items-center.gl-justify-content-center .text-content.gl-text-center.order-md-1 %h4= s_("GitLabPages|Waiting for the Pages Pipeline to complete...") - %p= s_("GitLabPages|Your Project has been configured for Pages. Now we have to wait for the Pipeline to succeed for the first time.") + %p= s_("GitLabPages|Your project is configured for GitLab Pages and the pipeline is running...") = render Pajamas::ButtonComponent.new(variant: :confirm, href: project_pipelines_path(@project)) do = s_("GitLabPages|Check the Pipeline Status") = render Pajamas::ButtonComponent.new(href: new_namespace_project_pages_path) do diff --git a/app/views/projects/pages/new.html.haml b/app/views/projects/pages/new.html.haml index 89f8f62ea83..56dfc69d740 100644 --- a/app/views/projects/pages/new.html.haml +++ b/app/views/projects/pages/new.html.haml @@ -1,10 +1,5 @@ - @breadcrumb_link = project_pages_path(@project) - page_title s_('GitLabPages|Pages') -- if Feature.enabled?(:use_pipeline_wizard_for_pages, @project.group) - #js-pages{ data: @pipeline_wizard_data } -- else - = render 'header' - - = render 'use' +#js-pages{ data: @pipeline_wizard_data } diff --git a/app/views/projects/pages_domains/_certificate.html.haml b/app/views/projects/pages_domains/_certificate.html.haml index f80fd495695..1136abe9884 100644 --- a/app/views/projects/pages_domains/_certificate.html.haml +++ b/app/views/projects/pages_domains/_certificate.html.haml @@ -21,7 +21,7 @@ label_position: :hidden) = f.hidden_field :auto_ssl_enabled?, class: "js-project-feature-toggle-input" %p.gl-text-secondary.gl-mt-1 - - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration.md") + - docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/lets_encrypt_integration") - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } - docs_link_end = "</a>".html_safe = _("Let's Encrypt is a free, automated, and open certificate authority (CA) that gives digital certificates in order to enable HTTPS (SSL/TLS) for websites. Learn more about Let's Encrypt configuration by following the %{docs_link_start}documentation on GitLab Pages%{docs_link_end}.").html_safe % { docs_link_url: docs_link_url, docs_link_start: docs_link_start, docs_link_end: docs_link_end } diff --git a/app/views/projects/pages_domains/_dns.html.haml b/app/views/projects/pages_domains/_dns.html.haml index 9ca9360199d..bec35dba147 100644 --- a/app/views/projects/pages_domains/_dns.html.haml +++ b/app/views/projects/pages_domains/_dns.html.haml @@ -27,5 +27,5 @@ .input-group-append = deprecated_clipboard_button(target: '#domain_verification', class: 'btn-default d-none d-sm-block') %p.form-text.text-muted - - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index.md', anchor: '4-verify-the-domains-ownership')) + - link_to_help = link_to(_('verify ownership'), help_page_path('user/project/pages/custom_domains_ssl_tls_certification/index', anchor: '4-verify-the-domains-ownership')) = _("To %{link_to_help} of your domain, add the above key to a TXT record within your DNS configuration within seven days.").html_safe % { link_to_help: link_to_help } diff --git a/app/views/projects/pages_domains/_helper_text.html.haml b/app/views/projects/pages_domains/_helper_text.html.haml index f29cb0609e6..4ad341c1394 100644 --- a/app/views/projects/pages_domains/_helper_text.html.haml +++ b/app/views/projects/pages_domains/_helper_text.html.haml @@ -1,4 +1,4 @@ -- docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/index.md", anchor: "adding-an-ssltls-certificate-to-pages") +- docs_link_url = help_page_path("user/project/pages/custom_domains_ssl_tls_certification/index", anchor: "adding-an-ssltls-certificate-to-pages") - docs_link_start = "<a href=\"%{docs_link_url}\" target=\"_blank\" rel=\"noopener noreferrer\" class=\"text-nowrap\">".html_safe % { docs_link_url: docs_link_url } - docs_link_end = "</a>".html_safe diff --git a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml index 8dcc59a09d0..cd49f064613 100644 --- a/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml +++ b/app/views/projects/protected_tags/shared/_create_protected_tag.html.haml @@ -14,7 +14,7 @@ .create_access_levels-container = yield :create_access_levels - = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { qa_selector: 'protect_tag_button' } + = f.submit _('Protect'), pajamas_button: true, disabled: true, data: { testid: 'protect-tag-button' } = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/projects/protected_tags/shared/_dropdown.html.haml b/app/views/projects/protected_tags/shared/_dropdown.html.haml index 758df7b3c1e..b1e29768be2 100644 --- a/app/views/projects/protected_tags/shared/_dropdown.html.haml +++ b/app/views/projects/protected_tags/shared/_dropdown.html.haml @@ -6,7 +6,7 @@ footer_content: true, data: { show_no: true, show_any: true, show_upcoming: true, selected: params[:protected_tag_name], - project_id: @project.try(:id), qa_selector: 'tags_dropdown' } }) do + project_id: @project.try(:id), testid: 'tags-dropdown' } }) do %ul.dropdown-footer-list %li diff --git a/app/views/projects/protected_tags/shared/_index.html.haml b/app/views/projects/protected_tags/shared/_index.html.haml index f71ecc3a7c5..5c810b55bec 100644 --- a/app/views/projects/protected_tags/shared/_index.html.haml +++ b/app/views/projects/protected_tags/shared/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expanded_by_default? -%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_tag_settings_content' } } +%section.settings.no-animate#js-protected-tags-settings{ class: ('expanded' if expanded), data: { testid: 'protected-tag-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_("ProtectedTag|Protected tags") diff --git a/app/views/projects/readme_templates/default.md.tt b/app/views/projects/readme_templates/default.md.tt index 779b87336ea..7432918be21 100644 --- a/app/views/projects/readme_templates/default.md.tt +++ b/app/views/projects/readme_templates/default.md.tt @@ -38,7 +38,7 @@ git push -uf origin <%= params[:default_branch] %> Use the built-in continuous integration in GitLab. - [ ] [Get started with GitLab CI/CD](https://docs.gitlab.com/ee/ci/quick_start/index.html) -- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing(SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) +- [ ] [Analyze your code for known vulnerabilities with Static Application Security Testing (SAST)](https://docs.gitlab.com/ee/user/application_security/sast/) - [ ] [Deploy to Kubernetes, Amazon EC2, or Amazon ECS using Auto Deploy](https://docs.gitlab.com/ee/topics/autodevops/requirements.html) - [ ] [Use pull-based deployments for improved Kubernetes management](https://docs.gitlab.com/ee/user/clusters/agent/) - [ ] [Set up protected environments](https://docs.gitlab.com/ee/ci/environments/protected_environments.html) @@ -47,9 +47,10 @@ Use the built-in continuous integration in GitLab. # Editing this README -When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thank you to [makeareadme.com](https://www.makeareadme.com/) for this template. +When you're ready to make this README your own, just edit this file and use the handy template below (or feel free to structure it however you want - this is just a starting point!). Thanks to [makeareadme.com](https://www.makeareadme.com/) for this template. ## Suggestions for a good README + Every project is different, so consider which of these sections apply to yours. The sections used in the template are suggestions for most open source projects. Also keep in mind that while a README can be too long and detailed, too long is better than too short. If you think your README is too long, consider utilizing another form of documentation rather than cutting out information. ## Name diff --git a/app/views/projects/runners/_group_runners.html.haml b/app/views/projects/runners/_group_runners.html.haml index 2d435a7ce9d..a79b73f6f61 100644 --- a/app/views/projects/runners/_group_runners.html.haml +++ b/app/views/projects/runners/_group_runners.html.haml @@ -1,4 +1,4 @@ -- link = link_to _('Runner API'), help_page_path('api/runners.md') +- link = link_to _('Runner API'), help_page_path('api/runners') %h4 = _('Group runners') diff --git a/app/views/projects/runners/_runner.html.haml b/app/views/projects/runners/_runner.html.haml index 12432cd3484..96b87767690 100644 --- a/app/views/projects/runners/_runner.html.haml +++ b/app/views/projects/runners/_runner.html.haml @@ -26,7 +26,8 @@ - elsif runner.project_type? = form_for [@project, @project.runner_projects.new] do |f| = f.hidden_field :runner_id, value: runner.id - = f.submit _('Enable for this project'), class: 'btn gl-button' + = render Pajamas::ButtonComponent.new(variant: :default, size: :small, type: :submit) do + = _('Enable for this project') - if runner.description.present? %p.gl-my-2 = runner.description diff --git a/app/views/projects/settings/access_tokens/_form.html.haml b/app/views/projects/settings/access_tokens/_form.html.haml index 919462a0f62..ee993962c7a 100644 --- a/app/views/projects/settings/access_tokens/_form.html.haml +++ b/app/views/projects/settings/access_tokens/_form.html.haml @@ -7,7 +7,7 @@ resource: @project, token: @resource_access_token, scopes: @scopes, - access_levels: ProjectMember.permissible_access_level_roles(current_user, @project), + access_levels: ProjectMember.permissible_access_level_roles_for_project_access_token(current_user, @project), default_access_level: Gitlab::Access::GUEST, prefix: :resource_access_token, description_prefix: :project_access_token, diff --git a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml index fd27b125602..7011595e075 100644 --- a/app/views/projects/settings/ci_cd/_autodevops_form.html.haml +++ b/app/views/projects/settings/ci_cd/_autodevops_form.html.haml @@ -9,9 +9,9 @@ - base_domain_path = help_page_path('user/project/clusters/gitlab_managed_clusters', anchor: 'base-domain') - base_domain_link_start = link_start % { url: base_domain_path } -- help_link_continouos = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/stages.md', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener noreferrer' -- help_link_timed = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables.md', anchor: 'timed-incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' -- help_link_incremental = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables.md', anchor: 'incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' +- help_link_continouos = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/stages', anchor: 'auto-deploy'), target: '_blank', rel: 'noopener noreferrer' +- help_link_timed = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables', anchor: 'timed-incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' +- help_link_incremental = link_to sprite_icon('question-o'), help_page_path('topics/autodevops/cicd_variables', anchor: 'incremental-rollout-to-production'), target: '_blank', rel: 'noopener noreferrer' .row .col-lg-12 @@ -22,7 +22,7 @@ = f.fields_for :auto_devops_attributes, @auto_devops do |form| = render Pajamas::CardComponent.new(card_options: { class: 'gl-mb-3' }, footer_options: { class: "js-extra-settings #{auto_devops_enabled || 'hidden'}", data: { testid: 'extra-auto-devops-settings' } }) do |c| - c.with_body do - - autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer' + - autodevops_help_link = link_to _('Learn more.'), help_page_path('topics/autodevops/index'), target: '_blank', rel: 'noopener noreferrer' - auto_devops_badge = auto_devops_enabled ? (gl_badge_tag badge_for_auto_devops_scope(@project), { variant: :info }, { class: 'js-instance-default-badge gl-ml-3 gl-mt-n1'}) : '' = form.gitlab_ui_checkbox_component :enabled, (s_('CICD|Default to Auto DevOps pipeline') + auto_devops_badge).html_safe, diff --git a/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml index da1965f549c..0a6f940e41a 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_commit_template.html.haml @@ -9,5 +9,5 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH }) - - link = link_to('', help_page_path('user/project/merge_requests/commit_templates.md'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/merge_requests/commit_templates'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml index dd32d3f9d92..891bd62c0a4 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_method_settings.html.haml @@ -12,7 +12,7 @@ - ffOnly = s_('ProjectSettings|Fast-forward merges only.') - ffConflictRebase = s_('ProjectSettings|When there is a merge conflict, the user is given the option to rebase.') - ffTrains = s_('ProjectSettings|If merge trains are enabled, merging is only possible if the branch can be rebased without conflicts.') -- ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains.md', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer' +- ffTrainsHelp = link_to s_('ProjectSettings|What are merge trains?'), help_page_path('ci/pipelines/merge_trains', anchor: 'enable-merge-trains'), target: '_blank', rel: 'noopener noreferrer' - ffTrainsWithFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase + "<br />" + ffTrains + " " + ffTrainsHelp).html_safe - ffTrainsWithoutFastForward = (noMergeCommit + "<br />" + ffOnly + "<br />" + ffConflictRebase).html_safe @@ -22,7 +22,7 @@ %b= s_('ProjectSettings|Merge method') %p.text-secondary = s_('ProjectSettings|Determine what happens to the commit history when you merge a merge request.') - = link_to s_('ProjectSettings|How do they differ?'), help_page_path('user/project/merge_requests/methods/index.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('ProjectSettings|How do they differ?'), help_page_path('user/project/merge_requests/methods/index'), target: '_blank', rel: 'noopener noreferrer' = form.gitlab_ui_radio_component :merge_method, :merge, labelMerge, @@ -35,4 +35,4 @@ :ff, labelFastForward, help_text: ffTrainsHelpFullHelpText, - radio_options: { data: { qa_selector: 'merge_ff_radio' } } + radio_options: { data: { testid: 'merge-ff-radio' } } diff --git a/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml index 501288f727b..5aa7449c72f 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_merge_suggestions_settings.html.haml @@ -9,5 +9,5 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_SUGGESTIONS_TEMPLATE_LENGTH }) - - link = link_to('', help_page_path('user/project/merge_requests/reviews/suggestions.md', anchor: 'configure-the-commit-message-for-applied-suggestions'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/merge_requests/reviews/suggestions', anchor: 'configure-the-commit-message-for-applied-suggestions'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml b/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml index a9609434f15..65eb5b60cc3 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_pipelines_and_threads_options.html.haml @@ -9,5 +9,4 @@ help_text: s_('MergeChecks|Introduces the risk of merging changes that do not pass the pipeline.'), checkbox_options: { class: 'gl-pl-6' } = form.gitlab_ui_checkbox_component :only_allow_merge_if_all_discussions_are_resolved, - s_('MergeChecks|All threads must be resolved'), - checkbox_options: { data: { qa_selector: 'only_allow_merge_if_all_discussions_are_resolved_checkbox' } } + s_('MergeChecks|All threads must be resolved') diff --git a/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml b/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml index bc6530b927c..26b038f1bf7 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_squash_commit_template.html.haml @@ -9,5 +9,5 @@ %p.form-text.text-muted = s_('ProjectSettings|Leave empty to use default template.') = sprintf(s_('ProjectSettings|Maximum %{maxLength} characters.'), { maxLength: Project::MAX_COMMIT_TEMPLATE_LENGTH }) - - link = link_to('', help_page_path('user/project/merge_requests/commit_templates.md'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/merge_requests/commit_templates'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('ProjectSettings|%{link_start}What variables can I use?%{link_end}'), tag_pair(link, :link_start, :link_end)) diff --git a/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml b/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml index 372c0723600..120b183bf51 100644 --- a/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml +++ b/app/views/projects/settings/merge_requests/_merge_request_squash_options_settings.html.haml @@ -5,7 +5,7 @@ %b= s_('ProjectSettings|Squash commits when merging') %p.text-secondary = s_('ProjectSettings|Set the default behavior of this option in merge requests. Changes to this are also applied to existing merge requests.') - = link_to s_('ProjectSettings|What is squashing?'), help_page_path('user/project/merge_requests/squash_and_merge.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to s_('ProjectSettings|What is squashing?'), help_page_path('user/project/merge_requests/squash_and_merge'), target: '_blank', rel: 'noopener noreferrer' = settings.gitlab_ui_radio_component :squash_option, :never, diff --git a/app/views/projects/settings/merge_requests/show.html.haml b/app/views/projects/settings/merge_requests/show.html.haml index e877be704a2..f48a4e5e42c 100644 --- a/app/views/projects/settings/merge_requests/show.html.haml +++ b/app/views/projects/settings/merge_requests/show.html.haml @@ -13,7 +13,7 @@ = gitlab_ui_form_for @project, url: project_settings_merge_requests_path(@project), html: { multipart: true, class: "merge-request-settings-form js-mr-settings-form" }, authenticity_token: true do |f| %input{ name: 'update_section', type: 'hidden', value: 'js-merge-request-settings' } = render 'projects/settings/merge_requests/merge_request_settings', form: f - = f.submit _('Save changes'), class: "rspec-save-merge-request-changes", data: { qa_selector: 'save_merge_request_changes_button' }, pajamas_button: true + = f.submit _('Save changes'), class: "rspec-save-merge-request-changes", data: { testid: 'save-merge-request-changes-button' }, pajamas_button: true = render_if_exists 'projects/settings/merge_requests/merge_request_approvals_settings', expanded: true = render_if_exists 'projects/settings/merge_requests/suggested_reviewers_settings', expanded: true diff --git a/app/views/projects/settings/operations/_alert_management.html.haml b/app/views/projects/settings/operations/_alert_management.html.haml index c29cedd8250..849597f6e65 100644 --- a/app/views/projects/settings/operations/_alert_management.html.haml +++ b/app/views/projects/settings/operations/_alert_management.html.haml @@ -11,6 +11,6 @@ = _('Expand') %p.gl-text-secondary = _('Display alerts from all configured monitoring tools.') - = link_to _('Learn more.'), help_page_path('operations/incident_management/integrations.md'), target: '_blank', rel: 'noopener noreferrer' + = link_to _('Learn more.'), help_page_path('operations/incident_management/integrations'), target: '_blank', rel: 'noopener noreferrer' .settings-content .js-alerts-settings{ data: alerts_settings_data } diff --git a/app/views/projects/tags/_tag.html.haml b/app/views/projects/tags/_tag.html.haml index cc49ff9e293..f04d6ab341f 100644 --- a/app/views/projects/tags/_tag.html.haml +++ b/app/views/projects/tags/_tag.html.haml @@ -30,7 +30,7 @@ = render partial: 'projects/commit/signature', object: tag.signature - if commit_status - = render 'ci/status/icon', size: 24, status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5' + = render 'ci/status/icon', status: commit_status, option_css_classes: 'gl-display-inline-flex gl-vertical-align-middle gl-mr-5' - elsif @tag_pipeline_statuses && @tag_pipeline_statuses.any? .gl-display-inline-flex.gl-vertical-align-middle.gl-mr-5 %svg.s24 diff --git a/app/views/projects/tree/_tree_header.html.haml b/app/views/projects/tree/_tree_header.html.haml index 37f27aa7caf..bed37d9cb63 100644 --- a/app/views/projects/tree/_tree_header.html.haml +++ b/app/views/projects/tree/_tree_header.html.haml @@ -10,9 +10,10 @@ .tree-controls .d-block.d-sm-flex.flex-wrap.align-items-start.gl-children-ml-sm-3.gl-first-child-ml-sm-0< = render_if_exists 'projects/tree/lock_link' + = render 'projects/buttons/compare', project: @project, ref: @ref, root_ref: @repository&.root_ref + #js-tree-history-link{ data: { history_link: project_commits_path(@project, @ref) } } - = render 'projects/buttons/compare', project: @project, ref: @ref, root_ref: @repository&.root_ref = render 'projects/find_file_link' = render 'shared/web_ide_button', blob: nil = render 'projects/buttons/download', project: @project, ref: @ref diff --git a/app/views/projects/tree/show.html.haml b/app/views/projects/tree/show.html.haml index 3c3f9eb7390..97b254a7b85 100644 --- a/app/views/projects/tree/show.html.haml +++ b/app/views/projects/tree/show.html.haml @@ -13,3 +13,6 @@ = render 'projects/last_push' = render 'projects/files', commit: @last_commit, project: @project, ref: @ref, content_url: project_tree_path(@project, @id) = render 'shared/web_ide_path' + +-# https://gitlab.com/gitlab-org/gitlab/-/issues/408388#note_1578533983 +#js-ambiguous-ref-modal{ data: { ambiguous: @is_ambiguous_ref.to_s, ref: current_ref } } diff --git a/app/views/projects/usage_quotas/index.html.haml b/app/views/projects/usage_quotas/index.html.haml index 6f2a2aacf66..039df9738ff 100644 --- a/app/views/projects/usage_quotas/index.html.haml +++ b/app/views/projects/usage_quotas/index.html.haml @@ -14,7 +14,7 @@ .col-sm-12 %p.gl-text-secondary = s_('UsageQuota|Usage of project resources across the %{strong_start}%{project_name}%{strong_end} project').html_safe % { strong_start: '<strong>'.html_safe, strong_end: '</strong>'.html_safe, project_name: @project.name } + '.' - %a{ href: help_page_path('user/usage_quotas.md'), target: '_blank', rel: 'noopener noreferrer' } + %a{ href: help_page_path('user/usage_quotas'), target: '_blank', rel: 'noopener noreferrer' } = s_('UsageQuota|Learn more about usage quotas') + '.' = gl_tabs_nav({ id: 'js-project-usage-quotas-tabs' }) do diff --git a/app/views/protected_branches/shared/_create_protected_branch.html.haml b/app/views/protected_branches/shared/_create_protected_branch.html.haml index 96e6990b080..bb1d56dcc61 100644 --- a/app/views/protected_branches/shared/_create_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_create_protected_branch.html.haml @@ -14,12 +14,17 @@ = render partial: "protected_branches/shared/dropdown", locals: { f: f, toggle_classes: 'gl-w-full! gl-form-input-lg' } .form-text.text-muted - wildcards_url = help_page_url('user/project/protected_branches', anchor: 'protect-multiple-branches-with-wildcard-rules') - - wildcards_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: wildcards_url } - - placeholders = { wildcards_link_start: wildcards_link_start, wildcards_link_end: '</a>', code_tag_start: '<code>', code_tag_end: '</code>' } + - wildcards_link_tag_pair = tag_pair(link_to('', wildcards_url, target: '_blank', rel: 'noopener noreferrer'), :wildcards_link_start, :wildcards_link_end) + + - case_sensitive_url = help_page_url('user/project/protected_branches', anchor: 'branch-names-are-case-sensitive') + - case_sensitive_link_tag_pair = tag_pair(link_to('', case_sensitive_url, target: '_blank', rel: 'noopener noreferrer'), :case_sensitive_link_start, :case_sensitive_link_end) + + - code_tag_pair = tag_pair(tag.code, :code_tag_start, :code_tag_end) + - if protected_branch_entity.is_a?(Group) - = (s_("ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe + = safe_format(s_('ProtectedBranch|Only %{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported. %{case_sensitive_link_start}Branch names are case-sensitive.%{case_sensitive_link_end}'), wildcards_link_tag_pair, case_sensitive_link_tag_pair, code_tag_pair) - else - = (s_("ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported.") % placeholders).html_safe + = safe_format(s_('ProtectedBranch|%{wildcards_link_start}Wildcards%{wildcards_link_end} such as %{code_tag_start}*-stable%{code_tag_end} or %{code_tag_start}production/*%{code_tag_end} are supported. %{case_sensitive_link_start}Branch names are case-sensitive.%{case_sensitive_link_end}'), wildcards_link_tag_pair, case_sensitive_link_tag_pair, code_tag_pair) .form-group.row = f.label :merge_access_levels_attributes, s_("ProtectedBranch|Allowed to merge:"), class: 'col-sm-12' .col-sm-12 @@ -38,6 +43,6 @@ - force_push_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: force_push_docs_url } = (s_("ProtectedBranch|Allow all users with push access to %{tag_start}force push%{tag_end}.") % { tag_start: force_push_link_start, tag_end: '</a>' }).html_safe = render_if_exists 'protected_branches/ee/code_owner_approval_form', f: f, protected_branch_entity: protected_branch_entity - = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { qa_selector: 'protect_button' }, pajamas_button: true + = f.submit s_('ProtectedBranch|Protect'), disabled: true, data: { testid: 'protect-button' }, pajamas_button: true = render Pajamas::ButtonComponent.new(button_options: { type: 'reset', class: 'gl-ml-2 js-toggle-button' }) do = _('Cancel') diff --git a/app/views/protected_branches/shared/_index.html.haml b/app/views/protected_branches/shared/_index.html.haml index 8e72563182c..ce5b58ee189 100644 --- a/app/views/protected_branches/shared/_index.html.haml +++ b/app/views/protected_branches/shared/_index.html.haml @@ -1,7 +1,7 @@ - can_admin_entity = protected_branch_can_admin_entity?(protected_branch_entity) - expanded = expanded_by_default? -%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { qa_selector: 'protected_branches_settings_content' } } +%section.settings.no-animate#js-protected-branches-settings{ class: ('expanded' if expanded), data: { testid: 'protected-branches-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only = s_("ProtectedBranch|Protected branches") diff --git a/app/views/protected_branches/shared/_protected_branch.html.haml b/app/views/protected_branches/shared/_protected_branch.html.haml index 93c84e67d81..67c6e991a59 100644 --- a/app/views/protected_branches/shared/_protected_branch.html.haml +++ b/app/views/protected_branches/shared/_protected_branch.html.haml @@ -27,4 +27,14 @@ %span.has-tooltip{ data: { container: 'body' }, title: s_('ProtectedBranch|Inherited - This setting can be changed at the group level'), 'aria-hidden': 'true' } = sprite_icon 'lock' - else - = link_button_to s_('ProtectedBranch|Unprotect'), [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], disabled: local_assigns[:disabled], aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' }, method: :delete, variant: :danger, category: :secondary, size: :small + .gl-relative + - if local_assigns[:protected_from_deletion] + %span.gl-absolute.gl-display-inline-block.gl-w-full.gl-h-full{ data: { container: 'body', toggle: 'popover', placement: local_assigns[:placemet], html: 'true', triggers: 'hover', content: local_assigns[:popover_content] } } + = render Pajamas::ButtonComponent.new(size: :small, + variant: :danger, + href: [protected_branch_entity, protected_branch, { update_section: 'js-protected-branches-settings' }], + method: :delete, + disabled: local_assigns[:protected_from_deletion], + button_options: { update_section: 'js-protected-branches-settings', aria: { label: s_('ProtectedBranch|Unprotect branch') }, data: { confirm: s_('ProtectedBranch|Branch will be writable for developers. Are you sure?'), confirm_btn_variant: 'danger' } }, + category: :secondary) do + = s_('ProtectedBranch|Unprotect') diff --git a/app/views/pwa/manifest.json.erb b/app/views/pwa/manifest.json.erb index e780b13de6e..82730105a53 100644 --- a/app/views/pwa/manifest.json.erb +++ b/app/views/pwa/manifest.json.erb @@ -3,7 +3,7 @@ "name": "<%= appearance_pwa_name %>", "short_name": "<%= appearance_pwa_short_name %>", "description": "<%= appearance_pwa_description %>", - "start_url": "<%= explore_projects_path %>", + "start_url": "<%= root_path %>", "scope": "<%= root_path %>", "display": "browser", "orientation": "any", diff --git a/app/views/search/show.html.haml b/app/views/search/show.html.haml index 9c1f4c8643f..4fda5379876 100644 --- a/app/views/search/show.html.haml +++ b/app/views/search/show.html.haml @@ -17,9 +17,8 @@ - page_description(_("%{count} %{scope} for term '%{term}'") % { count: @search_results.formatted_count(@scope), scope: @scope, term: @search_term }) - page_card_attributes("Namespace" => @group&.full_path, "Project" => @project&.full_path) -.page-title-holder.gl-display-flex.gl-flex-wrap.gl-justify-content-space-between - %h1.page-title.gl-font-size-h-display.gl-mr-5= _('Search') - = render_if_exists 'search/form_elasticsearch', attrs: { class: 'mb-2 mb-sm-0 align-self-center' } +.gl-display-flex.gl-flex-wrap.gl-justify-content-end.gl-pt-6.gl-pb-5 + = render_if_exists 'search/form_elasticsearch' #js-search-topbar{ data: { "group-initial-json": group_attributes.to_json, "project-initial-json": project_attributes.to_json, "default-branch-name": @project&.default_branch } } .results.gl-lg-display-flex.gl-mt-0 diff --git a/app/views/shared/_auto_devops_callout.html.haml b/app/views/shared/_auto_devops_callout.html.haml index 6d8d4f4cab9..3f613a1b383 100644 --- a/app/views/shared/_auto_devops_callout.html.haml +++ b/app/views/shared/_auto_devops_callout.html.haml @@ -12,5 +12,5 @@ %p= s_('AutoDevOps|It will automatically build, test, and deploy your application based on a predefined CI/CD configuration.') %p - - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index.md'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to(s_('AutoDevOps|Auto DevOps documentation'), help_page_path('topics/autodevops/index'), target: '_blank', rel: 'noopener noreferrer') = s_('AutoDevOps|Learn more in the %{link_to_documentation}').html_safe % { link_to_documentation: link } diff --git a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml index 79a9bafc4f0..0ff2ee935cc 100644 --- a/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml +++ b/app/views/shared/_auto_devops_implicitly_enabled_banner.html.haml @@ -9,4 +9,4 @@ = _('Container registry is not enabled on this GitLab instance. Ask an administrator to enable it in order for Auto DevOps to work.') - c.with_actions do = link_button_to _('Settings'), project_settings_ci_cd_path(project), class: 'alert-link', variant: :confirm - = link_button_to _('More information'), help_page_path('topics/autodevops/index.md'), target: '_blank', class: 'alert-link gl-ml-3' + = link_button_to _('More information'), help_page_path('topics/autodevops/index'), target: '_blank', class: 'alert-link gl-ml-3' diff --git a/app/views/shared/_ci_catalog_badge.html.haml b/app/views/shared/_ci_catalog_badge.html.haml new file mode 100644 index 00000000000..7f8f4f6143b --- /dev/null +++ b/app/views/shared/_ci_catalog_badge.html.haml @@ -0,0 +1 @@ += render Pajamas::BadgeComponent.new(s_('CiCatalog|CI/CD catalog resource'), variant: 'info', icon: 'catalog-checkmark', class: css_class, href: href) diff --git a/app/views/shared/_commit_message_container.html.haml b/app/views/shared/_commit_message_container.html.haml index 2b55d35cf1f..f420f176a11 100644 --- a/app/views/shared/_commit_message_container.html.haml +++ b/app/views/shared/_commit_message_container.html.haml @@ -10,7 +10,7 @@ class: 'form-control gl-form-input js-commit-message', placeholder: local_assigns[:placeholder], data: descriptions, - 'data-qa-selector': 'commit_message_field', + 'data-testid': 'commit-message-field', required: true, rows: (local_assigns[:rows] || 3), id: "commit_message-#{nonce}" - if local_assigns[:hint] diff --git a/app/views/shared/_custom_attributes.html.haml b/app/views/shared/_custom_attributes.html.haml index be96e77dbd4..33f3ca93b9c 100644 --- a/app/views/shared/_custom_attributes.html.haml +++ b/app/views/shared/_custom_attributes.html.haml @@ -2,7 +2,7 @@ = render Pajamas::CardComponent.new(body_options: { class: 'gl-py-0' }) do |c| - c.with_header do - = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes.md')) + = link_to(_('Custom Attributes'), help_page_path('api/custom_attributes')) - c.with_body do %ul.content-list - custom_attributes.each do |custom_attribute| diff --git a/app/views/shared/_md_preview.html.haml b/app/views/shared/_md_preview.html.haml index 1fd430527a1..7ac6a822420 100644 --- a/app/views/shared/_md_preview.html.haml +++ b/app/views/shared/_md_preview.html.haml @@ -5,7 +5,7 @@ .issuable-note-warning = sprite_icon('lock', css_class: 'icon') %span - = _('This merge request is locked.') + = _('The discussion in this merge request is locked.') = _('Only project members can comment.') .md-area.position-relative diff --git a/app/views/shared/_new_nav_announcement.html.haml b/app/views/shared/_new_nav_announcement.html.haml deleted file mode 100644 index 8cabab09ec2..00000000000 --- a/app/views/shared/_new_nav_announcement.html.haml +++ /dev/null @@ -1,33 +0,0 @@ -- return unless show_new_navigation_callout? - -- changes_url = 'https://gitlab.com/groups/gitlab-org/-/epics/9044#whats-different' -- vision_url = 'https://about.gitlab.com/blog/2023/05/01/gitlab-product-navigation/' -- design_url = 'https://about.gitlab.com/blog/2023/05/15/overhauling-the-navigation-is-like-building-a-dream-home/' -- feedback_url = 'https://gitlab.com/gitlab-org/gitlab/-/issues/409005' -- docs_url = help_page_path('tutorials/left_sidebar/index') - -- changes_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: changes_url } -- vision_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: vision_url } -- design_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: design_url } -- link_end = '</a>'.html_safe - -- welcome_text = _('For the next few releases, you can go to your avatar at any time to turn the new navigation on and off.') -- cta_text = _('Read more about the %{changes_link_start}changes%{link_end}, the %{vision_link_start}vision%{link_end}, and the %{design_link_start}design%{link_end}.' % { changes_link_start: changes_link_start, - vision_link_start: vision_link_start, - design_link_start: design_link_start, - link_end: link_end}).html_safe # rubocop:disable Gettext/StaticIdentifier - -= render Pajamas::AlertComponent.new(dismissible: true, title: _('Welcome to a new navigation experience'), - alert_options: { class: 'js-new-navigation-callout', data: { feature_id: "new_navigation_callout", dismiss_endpoint: callouts_path }}) do |c| - - c.with_body do - %p - = welcome_text - = cta_text - - c.with_actions do - = render Pajamas::ButtonComponent.new(variant: :confirm, - href: docs_url, - button_options: { class: 'gl-alert-action', data: { track_action: 'click_button', track_label: 'banner_nav_learn_more' } }) do |c| - = _('Learn more') - = render Pajamas::ButtonComponent.new(href: feedback_url, - button_options: { data: { track_action: 'click_button', track_label: 'banner_nav_provide_feedback' } }) do |c| - = _('Provide feedback') diff --git a/app/views/shared/_new_nav_for_everyone_announcement.html.haml b/app/views/shared/_new_nav_for_everyone_announcement.html.haml new file mode 100644 index 00000000000..fa870249596 --- /dev/null +++ b/app/views/shared/_new_nav_for_everyone_announcement.html.haml @@ -0,0 +1,18 @@ +- return unless show_new_nav_for_everyone_callout? + +- blog_url = 'https://about.gitlab.com/blog/2023/08/15/navigation-research-blog-post/' +- issues_url = 'https://about.gitlab.com/submit-feedback/#product-feedback' + +- blog_link_tags = tag_pair(link_to('', blog_url, rel: 'noopener noreferrer', target: '_blank'), :blog_link_start, :link_end) +- issues_link_tags = tag_pair(link_to('', issues_url, rel: 'noopener noreferrer', target: '_blank'), :issues_link_start, :link_end) + +- welcome_text = safe_format(_('GitLab has redesigned the left sidebar to address customer feedback. View details in %{blog_link_start}this blog post%{link_end}. Here\'s how to %{issues_link_start}file an issue%{link_end} with the GitLab product team.'), blog_link_tags, issues_link_tags) + += render Pajamas::AlertComponent.new(dismissible: true, + alert_options: { class: 'js-new-nav-for-everyone-callout', data: { feature_id: "new_nav_for_everyone_callout", dismiss_endpoint: callouts_path }}) do |c| + - c.with_body do + %p + = welcome_text + - c.with_actions do + = render Pajamas::ButtonComponent.new(variant: :confirm, href: blog_url, target: '_blank', button_options: { class: 'gl-alert-action' }) do |c| + = _('Learn more') diff --git a/app/views/shared/_project_limit.html.haml b/app/views/shared/_project_limit.html.haml index a99db32c40e..914c20fb7b0 100644 --- a/app/views/shared/_project_limit.html.haml +++ b/app/views/shared/_project_limit.html.haml @@ -3,7 +3,7 @@ dismissible: false, alert_options: { class: 'project-limit-message' }) do |c| - c.with_body do - = _("You won't be able to create new projects because you have reached your project limit.") + = _("You cannot create new projects in your personal namespace because you have reached your personal project limit.") - c.with_actions do = link_button_to _('Remind later'), '#', class: 'alert-link hide-project-limit-message', variant: :confirm = link_button_to _("Don't show again"), profile_path(user: {hide_project_limit: true}), method: :put, class: 'alert-link gl-ml-3' diff --git a/app/views/shared/_registration_features_discovery_message.html.haml b/app/views/shared/_registration_features_discovery_message.html.haml index 6e386866dfb..5fa554171aa 100644 --- a/app/views/shared/_registration_features_discovery_message.html.haml +++ b/app/views/shared/_registration_features_discovery_message.html.haml @@ -1,5 +1,5 @@ - feature_title = local_assigns.fetch(:feature_title, s_('RegistrationFeatures|use this feature')) -- registration_features_docs_path = help_page_path('administration/settings/usage_statistics.md', anchor: 'registration-features-program') +- registration_features_docs_path = help_page_path('administration/settings/usage_statistics', anchor: 'registration-features-program') - registration_features_link_start = '<a href="%{url}" target="_blank">'.html_safe % { url: registration_features_docs_path } %div diff --git a/app/views/shared/_remote_mirror_update_button.html.haml b/app/views/shared/_remote_mirror_update_button.html.haml index fa5c862b768..ec897e59d4a 100644 --- a/app/views/shared/_remote_mirror_update_button.html.haml +++ b/app/views/shared/_remote_mirror_update_button.html.haml @@ -3,4 +3,4 @@ button_options: { class: 'disabled', title: _('Updating'), data: { toggle: 'tooltip', container: 'body' } }, icon_classes: 'spin') - elsif remote_mirror.enabled? - = link_button_to nil, update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: 'rspec-update-now-button', data: { toggle: 'tooltip', container: 'body', qa_selector: 'update_now_button' }, title: _('Update now'), icon: 'retry' + = link_button_to nil, update_now_project_mirror_path(@project, sync_remote: true), method: :post, class: 'rspec-update-now-button', data: { toggle: 'tooltip', container: 'body', testid: 'update-now-button' }, title: _('Update now'), icon: 'retry' diff --git a/app/views/shared/_service_ping_consent.html.haml b/app/views/shared/_service_ping_consent.html.haml index cfc0afb4646..b65808bfcd2 100644 --- a/app/views/shared/_service_ping_consent.html.haml +++ b/app/views/shared/_service_ping_consent.html.haml @@ -1,14 +1,14 @@ - if session[:ask_for_usage_stats_consent] = render Pajamas::AlertComponent.new(alert_options: { class: 'service-ping-consent-message' }) do |c| - c.with_body do - - docs_link = link_to '', help_page_path('administration/settings/usage_statistics.md'), class: 'gl-link' + - docs_link = link_to '', help_page_path('administration/settings/usage_statistics'), class: 'gl-link' - settings_link = link_to '', metrics_and_profiling_admin_application_settings_path(anchor: 'js-usage-settings'), class: 'gl-link' = safe_format s_('ServicePing|To help improve GitLab, we would like to periodically %{link_start}collect usage information%{link_end}.'), tag_pair(docs_link, :link_start, :link_end) = safe_format s_('ServicePing|This can be changed at any time in %{link_start}your settings%{link_end}.'), tag_pair(settings_link, :link_start, :link_end) - c.with_actions do - send_service_data_path = admin_application_settings_path(application_setting: { version_check_enabled: 1, usage_ping_enabled: 1 }) - not_now_path = admin_application_settings_path(application_setting: { version_check_enabled: 0, usage_ping_enabled: 0 }) - = render Pajamas::ButtonComponent.new(href: send_service_data_path, method: :put, variant: :confirm, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': true, 'data-service-ping-enabled': true, class: 'js-service-ping-consent-action alert-link' }) do + = render Pajamas::ButtonComponent.new(href: send_service_data_path, method: :put, variant: :confirm, button_options: { class: 'alert-link' }) do = _('Send service data') - = render Pajamas::ButtonComponent.new(href: not_now_path, method: :put, button_options: { 'data-url' => admin_application_settings_path, 'data-check-enabled': false, 'data-service-ping-enabled': false, class: 'js-service-ping-consent-action alert-link gl-ml-3' }) do + = render Pajamas::ButtonComponent.new(href: not_now_path, method: :put, button_options: { class: 'alert-link gl-ml-3' }) do = _("Don't send service data") diff --git a/app/views/shared/access_tokens/_form.html.haml b/app/views/shared/access_tokens/_form.html.haml index e46da882e83..3bf85da83b1 100644 --- a/app/views/shared/access_tokens/_form.html.haml +++ b/app/views/shared/access_tokens/_form.html.haml @@ -30,7 +30,7 @@ .form-group = label_tag :access_level, s_("AccessTokens|Select a role"), class: "label-bold" .select-wrapper.gl-form-input-md - = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control", data: { qa_selector: 'access_token_access_level' } + = select_tag :"#{prefix}[access_level]", options_for_select(access_levels, default_access_level), class: "form-control select-control" = sprite_icon('chevron-down', css_class: "gl-icon gl-absolute gl-top-3 gl-right-3 gl-text-gray-200") .form-group diff --git a/app/views/shared/deploy_tokens/_form.html.haml b/app/views/shared/deploy_tokens/_form.html.haml index bb7e0d774cc..109bd559762 100644 --- a/app/views/shared/deploy_tokens/_form.html.haml +++ b/app/views/shared/deploy_tokens/_form.html.haml @@ -1,17 +1,17 @@ %p - - link = link_to('', help_page_path('user/project/deploy_tokens/index.md'), target: '_blank', rel: 'noopener noreferrer') + - link = link_to('', help_page_path('user/project/deploy_tokens/index'), target: '_blank', rel: 'noopener noreferrer') = safe_format(s_('DeployTokens|Create a new deploy token for all projects in this group. %{link_start}What are deploy tokens?%{link_end}'), tag_pair(link, :link_start, :link_end)) = gitlab_ui_form_for token, url: create_deploy_token_path(group_or_project, anchor: 'js-deploy-tokens'), method: :post, remote: true do |f| .form-group = f.label :name, class: 'label-bold' - = f.text_field :name, class: 'form-control gl-form-input', data: { qa_selector: 'deploy_token_name_field' }, required: true + = f.text_field :name, class: 'form-control gl-form-input', data: { testid: 'deploy-token-name-field' }, required: true .text-secondary= s_('DeployTokens|Enter a unique name for your deploy token.') .form-group = f.label :expires_at, _('Expiration date (optional)'), class: 'label-bold' - = f.gitlab_ui_datepicker :expires_at, data: { qa_selector: 'deploy_token_expires_at_field' }, value: f.object.expires_at + = f.gitlab_ui_datepicker :expires_at, data: { testid: 'deploy-token-expires-at-field' }, value: f.object.expires_at .text-secondary= s_('DeployTokens|Enter an expiration date for your token. Defaults to never expire.') .form-group @@ -22,15 +22,15 @@ .form-group = f.label :scopes, _('Scopes (select at least one)'), class: 'label-bold' - = f.gitlab_ui_checkbox_component :read_repository, 'read_repository', help_text: s_('DeployTokens|Allows read-only access to the repository.'), checkbox_options: { data: { qa_selector: 'deploy_token_read_repository_checkbox' } } + = f.gitlab_ui_checkbox_component :read_repository, 'read_repository', help_text: s_('DeployTokens|Allows read-only access to the repository.'), checkbox_options: { data: { testid: 'deploy-token-read-repository-checkbox' } } - if container_registry_enabled?(group_or_project) - = f.gitlab_ui_checkbox_component :read_registry, 'read_registry', help_text: s_('DeployTokens|Allows read-only access to registry images.'), checkbox_options: { data: { qa_selector: 'deploy_token_read_registry_checkbox' } } - = f.gitlab_ui_checkbox_component :write_registry, 'write_registry', help_text: s_('DeployTokens|Allows write access to registry images.'), checkbox_options: { data: { qa_selector: 'deploy_token_write_registry_checkbox' } } + = f.gitlab_ui_checkbox_component :read_registry, 'read_registry', help_text: s_('DeployTokens|Allows read-only access to registry images.'), checkbox_options: { data: { testid: 'deploy-token-read-registry-checkbox' } } + = f.gitlab_ui_checkbox_component :write_registry, 'write_registry', help_text: s_('DeployTokens|Allows write access to registry images.'), checkbox_options: { data: { testid: 'deploy-token-write-registry-checkbox' } } - if packages_registry_enabled?(group_or_project) - = f.gitlab_ui_checkbox_component :read_package_registry, 'read_package_registry', help_text: s_('DeployTokens|Allows read-only access to the package registry.'), checkbox_options: { data: { qa_selector: 'deploy_token_read_package_registry_checkbox' } } - = f.gitlab_ui_checkbox_component :write_package_registry, 'write_package_registry', help_text: s_('DeployTokens|Allows read and write access to the package registry.'), checkbox_options: { data: { qa_selector: 'deploy_token_write_package_registry_checkbox' } } + = f.gitlab_ui_checkbox_component :read_package_registry, 'read_package_registry', help_text: s_('DeployTokens|Allows read-only access to the package registry.'), checkbox_options: { data: { testid: 'deploy-token-read-package-registry-checkbox' } } + = f.gitlab_ui_checkbox_component :write_package_registry, 'write_package_registry', help_text: s_('DeployTokens|Allows read and write access to the package registry.'), checkbox_options: { data: { testid: 'deploy-token-write-package-registry-checkbox' } } .gl-mt-3 - = f.submit s_('DeployTokens|Create deploy token'), data: { qa_selector: 'create_deploy_token_button' }, pajamas_button: true + = f.submit s_('DeployTokens|Create deploy token'), data: { testid: 'create-deploy-token-button' }, pajamas_button: true diff --git a/app/views/shared/deploy_tokens/_index.html.haml b/app/views/shared/deploy_tokens/_index.html.haml index ccffc3ec923..74de71867b8 100644 --- a/app/views/shared/deploy_tokens/_index.html.haml +++ b/app/views/shared/deploy_tokens/_index.html.haml @@ -1,6 +1,6 @@ - expanded = expand_deploy_tokens_section?(@new_deploy_token, @created_deploy_token) -%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { qa_selector: 'deploy_tokens_settings_content' } } +%section.settings.no-animate#js-deploy-tokens{ class: ('expanded' if expanded), data: { testid: 'deploy-tokens-settings-content' } } .settings-header %h4.settings-title.js-settings-toggle.js-settings-toggle-trigger-only= s_('DeployTokens|Deploy tokens') = render Pajamas::ButtonComponent.new(button_options: { class: 'js-settings-toggle' }) do diff --git a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml index 25c277ea0ea..2bc2e6c5b81 100644 --- a/app/views/shared/deploy_tokens/_new_deploy_token.html.haml +++ b/app/views/shared/deploy_tokens/_new_deploy_token.html.haml @@ -1,21 +1,21 @@ -.created-deploy-token-container.info-well{ data: { qa_selector: 'created_deploy_token_container' } } +.created-deploy-token-container.info-well{ data: { testid: 'created-deploy-token-container' } } .well-segment %h5.gl-mt-0 = s_('DeployTokens|Your new Deploy Token username') .form-group .input-group - = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_user_field' } + = text_field_tag 'deploy-token-user', deploy_token.username, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-user-field' } .input-group-append = deprecated_clipboard_button(text: deploy_token.username, title: s_('DeployTokens|Copy username'), placement: 'left') %span.deploy-token-help-block.gl-mt-2.text-success - - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_tokens/index.md') } + - link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('user/project/deploy_tokens/index') } - link_end = "</a>".html_safe = s_("DeployTokens|This username supports access. %{link_start}What kind of access?%{link_end}").html_safe % { link_start: link_start, link_end: link_end } .form-group .input-group - = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { qa_selector: 'deploy_token_field' } + = text_field_tag 'deploy-token', deploy_token.token, readonly: true, class: 'deploy-token-field form-control js-select-on-focus', data: { testid: 'deploy-token-field' } .input-group-append = deprecated_clipboard_button(text: deploy_token.token, title: s_('DeployTokens|Copy deploy token'), placement: 'left') %span.deploy-token-help-block.gl-mt-2.text-danger diff --git a/app/views/shared/deploy_tokens/_table.html.haml b/app/views/shared/deploy_tokens/_table.html.haml index 3b351387d41..0b8a97a34f2 100644 --- a/app/views/shared/deploy_tokens/_table.html.haml +++ b/app/views/shared/deploy_tokens/_table.html.haml @@ -16,7 +16,7 @@ packages_registry_enabled: packages_registry_enabled?(group_or_project), create_new_token_path: create_deploy_token_path(group_or_project), token_type: group_or_project.is_a?(Group) ? 'group' : 'project', - deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index.md') + deploy_tokens_help_url: help_page_path('user/project/deploy_tokens/index') } } - if active_tokens.present? diff --git a/app/views/shared/empty_states/_snippets.html.haml b/app/views/shared/empty_states/_snippets.html.haml index a2457fb0810..800cfe8b0d1 100644 --- a/app/views/shared/empty_states/_snippets.html.haml +++ b/app/views/shared/empty_states/_snippets.html.haml @@ -13,6 +13,6 @@ .gl-mt-3< - if button_path = link_button_to s_('SnippetsEmptyState|New snippet'), button_path, title: s_('SnippetsEmptyState|New snippet'), id: 'new_snippet_link', data: { testid: 'create-first-snippet-link' }, variant: :confirm - = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets.md'), title: s_('SnippetsEmptyState|Documentation') + = link_button_to s_('SnippetsEmptyState|Documentation'), help_page_path('user/snippets'), title: s_('SnippetsEmptyState|Documentation') - else %h4.gl-text-center= s_('SnippetsEmptyState|There are no snippets to show.') diff --git a/app/views/shared/integrations/gitlab_slack_application/_help.html.haml b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml index 0956f1183cb..2e7768e54f4 100644 --- a/app/views/shared/integrations/gitlab_slack_application/_help.html.haml +++ b/app/views/shared/integrations/gitlab_slack_application/_help.html.haml @@ -2,7 +2,7 @@ .well-segment %p = s_("SlackIntegration|This integration allows users to perform common operations on this project by entering slash commands in Slack.") - = link_to _('Learn more'), help_page_path('user/project/integrations/gitlab_slack_application.md') + = link_to _('Learn more'), help_page_path('user/project/integrations/gitlab_slack_application') %p = s_("SlackIntegration|See the list of available commands in Slack after setting up this integration by entering") %kbd.inline /gitlab help diff --git a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml index e5d05a8a83d..57d172b41f4 100644 --- a/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml +++ b/app/views/shared/integrations/gitlab_slack_application/_slack_integration_form.html.haml @@ -29,7 +29,7 @@ = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do = s_('SlackIntegration|Reinstall GitLab for Slack app…') %p - = html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application.md', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe} + = html_escape(s_('SlackIntegration|You may need to reinstall the GitLab for Slack app when we %{linkStart}make updates or change permissions%{linkEnd}.')) % { linkStart: %(<a href="#{help_page_path('user/project/integrations/gitlab_slack_application', anchor: 'update-the-gitlab-for-slack-app')}">).html_safe, linkEnd: '</a>'.html_safe} - else = render Pajamas::ButtonComponent.new(href: add_to_slack_link(@project, slack_app_id)) do = s_('SlackIntegration|Install GitLab for Slack app…') diff --git a/app/views/shared/integrations/mattermost_slash_commands/_help.html.haml b/app/views/shared/integrations/mattermost_slash_commands/_help.html.haml index 6ce1c65a8dc..e01999c2279 100644 --- a/app/views/shared/integrations/mattermost_slash_commands/_help.html.haml +++ b/app/views/shared/integrations/mattermost_slash_commands/_help.html.haml @@ -4,7 +4,7 @@ .well-segment %p = s_("MattermostService|Use this service to perform common tasks in your project by entering slash commands in Mattermost.") - = link_to help_page_path('user/project/integrations/mattermost_slash_commands.md'), target: '_blank' do + = link_to help_page_path('user/project/integrations/mattermost_slash_commands'), target: '_blank' do = _("How do I configure this integration?") = sprite_icon('external-link') %p.inline diff --git a/app/views/shared/integrations/slack_slash_commands/_help.html.haml b/app/views/shared/integrations/slack_slash_commands/_help.html.haml index fd30c5b0da3..0440bb13797 100644 --- a/app/views/shared/integrations/slack_slash_commands/_help.html.haml +++ b/app/views/shared/integrations/slack_slash_commands/_help.html.haml @@ -5,7 +5,7 @@ .well-segment %p = s_("SlackService|Perform common operations in this project by entering slash commands in Slack.") - = link_to help_page_path('user/project/integrations/slack_slash_commands.md'), target: '_blank' do + = link_to help_page_path('user/project/integrations/slack_slash_commands'), target: '_blank' do = _("Learn more.") = sprite_icon('external-link') %p.inline @@ -40,7 +40,7 @@ .col-12.input-group = text_field_tag :url, integration_trigger_url(integration), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#url', category: :primary, size: :medium) + = clipboard_button(target: '#url', category: :primary, size: :medium, title: _('Copy URL')) .form-group = label_tag nil, _('Method'), class: 'col-12 col-form-label label-bold' @@ -51,7 +51,7 @@ .col-12.input-group = text_field_tag :customize_name, 'GitLab', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#customize_name', category: :primary, size: :medium) + = clipboard_button(target: '#customize_name', category: :primary, size: :medium, title: _('Copy customize name')) .form-group = label_tag nil, _('Customize icon'), class: 'col-12 col-form-label label-bold' @@ -68,21 +68,21 @@ .col-12.input-group = text_field_tag :autocomplete_description, run_actions_text.html_safe, class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_description', category: :primary, size: :medium) + = clipboard_button(target: '#autocomplete_description', category: :primary, size: :medium, title: _('Copy autocomplete description')) .form-group = label_tag :autocomplete_usage_hint, _('Autocomplete usage hint'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :autocomplete_usage_hint, '[help]', class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#autocomplete_usage_hint', category: :primary, size: :medium) + = clipboard_button(target: '#autocomplete_usage_hint', category: :primary, size: :medium, title: _('Copy autocomplete usage hint')) .form-group = label_tag :descriptive_label, _('Descriptive label'), class: 'col-12 col-form-label label-bold' .col-12.input-group = text_field_tag :descriptive_label, _('Perform common operations on GitLab project'), class: 'form-control form-control-sm', readonly: 'readonly' .input-group-append - = clipboard_button(target: '#descriptive_label', category: :primary, size: :medium) + = clipboard_button(target: '#descriptive_label', category: :primary, size: :medium, title: _('Copy descriptive label')) %hr diff --git a/app/views/shared/issuable/_assignees.html.haml b/app/views/shared/issuable/_assignees.html.haml index 5326b26d655..1ae9ce4eecd 100644 --- a/app/views/shared/issuable/_assignees.html.haml +++ b/app/views/shared/issuable/_assignees.html.haml @@ -7,5 +7,5 @@ = link_to_member(@project, assignee, name: false, title: s_("MrList|Assigned to %{name}") % { name: assignee.name}) - if more_assignees_count > 0 - %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old', qa_selector: 'avatar_counter_content' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} } + %span{ class: 'avatar-counter has-tooltip', data: { container: 'body', placement: 'bottom', 'line-type' => 'old' }, title: _("+%{more_assignees_count} more assignees") % { more_assignees_count: more_assignees_count} } = _("+%{more_assignees_count}") % { more_assignees_count: more_assignees_count} diff --git a/app/views/shared/issuable/_nav.html.haml b/app/views/shared/issuable/_nav.html.haml index 4a33f625347..c48f51dc9bc 100644 --- a/app/views/shared/issuable/_nav.html.haml +++ b/app/views/shared/issuable/_nav.html.haml @@ -11,7 +11,7 @@ = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[:state] == 'closed', id: 'state-closed', title: _('Filter by merge requests that are currently closed and unmerged.'), data: { state: 'closed' } do #{issuables_state_counter_text(type, :closed, display_count)} - else - = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[:state] == 'closed', id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed', qa_selector: 'closed_issues_link' } do + = gl_tab_link_to page_filter_path(state: 'closed'), item_active: params[:state] == 'closed', id: 'state-closed', title: _('Filter by issues that are currently closed.'), data: { state: 'closed' } do #{issuables_state_counter_text(type, :closed, display_count)} = render 'shared/issuable/nav_links/all', page_context_word: page_context_word, counter: issuables_state_counter_text(type, :all, display_count) diff --git a/app/views/shared/issuable/_search_bar.html.haml b/app/views/shared/issuable/_search_bar.html.haml index 86aaa5128a8..52c8a4d4123 100644 --- a/app/views/shared/issuable/_search_bar.html.haml +++ b/app/views/shared/issuable/_search_bar.html.haml @@ -185,6 +185,11 @@ %li.filter-dropdown-item %button.btn.btn-link.js-data-value.monospace {{title}} + #js-dropdown-source-branch.filtered-search-input-dropdown-menu.dropdown-menu + %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } + %li.filter-dropdown-item + %button.btn.btn-link.js-data-value.monospace + {{title}} #js-dropdown-environment.filtered-search-input-dropdown-menu.dropdown-menu %ul.filter-dropdown{ data: { dynamic: true, dropdown: true } } %li.filter-dropdown-item diff --git a/app/views/shared/issuable/_sidebar.html.haml b/app/views/shared/issuable/_sidebar.html.haml index 1392c7ab89f..f018e4f122e 100644 --- a/app/views/shared/issuable/_sidebar.html.haml +++ b/app/views/shared/issuable/_sidebar.html.haml @@ -16,7 +16,7 @@ %aside.right-sidebar.js-right-sidebar.js-issuable-sidebar{ data: { always_show_toggle: true, signed: { in: signed_in }, issuable_type: issuable_type }, class: "#{sidebar_gutter_collapsed_class(is_merge_request_with_flag)} #{'right-sidebar-merge-requests' if is_merge_request_with_flag}", 'aria-live' => 'polite', 'aria-label': issuable_type } .issuable-sidebar{ class: "#{'is-merge-request' if is_merge_request_with_flag}" } .issuable-sidebar-header{ class: "gl-pb-4! #{'gl-pb-2! gl-md-display-flex gl-justify-content-end gl-lg-display-none!' if is_merge_request_with_flag}" } - = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled}", type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do + = render Pajamas::ButtonComponent.new(button_options: { class: "gutter-toggle float-right js-sidebar-toggle has-tooltip gl-shadow-none! #{'gl-display-block' if moved_sidebar_enabled} #{'gl-mt-2' if notifications_todos_buttons_enabled?}" , type: 'button', 'aria-label' => _('Toggle sidebar'), title: sidebar_gutter_tooltip_text, data: { container: 'body', placement: 'left', boundary: 'viewport' } }) do = sidebar_gutter_toggle_icon - if signed_in - if !is_merge_request_with_flag diff --git a/app/views/shared/issuable/form/_type_selector.html.haml b/app/views/shared/issuable/form/_type_selector.html.haml index 0bcdcb9e963..89a07444d9f 100644 --- a/app/views/shared/issuable/form/_type_selector.html.haml +++ b/app/views/shared/issuable/form/_type_selector.html.haml @@ -10,6 +10,6 @@ - if issuable.incident_type_issue? %p.form-text.text-muted - - incident_docs_url = help_page_path('operations/incident_management/incidents.md') + - incident_docs_url = help_page_path('operations/incident_management/incidents') - incident_docs_start = format('<a href="%{url}" target="_blank" rel="noopener noreferrer">', url: incident_docs_url) = format(_('A %{incident_docs_start}modified issue%{incident_docs_end} to guide the resolution of incidents.'), incident_docs_start: incident_docs_start, incident_docs_end: '</a>').html_safe diff --git a/app/views/shared/milestones/_sidebar.html.haml b/app/views/shared/milestones/_sidebar.html.haml index 7d1e9c06966..2e2c0300ae1 100644 --- a/app/views/shared/milestones/_sidebar.html.haml +++ b/app/views/shared/milestones/_sidebar.html.haml @@ -161,11 +161,10 @@ - milestone_ref = milestone.try(:to_reference, full: true) - if milestone_ref.present? .block.reference - .sidebar-collapsed-icon.js-dont-change-state - = deprecated_clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') + = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport', class: 'sidebar-collapsed-icon js-dont-change-state') .gl-display-flex.gl-align-items-center.gl-justify-content-space-between.gl-mb-2.hide-collapsed %span.gl-overflow-hidden.gl-text-overflow-ellipsis.gl-white-space-nowrap = s_('MilestoneSidebar|Reference:') %span{ title: milestone_ref } = milestone_ref - = deprecated_clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') + = clipboard_button(text: milestone_ref, title: s_('MilestoneSidebar|Copy reference'), placement: "left", boundary: 'viewport') diff --git a/app/views/shared/notes/_notes_with_form.html.haml b/app/views/shared/notes/_notes_with_form.html.haml index 336fdedf89b..343a8597444 100644 --- a/app/views/shared/notes/_notes_with_form.html.haml +++ b/app/views/shared/notes/_notes_with_form.html.haml @@ -15,12 +15,12 @@ .timeline-content.timeline-content-form = render "shared/notes/form", view: diff_view, supports_autocomplete: autocomplete - elsif !current_user - .disabled-comment.text-center.gl-mt-3 + .disabled-comment.gl-text-center.gl-text-secondary.gl-mt-3 - link_to_register = link_to(_("register"), new_user_registration_path(redirect_to_referer: 'yes'), class: 'js-register-link') - link_to_sign_in = link_to(_("sign in"), new_session_path(:user, redirect_to_referer: 'yes'), class: 'js-sign-in-link') = _("Please %{link_to_register} or %{link_to_sign_in} to comment").html_safe % { link_to_register: link_to_register, link_to_sign_in: link_to_sign_in } - elsif discussion_locked - .disabled-comment.text-center.gl-mt-3 + .disabled-comment.gl-text-center.gl-mt-3 %span.issuable-note-warning = sprite_icon('lock', css_class: 'icon') %span diff --git a/app/views/shared/projects/_list.html.haml b/app/views/shared/projects/_list.html.haml index 14785870dc0..74c325383a1 100644 --- a/app/views/shared/projects/_list.html.haml +++ b/app/views/shared/projects/_list.html.haml @@ -32,6 +32,7 @@ - if any_projects?(projects) - load_pipeline_status(projects) if pipeline_status - load_max_project_member_accesses(projects) # Prime cache used in shared/projects/project view rendered below + - load_catalog_resources(projects) %ul.projects-list.gl-text-secondary.gl-w-full.gl-my-2{ class: css_classes } - projects.each_with_index do |project, i| - css_class = (i >= projects_limit) || project.pending_delete? ? 'hide' : nil diff --git a/app/views/shared/projects/_project.html.haml b/app/views/shared/projects/_project.html.haml index 2de4a9d7780..e65dcd68f66 100644 --- a/app/views/shared/projects/_project.html.haml +++ b/app/views/shared/projects/_project.html.haml @@ -35,7 +35,10 @@ %span.project-name< = project.name - = visibility_level_content(project, css_class: 'gl-mr-3') + = visibility_level_content(project, css_class: 'gl-mr-2') + + - if project.catalog_resource + = render partial: 'shared/ci_catalog_badge', locals: { href: project_ci_catalog_resource_path(project, project.catalog_resource), css_class: 'gl-mr-2' } - if explore_projects_tab? && project_license_name(project) %span.gl-display-inline-flex.gl-align-items-center.gl-mr-3 diff --git a/app/views/shared/runners/_shared_runners_description.html.haml b/app/views/shared/runners/_shared_runners_description.html.haml index c8ddb5d5176..89da1a6fa09 100644 --- a/app/views/shared/runners/_shared_runners_description.html.haml +++ b/app/views/shared/runners/_shared_runners_description.html.haml @@ -1,4 +1,4 @@ -- shared_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('ci/runners/runners_scope.md', anchor: 'shared-runners') } +- shared_link_start = '<a href="%{url}" target="_blank" rel="noopener noreferrer">'.html_safe % { url: help_page_path('ci/runners/runners_scope', anchor: 'shared-runners') } %h4 = _('Shared runners') diff --git a/app/views/shared/web_hooks/_form.html.haml b/app/views/shared/web_hooks/_form.html.haml index 7c713e63cd7..a3dfc6eb042 100644 --- a/app/views/shared/web_hooks/_form.html.haml +++ b/app/views/shared/web_hooks/_form.html.haml @@ -66,7 +66,7 @@ help_text: s_('Webhooks|A release is created, updated, or deleted.') - if Feature.enabled?(:emoji_webhooks, hook.parent) %li.gl-pb-5 - - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events.md', anchor: 'emoji-events') + - emoji_help_link = link_to s_('Which emoji events trigger webhooks'), help_page_path('user/project/integrations/webhook_events', anchor: 'emoji-events') = form.gitlab_ui_checkbox_component :emoji_events, integration_webhook_event_human_name(:emoji_events), help_text: s_('Webhooks|An emoji is awarded or revoked. %{help_link}?').html_safe % { help_link: emoji_help_link } diff --git a/app/views/shared/wikis/_wiki_directory.html.haml b/app/views/shared/wikis/_wiki_directory.html.haml index cce81257691..8b0b6dbd8f7 100644 --- a/app/views/shared/wikis/_wiki_directory.html.haml +++ b/app/views/shared/wikis/_wiki_directory.html.haml @@ -1,12 +1,12 @@ - wiki_path = wiki_page_path(@wiki, wiki_directory) -%li{ class: active_when(params[:id] == wiki_directory.slug), data: { testid: 'wiki-directory-content' } } +%li{ class: ['wiki-directory', active_when(params[:id] == wiki_directory.slug)], data: { testid: 'wiki-directory-content' } } .gl-relative.gl-display-flex.gl-align-items-center.js-wiki-list-toggle.wiki-list{ data: { testid: 'wiki-list' } }< = sprite_icon('chevron-right', css_class: 'js-wiki-list-expand-button wiki-list-expand-button gl-mr-2 gl-cursor-pointer') = sprite_icon('chevron-down', css_class: 'js-wiki-list-collapse-button wiki-list-collapse-button gl-mr-2 gl-cursor-pointer') = render Pajamas::ButtonComponent.new(icon: 'plus', href: "#{wiki_path}/{new_page_title}", button_options: { class: 'wiki-list-create-child-button gl-bg-transparent! gl-hover-bg-gray-50! gl-focus-bg-gray-50! gl-absolute gl-top-half gl-translate-y-n50 gl-cursor-pointer gl-right-3' }) = link_to wiki_path, data: { testid: 'wiki-dir-page-link', qa_page_name: wiki_directory.title } do = wiki_directory.title - %ul + %ul.gl-pl-8 - wiki_directory.entries.each do |entry| = render partial: entry.to_partial_path, object: entry, locals: { context: context } diff --git a/app/views/shared/wikis/show.html.haml b/app/views/shared/wikis/show.html.haml index 9537d6fec15..2cd03c20080 100644 --- a/app/views/shared/wikis/show.html.haml +++ b/app/views/shared/wikis/show.html.haml @@ -12,8 +12,7 @@ .nav-controls.pb-md-3.pb-lg-0 = render 'shared/wikis/main_links' - - if Feature.enabled?(:print_wiki, current_user) - #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } } + #js-export-actions{ data: { options: { target: '.js-wiki-page-content', title: @page.human_title, stylesheet: [stylesheet_path('application')] }.to_json } } - if @page.historical? = render Pajamas::AlertComponent.new(variant: :warning, diff --git a/app/views/users/_cover_controls.html.haml b/app/views/users/_cover_controls.html.haml deleted file mode 100644 index 899a08c8a17..00000000000 --- a/app/views/users/_cover_controls.html.haml +++ /dev/null @@ -1,2 +0,0 @@ -.cover-controls.gl-display-flex.gl-gap-3.gl-pb-4 - = yield diff --git a/app/views/users/_overview.html.haml b/app/views/users/_overview.html.haml index 3649f72c956..597e7c37388 100644 --- a/app/views/users/_overview.html.haml +++ b/app/views/users/_overview.html.haml @@ -1,4 +1,4 @@ -- activity_pane_class = Feature.enabled?(:security_auto_fix) && @user.bot? ? "col-12" : "col-md-12 col-lg-6" +- activity_pane_class = Feature.enabled?(:security_auto_fix) && @user.bot? ? "col-12" : "col-md-12 col-lg-6 gl-align-self-start" .row.d-none.d-sm-flex .col-12.calendar-block.gl-my-3 @@ -33,7 +33,7 @@ %h4.gl-flex-grow-1 = Feature.enabled?(:security_auto_fix) && @user.bot? ? s_('UserProfile|Bot activity') : s_('UserProfile|Activity') = link_to s_('UserProfile|View all'), user_activity_path, class: "hide js-view-all" - .overview-content-list{ data: { href: user_activity_path, testid: 'user-activity-content' } } + .overview-content-list.user-activity-content{ data: { href: user_activity_path, testid: 'user-activity-content' } } = gl_loading_icon(size: 'md', css_class: 'loading') - unless Feature.enabled?(:security_auto_fix) && @user.bot? diff --git a/app/views/users/_profile_basic_info.html.haml b/app/views/users/_profile_basic_info.html.haml index 6de9e80008e..7dd131dbe2c 100644 --- a/app/views/users/_profile_basic_info.html.haml +++ b/app/views/users/_profile_basic_info.html.haml @@ -2,9 +2,5 @@ = render 'middle_dot_divider', stacking: true do @#{@user.username} - if can?(current_user, :read_user_profile, @user) - - unless Feature.enabled?(:user_profile_overflow_menu_vue) - = render 'middle_dot_divider', stacking: true do - = s_('UserProfile|User ID: %{id}') % { id: @user.id } - = clipboard_button(title: s_('UserProfile|Copy user ID'), text: @user.id) = render 'middle_dot_divider', stacking: true do = s_('Member since %{date}') % { date: l(@user.created_at.to_date, format: :long) } diff --git a/app/views/users/show.html.haml b/app/views/users/show.html.haml index 0881c5bba54..e23555428aa 100644 --- a/app/views/users/show.html.haml +++ b/app/views/users/show.html.haml @@ -17,32 +17,16 @@ .user-profile .cover-block.user-cover-block.gl-border-t.gl-border-b.gl-mt-n1 %div{ class: container_class } - - if Feature.enabled?(:user_profile_overflow_menu_vue) - .cover-controls.gl-display-flex.gl-gap-3.gl-pb-4 - = render 'users/follow_user' - -# The following edit button is mutually exclusive to the follow user button, they won't be shown together - - if @user == current_user - = render Pajamas::ButtonComponent.new(href: profile_path, - button_options: { class: 'gl-flex-grow-1', title: s_('UserProfile|Edit profile') }) do - = s_("UserProfile|Edit profile") - = render 'users/view_gpg_keys' - = render 'users/view_user_in_admin_area' - .js-user-profile-actions{ data: user_profile_actions_data(@user) } - - else - = render layout: 'users/cover_controls' do - - if @user == current_user - = render Pajamas::ButtonComponent.new(href: profile_path, - icon: 'pencil', - button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|Edit profile'), 'aria-label': 'Edit profile', data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - - elsif current_user - #js-report-abuse{ data: { report_abuse_path: add_category_abuse_reports_path, reported_user_id: @user.id, reported_from_url: user_url(@user) } } - = render 'users/view_gpg_keys' - - if can?(current_user, :read_user_profile, @user) - = render Pajamas::ButtonComponent.new(href: user_path(@user, rss_url_options), - icon: 'rss', - button_options: { class: 'gl-flex-grow-1 has-tooltip', title: s_('UserProfile|Subscribe'), data: { toggle: 'tooltip', placement: 'bottom', container: 'body' }}) - = render 'users/view_user_in_admin_area' - = render 'users/follow_user' + .cover-controls.gl-display-flex.gl-gap-3.gl-pb-4 + = render 'users/follow_user' + -# The following edit button is mutually exclusive to the follow user button, they won't be shown together + - if @user == current_user + = render Pajamas::ButtonComponent.new(href: profile_path, + button_options: { class: 'gl-flex-grow-1', title: s_('UserProfile|Edit profile') }) do + = s_("UserProfile|Edit profile") + = render 'users/view_gpg_keys' + = render 'users/view_user_in_admin_area' + .js-user-profile-actions{ data: user_profile_actions_data(@user) } .profile-header{ class: [('with-no-profile-tabs' if profile_tabs.empty?), ('gl-mb-4!' if show_super_sidebar?)] } .gl-display-inline-block.gl-mx-8.gl-vertical-align-top @@ -111,6 +95,10 @@ = render 'middle_dot_divider', breakpoint: 'sm' do = link_to discord_url(@user), class: 'gl-hover-text-decoration-none', title: "Discord", target: '_blank', rel: 'noopener noreferrer nofollow' do = sprite_icon('discord', css_class: 'discord-icon') + - if Feature.enabled?(:mastodon_social_ui, @user) && @user.mastodon.present? + = render 'middle_dot_divider', breakpoint: 'sm' do + = link_to mastodon_url(@user), class: 'gl-hover-text-decoration-none', title: "Mastodon", target: '_blank', rel: 'noopener noreferrer nofollow' do + = sprite_icon('mastodon', css_class: 'mastodon-icon') - if @user.website_url.present? = render 'middle_dot_divider', stacking: true do - if Feature.enabled?(:security_auto_fix) && @user.bot? @@ -126,6 +114,8 @@ %p.profile-user-bio.gl-mb-3 = @user.bio + -# TODO: Remove this with the removal of the old navigation. + -# See https://gitlab.com/groups/gitlab-org/-/epics/11875. - if !profile_tabs.empty? && !Feature.enabled?(:profile_tabs_vue, current_user) .scrolling-tabs-container{ class: [('gl-display-none' if show_super_sidebar?)] } %button.fade-left{ type: 'button', title: _('Scroll left'), 'aria-label': _('Scroll left') } @@ -169,7 +159,7 @@ = gl_badge_tag @user.followers.count, size: :sm - if profile_tab?(:following) %li.js-following-tab - = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json), qa_selector: 'following_tab' } do + = link_to user_following_path, data: { target: 'div#following', action: 'following', toggle: 'tab', endpoint: user_following_path(format: :json) } do = s_('UserProfile|Following') = gl_badge_tag @user.followees.count, size: :sm - if !profile_tabs.empty? && Feature.enabled?(:profile_tabs_vue, current_user) @@ -183,13 +173,15 @@ - if profile_tab?(:activity) #activity.tab-pane - .flash-container - - if can?(current_user, :read_cross_project) - %h4.prepend-top-20 - = s_('UserProfile|Most Recent Activity') - .content_list{ data: { href: user_activity_path } } - .loading - = gl_loading_icon(size: 'md') + .row + .col-12 + .flash-container + - if can?(current_user, :read_cross_project) + %h4.prepend-top-20 + = s_('UserProfile|Most Recent Activity') + .content_list.user-activity-content{ data: { href: user_activity_path } } + .loading + = gl_loading_icon(size: 'md') - unless @user.bot? - if profile_tab?(:groups) #groups.tab-pane diff --git a/app/workers/abuse/spam_abuse_events_worker.rb b/app/workers/abuse/spam_abuse_events_worker.rb new file mode 100644 index 00000000000..7d86e994ae4 --- /dev/null +++ b/app/workers/abuse/spam_abuse_events_worker.rb @@ -0,0 +1,60 @@ +# frozen_string_literal: true + +module Abuse + class SpamAbuseEventsWorker + include ApplicationWorker + + data_consistency :delayed + + idempotent! + feature_category :instance_resiliency + urgency :low + + def perform(params) + params = params.with_indifferent_access + + @user = User.find_by_id(params[:user_id]) + unless @user + logger.info(structured_payload(message: "User not found.", user_id: params[:user_id])) + return + end + + report_user(params) + end + + private + + attr_reader :user + + def report_user(params) + category = 'spam' + reporter = Users::Internal.security_bot + report_params = { user_id: params[:user_id], + reporter: reporter, + category: category, + message: 'User reported for abuse based on spam verdict' } + + abuse_report = AbuseReport.by_category(category).by_reporter_id(reporter.id).by_user_id(params[:user_id]).first + + abuse_report = AbuseReport.create!(report_params) if abuse_report.nil? + + create_abuse_event(abuse_report.id, params) + end + + # Associate the abuse report with an abuse event + def create_abuse_event(abuse_report_id, params) + Abuse::Event.create!( + abuse_report_id: abuse_report_id, + category: :spam, + metadata: { noteable_type: params[:noteable_type], + title: params[:title], + description: params[:description], + source_ip: params[:source_ip], + user_agent: params[:user_agent], + verdict: params[:verdict] }, + source: :spamcheck, + user: user + ) + end + end +end diff --git a/app/workers/activity_pub/projects/releases_subscription_worker.rb b/app/workers/activity_pub/projects/releases_subscription_worker.rb new file mode 100644 index 00000000000..c392726a469 --- /dev/null +++ b/app/workers/activity_pub/projects/releases_subscription_worker.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module ActivityPub + module Projects + class ReleasesSubscriptionWorker + include ApplicationWorker + include Gitlab::Routing.url_helpers + + idempotent! + worker_has_external_dependencies! + feature_category :release_orchestration + data_consistency :delayed + queue_namespace :activity_pub + + sidekiq_retries_exhausted do |msg, _ex| + subscription_id = msg['args'].second + subscription = ActivityPub::ReleasesSubscription.find_by_id(subscription_id) + subscription&.destroy + end + + def perform(subscription_id) + subscription = ActivityPub::ReleasesSubscription.find_by_id(subscription_id) + return if subscription.nil? + + unless subscription.project.public? + subscription.destroy + return + end + + InboxResolverService.new(subscription).execute if needs_resolving?(subscription) + AcceptFollowService.new(subscription, project_releases_url(subscription.project)).execute + end + + def needs_resolving?(subscription) + subscription.subscriber_inbox_url.blank? || subscription.shared_inbox_url.blank? + end + end + end +end diff --git a/app/workers/all_queues.yml b/app/workers/all_queues.yml index e5b860ba525..0bb88efe183 100644 --- a/app/workers/all_queues.yml +++ b/app/workers/all_queues.yml @@ -3,6 +3,15 @@ # # Do not edit it manually! --- +- :name: activity_pub:activity_pub_projects_releases_subscription + :worker_name: ActivityPub::Projects::ReleasesSubscriptionWorker + :feature_category: :release_orchestration + :has_external_dependencies: true + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: authorized_project_update:authorized_project_update_project_recalculate :worker_name: AuthorizedProjectUpdate::ProjectRecalculateWorker :feature_category: :system_access @@ -1461,42 +1470,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: hashed_storage:hashed_storage_migrator - :worker_name: HashedStorage::MigratorWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] -- :name: hashed_storage:hashed_storage_project_migrate - :worker_name: HashedStorage::ProjectMigrateWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] -- :name: hashed_storage:hashed_storage_project_rollback - :worker_name: HashedStorage::ProjectRollbackWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] -- :name: hashed_storage:hashed_storage_rollbacker - :worker_name: HashedStorage::RollbackerWorker - :feature_category: :source_code_management - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :unknown - :weight: 1 - :idempotent: false - :tags: [] - :name: incident_management:incident_management_add_severity_system_note :worker_name: IncidentManagement::AddSeveritySystemNoteWorker :feature_category: :incident_management @@ -1767,6 +1740,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: package_cleanup:packages_npm_cleanup_stale_metadata_cache + :worker_name: Packages::Npm::CleanupStaleMetadataCacheWorker + :feature_category: :package_registry + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: package_repositories:packages_debian_generate_distribution :worker_name: Packages::Debian::GenerateDistributionWorker :feature_category: :package_registry @@ -2307,6 +2289,15 @@ :weight: 1 :idempotent: true :tags: [] +- :name: abuse_spam_abuse_events + :worker_name: Abuse::SpamAbuseEventsWorker + :feature_category: :instance_resiliency + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: analytics_usage_trends_counter_job :worker_name: Analytics::UsageTrends::CounterJobWorker :feature_category: :devops_reports @@ -2575,7 +2566,7 @@ :urgency: :low :resource_boundary: :unknown :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: bulk_imports_entity :worker_name: BulkImports::EntityWorker @@ -2629,7 +2620,7 @@ :urgency: :low :resource_boundary: :memory :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: bulk_imports_pipeline_batch :worker_name: BulkImports::PipelineBatchWorker @@ -2638,7 +2629,7 @@ :urgency: :low :resource_boundary: :memory :weight: 1 - :idempotent: false + :idempotent: true :tags: [] - :name: bulk_imports_relation_batch_export :worker_name: BulkImports::RelationBatchExportWorker @@ -2892,6 +2883,15 @@ :weight: 2 :idempotent: false :tags: [] +- :name: environments_auto_recover + :worker_name: Environments::AutoRecoverWorker + :feature_category: :continuous_delivery + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: environments_auto_stop :worker_name: Environments::AutoStopWorker :feature_category: :continuous_delivery @@ -3567,6 +3567,15 @@ :weight: 1 :idempotent: false :tags: [] +- :name: projects_import_export_after_import_merge_requests + :worker_name: Projects::ImportExport::AfterImportMergeRequestsWorker + :feature_category: :importers + :has_external_dependencies: false + :urgency: :low + :resource_boundary: :unknown + :weight: 1 + :idempotent: true + :tags: [] - :name: projects_import_export_create_relation_exports :worker_name: Projects::ImportExport::CreateRelationExportsWorker :feature_category: :importers @@ -3837,15 +3846,6 @@ :weight: 1 :idempotent: false :tags: [] -- :name: tasks_to_be_done_create - :worker_name: TasksToBeDone::CreateWorker - :feature_category: :onboarding - :has_external_dependencies: false - :urgency: :low - :resource_boundary: :cpu - :weight: 1 - :idempotent: true - :tags: [] - :name: update_external_pull_requests :worker_name: UpdateExternalPullRequestsWorker :feature_category: :continuous_integration diff --git a/app/workers/bulk_import_worker.rb b/app/workers/bulk_import_worker.rb index 5b9b46081cc..70e7d82741f 100644 --- a/app/workers/bulk_import_worker.rb +++ b/app/workers/bulk_import_worker.rb @@ -1,11 +1,16 @@ # frozen_string_literal: true -class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker +class BulkImportWorker include ApplicationWorker data_consistency :always feature_category :importers - sidekiq_options retry: false, dead: false + sidekiq_options retry: 3, dead: false + idempotent! + + sidekiq_retries_exhausted do |msg, exception| + new.perform_failure(exception, msg['args'].first) + end def perform(bulk_import_id) bulk_import = BulkImport.find_by_id(bulk_import_id) @@ -13,4 +18,12 @@ class BulkImportWorker # rubocop:disable Scalability/IdempotentWorker BulkImports::ProcessService.new(bulk_import).execute end + + def perform_failure(exception, bulk_import_id) + bulk_import = BulkImport.find_by_id(bulk_import_id) + + Gitlab::ErrorTracking.track_exception(exception, bulk_import_id: bulk_import.id) + + bulk_import.fail_op + end end diff --git a/app/workers/bulk_imports/entity_worker.rb b/app/workers/bulk_imports/entity_worker.rb index 9b60dcdeb8a..e510a8c0d06 100644 --- a/app/workers/bulk_imports/entity_worker.rb +++ b/app/workers/bulk_imports/entity_worker.rb @@ -5,12 +5,16 @@ module BulkImports include ApplicationWorker idempotent! - deduplicate :until_executed + deduplicate :until_executed, if_deduplicated: :reschedule_once data_consistency :always feature_category :importers - sidekiq_options retry: false, dead: false + sidekiq_options retry: 3, dead: false worker_has_external_dependencies! + sidekiq_retries_exhausted do |msg, exception| + new.perform_failure(exception, msg['args'].first) + end + PERFORM_DELAY = 5.seconds # Keep `_current_stage` parameter for backwards compatibility. @@ -27,10 +31,17 @@ module BulkImports end re_enqueue - rescue StandardError => e - Gitlab::ErrorTracking.track_exception(e, log_params(message: 'Entity failed')) + end + + def perform_failure(exception, entity_id) + @entity = ::BulkImports::Entity.find(entity_id) + + Gitlab::ErrorTracking.track_exception( + exception, + log_params(message: "Request to export #{entity.source_type} failed") + ) - @entity.fail_op! + entity.fail_op! end private @@ -68,7 +79,7 @@ module BulkImports end def logger - @logger ||= Gitlab::Import::Logger.build + @logger ||= Logger.build end def log_exception(exception, payload) @@ -88,7 +99,7 @@ module BulkImports bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, source_version: source_version, - importer: 'gitlab_migration' + importer: Logger::IMPORTER_NAME } defaults.merge(extra) diff --git a/app/workers/bulk_imports/export_request_worker.rb b/app/workers/bulk_imports/export_request_worker.rb index 44759916f99..f7456ddccb1 100644 --- a/app/workers/bulk_imports/export_request_worker.rb +++ b/app/workers/bulk_imports/export_request_worker.rb @@ -80,8 +80,7 @@ module BulkImports bulk_import_id: entity.bulk_import_id, bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, - source_version: entity.bulk_import.source_version_info.to_s, - importer: 'gitlab_migration' + source_version: entity.bulk_import.source_version_info.to_s } ) @@ -97,7 +96,7 @@ module BulkImports end def logger - @logger ||= Gitlab::Import::Logger.build + @logger ||= Logger.build end def log_exception(exception, payload) @@ -114,8 +113,7 @@ module BulkImports bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, message: "Request to export #{entity.source_type} failed", - source_version: entity.bulk_import.source_version_info.to_s, - importer: 'gitlab_migration' + source_version: entity.bulk_import.source_version_info.to_s } ) diff --git a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb index b1f3757e058..40d26e14dc1 100644 --- a/app/workers/bulk_imports/finish_batched_pipeline_worker.rb +++ b/app/workers/bulk_imports/finish_batched_pipeline_worker.rb @@ -16,22 +16,21 @@ module BulkImports def perform(pipeline_tracker_id) @tracker = Tracker.find(pipeline_tracker_id) + @context = ::BulkImports::Pipeline::Context.new(tracker) return unless tracker.batched? return unless tracker.started? return re_enqueue if import_in_progress? if tracker.stale? + logger.error(log_attributes(message: 'Tracker stale. Failing batches and tracker')) tracker.batches.map(&:fail_op!) tracker.fail_op! else + tracker.pipeline_class.new(@context).on_finish + logger.info(log_attributes(message: 'Tracker finished')) tracker.finish! end - - ensure - # This is needed for in-flight migrations. - # It will be remove in https://gitlab.com/gitlab-org/gitlab/-/issues/426299 - ::BulkImports::EntityWorker.perform_async(tracker.entity.id) if job_version.nil? end private @@ -45,5 +44,20 @@ module BulkImports def import_in_progress? tracker.batches.any? { |b| b.started? || b.created? } end + + def logger + @logger ||= Logger.build + end + + def log_attributes(extra = {}) + structured_payload( + { + tracker_id: tracker.id, + bulk_import_id: tracker.entity.id, + bulk_import_entity_id: tracker.entity.bulk_import_id, + pipeline_class: tracker.pipeline_name + }.merge(extra) + ) + end end end diff --git a/app/workers/bulk_imports/pipeline_batch_worker.rb b/app/workers/bulk_imports/pipeline_batch_worker.rb index 6230d517641..1485275e616 100644 --- a/app/workers/bulk_imports/pipeline_batch_worker.rb +++ b/app/workers/bulk_imports/pipeline_batch_worker.rb @@ -1,26 +1,65 @@ # frozen_string_literal: true module BulkImports - class PipelineBatchWorker # rubocop:disable Scalability/IdempotentWorker + class PipelineBatchWorker include ApplicationWorker include ExclusiveLeaseGuard + DEFER_ON_HEALTH_DELAY = 5.minutes + data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency feature_category :importers - sidekiq_options retry: false, dead: false + sidekiq_options dead: false, retry: 3 worker_has_external_dependencies! worker_resource_boundary :memory + idempotent! + + sidekiq_retries_exhausted do |msg, exception| + new.perform_failure(msg['args'].first, exception) + end + + defer_on_database_health_signal(:gitlab_main, [], DEFER_ON_HEALTH_DELAY) do |job_args, schema, tables| + batch = ::BulkImports::BatchTracker.find(job_args.first) + pipeline_tracker = batch.tracker + pipeline_schema = ::BulkImports::PipelineSchemaInfo.new( + pipeline_tracker.pipeline_class, + pipeline_tracker.entity.portable_class + ) + + if pipeline_schema.db_schema && pipeline_schema.db_table + schema = pipeline_schema.db_schema + tables = [pipeline_schema.db_table] + end + + [schema, tables] + end + + def self.defer_on_database_health_signal? + Feature.enabled?(:bulk_import_deferred_workers) + end def perform(batch_id) @batch = ::BulkImports::BatchTracker.find(batch_id) + @tracker = @batch.tracker @pending_retry = false + return unless process_batch? + + log_extra_metadata_on_done(:pipeline_class, @tracker.pipeline_name) + try_obtain_lease { run } ensure ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) unless pending_retry end + def perform_failure(batch_id, exception) + @batch = ::BulkImports::BatchTracker.find(batch_id) + @tracker = @batch.tracker + + fail_batch(exception) + end + private attr_reader :batch, :tracker, :pending_retry @@ -28,35 +67,31 @@ module BulkImports def run return batch.skip! if tracker.failed? || tracker.finished? + logger.info(log_attributes(message: 'Batch tracker started')) batch.start! tracker.pipeline_class.new(context).run batch.finish! + logger.info(log_attributes(message: 'Batch tracker finished')) rescue BulkImports::RetryPipelineError => e @pending_retry = true retry_batch(e) - rescue StandardError => e - fail_batch(e) end def fail_batch(exception) batch.fail_op! - Gitlab::ErrorTracking.track_exception( - exception, - batch_id: batch.id, - tracker_id: tracker.id, - pipeline_class: tracker.pipeline_name, - pipeline_step: 'pipeline_batch_worker_run' - ) + Gitlab::ErrorTracking.track_exception(exception, log_attributes(message: 'Batch tracker failed')) BulkImports::Failure.create( bulk_import_entity_id: batch.tracker.entity.id, pipeline_class: tracker.pipeline_name, pipeline_step: 'pipeline_batch_worker_run', exception_class: exception.class.to_s, - exception_message: exception.message.truncate(255), + exception_message: exception.message, correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id ) + + ::BulkImports::FinishBatchedPipelineWorker.perform_async(tracker.id) end def context @@ -78,7 +113,32 @@ module BulkImports end def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY) + log_extra_metadata_on_done(:re_enqueue, true) + self.class.perform_in(delay, batch.id) end + + def process_batch? + batch.created? || batch.started? + end + + def logger + @logger ||= Logger.build + end + + def log_attributes(extra = {}) + structured_payload( + { + batch_id: batch.id, + batch_number: batch.batch_number, + tracker_id: tracker.id, + bulk_import_id: tracker.entity.bulk_import_id, + bulk_import_entity_id: tracker.entity.id, + pipeline_class: tracker.pipeline_name, + pipeline_step: 'pipeline_batch_worker_run', + importer: Logger::IMPORTER_NAME + }.merge(extra) + ) + end end end diff --git a/app/workers/bulk_imports/pipeline_worker.rb b/app/workers/bulk_imports/pipeline_worker.rb index 24185f43795..2c1d28b33c5 100644 --- a/app/workers/bulk_imports/pipeline_worker.rb +++ b/app/workers/bulk_imports/pipeline_worker.rb @@ -1,43 +1,68 @@ # frozen_string_literal: true module BulkImports - class PipelineWorker # rubocop:disable Scalability/IdempotentWorker + class PipelineWorker include ApplicationWorker include ExclusiveLeaseGuard FILE_EXTRACTION_PIPELINE_PERFORM_DELAY = 10.seconds + DEFER_ON_HEALTH_DELAY = 5.minutes + data_consistency :always feature_category :importers - sidekiq_options retry: false, dead: false + sidekiq_options dead: false, retry: 3 worker_has_external_dependencies! deduplicate :until_executing worker_resource_boundary :memory + idempotent! version 2 + sidekiq_retries_exhausted do |msg, exception| + new.perform_failure(msg['args'][0], msg['args'][2], exception) + end + + defer_on_database_health_signal(:gitlab_main, [], DEFER_ON_HEALTH_DELAY) do |job_args, schema, tables| + pipeline_tracker = ::BulkImports::Tracker.find(job_args.first) + pipeline_schema = ::BulkImports::PipelineSchemaInfo.new( + pipeline_tracker.pipeline_class, + pipeline_tracker.entity.portable_class + ) + + if pipeline_schema.db_schema && pipeline_schema.db_table + schema = pipeline_schema.db_schema + tables = [pipeline_schema.db_table] + end + + [schema, tables] + end + + def self.defer_on_database_health_signal? + Feature.enabled?(:bulk_import_deferred_workers) + end + # Keep _stage parameter for backwards compatibility. def perform(pipeline_tracker_id, _stage, entity_id) @entity = ::BulkImports::Entity.find(entity_id) @pipeline_tracker = ::BulkImports::Tracker.find(pipeline_tracker_id) + log_extra_metadata_on_done(:pipeline_class, @pipeline_tracker.pipeline_name) + try_obtain_lease do - if pipeline_tracker.enqueued? + if pipeline_tracker.enqueued? || pipeline_tracker.started? logger.info(log_attributes(message: 'Pipeline starting')) run - else - message = "Pipeline in #{pipeline_tracker.human_status_name} state instead of expected enqueued state" - - logger.error(log_attributes(message: message)) - - fail_tracker(StandardError.new(message)) unless pipeline_tracker.finished? || pipeline_tracker.skipped? end end - ensure - # This is needed for in-flight migrations. - # It will be remove in https://gitlab.com/gitlab-org/gitlab/-/issues/426299 - ::BulkImports::EntityWorker.perform_async(entity_id) if job_version.nil? + end + + def perform_failure(pipeline_tracker_id, entity_id, exception) + @entity = ::BulkImports::Entity.find(entity_id) + @pipeline_tracker = ::BulkImports::Tracker.find(pipeline_tracker_id) + + fail_tracker(exception) end private @@ -53,20 +78,22 @@ module BulkImports return re_enqueue if export_empty? || export_started? if file_extraction_pipeline? && export_status.batched? + log_extra_metadata_on_done(:batched, true) + pipeline_tracker.update!(status_event: 'start', jid: jid, batched: true) return pipeline_tracker.finish! if export_status.batches_count < 1 enqueue_batches else + log_extra_metadata_on_done(:batched, false) + pipeline_tracker.update!(status_event: 'start', jid: jid) pipeline_tracker.pipeline_class.new(context).run pipeline_tracker.finish! end rescue BulkImports::RetryPipelineError => e retry_tracker(e) - rescue StandardError => e - fail_tracker(e) end def source_version @@ -85,16 +112,18 @@ module BulkImports pipeline_class: pipeline_tracker.pipeline_name, pipeline_step: 'pipeline_worker_run', exception_class: exception.class.to_s, - exception_message: exception.message.truncate(255), + exception_message: exception.message, correlation_id_value: Labkit::Correlation::CorrelationId.current_or_new_id ) end def logger - @logger ||= Gitlab::Import::Logger.build + @logger ||= Logger.build end def re_enqueue(delay = FILE_EXTRACTION_PIPELINE_PERFORM_DELAY) + log_extra_metadata_on_done(:re_enqueue, true) + self.class.perform_in( delay, pipeline_tracker.id, @@ -159,10 +188,10 @@ module BulkImports bulk_import_entity_type: entity.source_type, source_full_path: entity.source_full_path, pipeline_tracker_id: pipeline_tracker.id, - pipeline_name: pipeline_tracker.pipeline_name, + pipeline_class: pipeline_tracker.pipeline_name, pipeline_tracker_state: pipeline_tracker.human_status_name, source_version: source_version, - importer: 'gitlab_migration' + importer: Logger::IMPORTER_NAME }.merge(extra) ) end diff --git a/app/workers/bulk_imports/relation_batch_export_worker.rb b/app/workers/bulk_imports/relation_batch_export_worker.rb index 4ce36929e15..87ceb775075 100644 --- a/app/workers/bulk_imports/relation_batch_export_worker.rb +++ b/app/workers/bulk_imports/relation_batch_export_worker.rb @@ -7,10 +7,25 @@ module BulkImports idempotent! data_consistency :always # rubocop:disable SidekiqLoadBalancing/WorkerDataConsistency feature_category :importers - sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 3 + + sidekiq_retries_exhausted do |job, exception| + batch = BulkImports::ExportBatch.find(job['args'][1]) + portable = batch.export.portable + + Gitlab::ErrorTracking.track_exception(exception, portable_id: portable.id, portable_type: portable.class.name) + + batch.update!(status_event: 'fail_op', error: exception.message.truncate(255)) + end def perform(user_id, batch_id) - RelationBatchExportService.new(user_id, batch_id).execute + @user = User.find(user_id) + @batch = BulkImports::ExportBatch.find(batch_id) + + log_extra_metadata_on_done(:relation, @batch.export.relation) + log_extra_metadata_on_done(:objects_count, @batch.objects_count) + + RelationBatchExportService.new(@user, @batch).execute end end end diff --git a/app/workers/bulk_imports/relation_export_worker.rb b/app/workers/bulk_imports/relation_export_worker.rb index 531edc6c7a7..168626fee85 100644 --- a/app/workers/bulk_imports/relation_export_worker.rb +++ b/app/workers/bulk_imports/relation_export_worker.rb @@ -10,25 +10,37 @@ module BulkImports loggable_arguments 2, 3 data_consistency :always feature_category :importers - sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION + sidekiq_options status_expiration: StuckExportJobsWorker::EXPORT_JOBS_EXPIRATION, retry: 3 worker_resource_boundary :memory + sidekiq_retries_exhausted do |job, exception| + _user_id, portable_id, portable_type, relation, batched = job['args'] + portable = portable(portable_id, portable_type) + + export = portable.bulk_import_exports.find_by_relation(relation) + + Gitlab::ErrorTracking.track_exception(exception, portable_id: portable_id, portable_type: portable.class.name) + + export.update!(status_event: 'fail_op', error: exception.message.truncate(255), batched: batched) + end + + def self.portable(portable_id, portable_class) + portable_class.classify.constantize.find(portable_id) + end + def perform(user_id, portable_id, portable_class, relation, batched = false) user = User.find(user_id) - portable = portable(portable_id, portable_class) + portable = self.class.portable(portable_id, portable_class) config = BulkImports::FileTransfer.config_for(portable) + log_extra_metadata_on_done(:relation, relation) if Gitlab::Utils.to_boolean(batched) && config.batchable_relation?(relation) + log_extra_metadata_on_done(:batched, true) BatchedRelationExportService.new(user, portable, relation, jid).execute else + log_extra_metadata_on_done(:batched, false) RelationExportService.new(user, portable, relation, jid).execute end end - - private - - def portable(portable_id, portable_class) - portable_class.classify.constantize.find(portable_id) - end end end diff --git a/app/workers/bulk_imports/stuck_import_worker.rb b/app/workers/bulk_imports/stuck_import_worker.rb index 3fa4221728b..6c8569b0aa0 100644 --- a/app/workers/bulk_imports/stuck_import_worker.rb +++ b/app/workers/bulk_imports/stuck_import_worker.rb @@ -14,18 +14,29 @@ module BulkImports def perform BulkImport.stale.find_each do |import| + logger.error(message: 'BulkImport stale', bulk_import_id: import.id) import.cleanup_stale end - BulkImports::Entity.includes(:trackers).stale.find_each do |import| # rubocop: disable CodeReuse/ActiveRecord + BulkImports::Entity.includes(:trackers).stale.find_each do |entity| # rubocop: disable CodeReuse/ActiveRecord ApplicationRecord.transaction do - import.cleanup_stale + logger.error( + message: 'BulkImports::Entity stale', + bulk_import_id: entity.bulk_import_id, + bulk_import_entity_id: entity.id + ) - import.trackers.find_each do |tracker| + entity.cleanup_stale + + entity.trackers.find_each do |tracker| tracker.cleanup_stale end end end end + + def logger + @logger ||= Logger.build + end end end diff --git a/app/workers/ci/cancel_pipeline_worker.rb b/app/workers/ci/cancel_pipeline_worker.rb index 0b2c96e7ace..f099e185629 100644 --- a/app/workers/ci/cancel_pipeline_worker.rb +++ b/app/workers/ci/cancel_pipeline_worker.rb @@ -20,7 +20,7 @@ module Ci pipeline: pipeline, current_user: nil, cascade_to_children: false, - auto_canceled_by_pipeline_id: auto_canceled_by_pipeline_id + auto_canceled_by_pipeline: ::Ci::Pipeline.find_by_id(auto_canceled_by_pipeline_id) ).force_execute end end diff --git a/app/workers/ci/initial_pipeline_process_worker.rb b/app/workers/ci/initial_pipeline_process_worker.rb index 703cae8bf88..8d7a62e5b09 100644 --- a/app/workers/ci/initial_pipeline_process_worker.rb +++ b/app/workers/ci/initial_pipeline_process_worker.rb @@ -17,24 +17,10 @@ module Ci def perform(pipeline_id) Ci::Pipeline.find_by_id(pipeline_id).try do |pipeline| - create_deployments!(pipeline) - Ci::PipelineCreation::StartPipelineService .new(pipeline) .execute end end - - private - - def create_deployments!(pipeline) - return if Feature.enabled?(:create_deployment_only_for_processable_jobs, pipeline.project) - - pipeline.stages.flat_map(&:statuses).each { |build| create_deployment(build) } - end - - def create_deployment(build) - ::Deployments::CreateForJobService.new.execute(build) - end end end diff --git a/app/workers/ci/refs/unlock_previous_pipelines_worker.rb b/app/workers/ci/refs/unlock_previous_pipelines_worker.rb index bf595590cb1..588ec4ce1f0 100644 --- a/app/workers/ci/refs/unlock_previous_pipelines_worker.rb +++ b/app/workers/ci/refs/unlock_previous_pipelines_worker.rb @@ -14,7 +14,9 @@ module Ci def perform(ref_id) ::Ci::Ref.find_by_id(ref_id).try do |ref| - pipeline = ref.last_finished_pipeline + next unless ref.artifacts_locked? + + pipeline = ref.last_unlockable_ci_source_pipeline result = ::Ci::Refs::EnqueuePipelinesToUnlockService.new.execute(ref, before_pipeline: pipeline) log_extra_metadata_on_done(:total_pending_entries, result[:total_pending_entries]) diff --git a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb index f6feb6d1598..316d30d94da 100644 --- a/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb +++ b/app/workers/concerns/gitlab/github_import/rescheduling_methods.rb @@ -52,8 +52,7 @@ module Gitlab job_delay = client.rate_limit_resets_in + calculate_job_delay(enqueued_job_counter) - self.class - .perform_in(job_delay, project.id, hash, notify_key) + self.class.perform_in(job_delay, project.id, hash.deep_stringify_keys, notify_key.to_s) end end end diff --git a/app/workers/concerns/gitlab/github_import/stage_methods.rb b/app/workers/concerns/gitlab/github_import/stage_methods.rb index 80013ff3cd9..5c63c667a03 100644 --- a/app/workers/concerns/gitlab/github_import/stage_methods.rb +++ b/app/workers/concerns/gitlab/github_import/stage_methods.rb @@ -5,6 +5,8 @@ module Gitlab module StageMethods extend ActiveSupport::Concern + MAX_RETRIES_AFTER_INTERRUPTION = 20 + included do include ApplicationWorker @@ -18,6 +20,29 @@ module Gitlab end end + class_methods do + # We can increase the number of times a GitHubImport::Stage worker is retried + # after being interrupted if the importer it executes can restart exactly + # from where it left off. + # + # It is not safe to call this method if the importer loops over its data from + # the beginning when restarted, even if it skips data that is already imported + # inside the loop, as there is a possibility the importer will never reach + # the end of the loop. + # + # Examples of stage workers that call this method are ones that execute services that: + # + # - Continue paging an endpoint from where it left off: + # https://gitlab.com/gitlab-org/gitlab/-/blob/487521cc/lib/gitlab/github_import/parallel_scheduling.rb#L114-117 + # - Continue their loop from where it left off: + # https://gitlab.com/gitlab-org/gitlab/-/blob/024235ec/lib/gitlab/github_import/importer/pull_requests/review_requests_importer.rb#L15 + def resumes_work_when_interrupted! + return unless Feature.enabled?(:github_importer_raise_max_interruptions) + + sidekiq_options max_retries_after_interruption: MAX_RETRIES_AFTER_INTERRUPTION + end + end + # project_id - The ID of the GitLab project to import the data into. def perform(project_id) info(project_id, message: 'starting stage') @@ -54,6 +79,8 @@ module Gitlab # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def try_import(client, project) + project.import_state.refresh_jid_expiration + import(client, project) rescue RateLimitError self.class.perform_in(client.rate_limit_resets_in, project.id) diff --git a/app/workers/concerns/worker_attributes.rb b/app/workers/concerns/worker_attributes.rb index cb09aaf1a6a..28c82a5a38e 100644 --- a/app/workers/concerns/worker_attributes.rb +++ b/app/workers/concerns/worker_attributes.rb @@ -201,10 +201,10 @@ module WorkerAttributes !!get_class_attribute(:big_payload) end - def defer_on_database_health_signal(gitlab_schema, tables = [], delay_by = DEFAULT_DEFER_DELAY) + def defer_on_database_health_signal(gitlab_schema, tables = [], delay_by = DEFAULT_DEFER_DELAY, &block) set_class_attribute( :database_health_check_attrs, - { gitlab_schema: gitlab_schema, tables: tables, delay_by: delay_by } + { gitlab_schema: gitlab_schema, tables: tables, delay_by: delay_by, block: block } ) end diff --git a/app/workers/environments/auto_recover_worker.rb b/app/workers/environments/auto_recover_worker.rb new file mode 100644 index 00000000000..75e86e38f1a --- /dev/null +++ b/app/workers/environments/auto_recover_worker.rb @@ -0,0 +1,22 @@ +# frozen_string_literal: true + +module Environments + class AutoRecoverWorker + include ApplicationWorker + + deduplicate :until_executed + data_consistency :delayed + idempotent! + feature_category :continuous_delivery + + def perform(environment_id, _params = {}) + Environment.find_by_id(environment_id).try do |environment| + next unless environment.long_stopping? + + next unless environment.stop_actions.all?(&:complete?) + + environment.recover_stuck_stopping + end + end + end +end diff --git a/app/workers/environments/auto_stop_cron_worker.rb b/app/workers/environments/auto_stop_cron_worker.rb index 4d6453a85e7..26b18c406e5 100644 --- a/app/workers/environments/auto_stop_cron_worker.rb +++ b/app/workers/environments/auto_stop_cron_worker.rb @@ -13,6 +13,7 @@ module Environments def perform AutoStopService.new.execute + AutoRecoverService.new.execute end end end diff --git a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb index f9952f04e99..a5d085a82c0 100644 --- a/app/workers/gitlab/github_import/stage/import_attachments_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_attachments_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -48,8 +50,8 @@ module Gitlab def move_to_next_stage(project, waiters = {}) AdvanceStageWorker.perform_async( project.id, - waiters, - :protected_branches + waiters.deep_stringify_keys, + 'protected_branches' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb index 94cb3cb6c71..5bbe14b6528 100644 --- a/app/workers/gitlab/github_import/stage/import_base_data_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_base_data_worker.rb @@ -27,8 +27,6 @@ module Gitlab klass.new(project, client).execute end - project.import_state.refresh_jid_expiration - ImportPullRequestsWorker.perform_async(project.id) end end diff --git a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb index 751ca92388a..037b529b866 100644 --- a/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_collaborators_worker.rb @@ -20,7 +20,6 @@ module Gitlab info(project.id, message: 'starting importer', importer: 'Importer::CollaboratorsImporter') waiter = Importer::CollaboratorsImporter.new(project, client).execute - project.import_state.refresh_jid_expiration move_to_next_stage(project, { waiter.key => waiter.jobs_remaining }) end @@ -44,7 +43,7 @@ module Gitlab def move_to_next_stage(project, waiters = {}) AdvanceStageWorker.perform_async( - project.id, waiters, :pull_requests_merged_by + project.id, waiters.deep_stringify_keys, 'pull_requests_merged_by' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb index c80412d941b..35779d7bfc5 100644 --- a/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issue_events_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -30,7 +32,7 @@ module Gitlab end def move_to_next_stage(project, waiters = {}) - AdvanceStageWorker.perform_async(project.id, waiters, :notes) + AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, 'notes') end end end diff --git a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb index 592b789cc94..58e1f637b6a 100644 --- a/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_issues_and_diff_notes_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -20,7 +22,7 @@ module Gitlab hash[waiter.key] = waiter.jobs_remaining end - AdvanceStageWorker.perform_async(project.id, waiters, :issue_events) + AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, 'issue_events') end # The importers to run in this stage. Issues can't be imported earlier diff --git a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb index e89a850c991..8d7bd98f303 100644 --- a/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_lfs_objects_worker.rb @@ -11,6 +11,11 @@ module Gitlab include GithubImport::Queue include StageMethods + # Importer::LfsObjectsImporter can resume work when interrupted as + # it uses Projects::LfsPointers::LfsObjectDownloadListService which excludes LFS objects that already exist. + # https://gitlab.com/gitlab-org/gitlab/-/blob/eabf0800/app/services/projects/lfs_pointers/lfs_object_download_list_service.rb#L69-71 + resumes_work_when_interrupted! + def perform(project_id) return unless (project = find_project(project_id)) @@ -28,7 +33,7 @@ module Gitlab AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :finish + 'finish' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_notes_worker.rb b/app/workers/gitlab/github_import/stage/import_notes_worker.rb index c1fdb76d03e..0459545d8e1 100644 --- a/app/workers/gitlab/github_import/stage/import_notes_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_notes_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -20,7 +22,7 @@ module Gitlab hash[waiter.key] = waiter.jobs_remaining end - AdvanceStageWorker.perform_async(project.id, waiters, :attachments) + AdvanceStageWorker.perform_async(project.id, waiters.deep_stringify_keys, 'attachments') end def importers(project) diff --git a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb index f8448094c28..e281e965f94 100644 --- a/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_protected_branches_worker.rb @@ -19,12 +19,10 @@ module Gitlab .new(project, client) .execute - project.import_state.refresh_jid_expiration - AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :lfs_objects + 'lfs_objects' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb index 2e7cd28578f..2f543951bf3 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_merged_by_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -18,12 +20,10 @@ module Gitlab .new(project, client) .execute - project.import_state.refresh_jid_expiration - AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :pull_request_review_requests + 'pull_request_review_requests' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb index 2f860349e25..db76545ae87 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_review_requests_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -18,12 +20,10 @@ module Gitlab .new(project, client) .execute - project.import_state.refresh_jid_expiration - AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :pull_request_reviews + 'pull_request_reviews' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb index 51730033133..31b7c57a524 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_reviews_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -18,12 +20,10 @@ module Gitlab .new(project, client) .execute - project.import_state.refresh_jid_expiration - AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :issues_and_diff_notes + 'issues_and_diff_notes' ) end end diff --git a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb index 029d38d8b93..c68b95b5111 100644 --- a/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb +++ b/app/workers/gitlab/github_import/stage/import_pull_requests_worker.rb @@ -11,6 +11,8 @@ module Gitlab include GithubImport::Queue include StageMethods + resumes_work_when_interrupted! + # client - An instance of Gitlab::GithubImport::Client. # project - An instance of Project. def import(client, project) @@ -25,12 +27,10 @@ module Gitlab .new(project, client) .execute - project.import_state.refresh_jid_expiration - AdvanceStageWorker.perform_async( project.id, { waiter.key => waiter.jobs_remaining }, - :collaborators + 'collaborators' ) end diff --git a/app/workers/gitlab/import/advance_stage.rb b/app/workers/gitlab/import/advance_stage.rb index 180c08905ff..782439894c0 100644 --- a/app/workers/gitlab/import/advance_stage.rb +++ b/app/workers/gitlab/import/advance_stage.rb @@ -19,7 +19,7 @@ module Gitlab # completed. # timeout_timer - Time the sidekiq worker was first initiated with the current job_count # previous_job_count - Number of jobs remaining on last invocation of this worker - def perform(project_id, waiters, next_stage, timeout_timer = Time.zone.now, previous_job_count = nil) + def perform(project_id, waiters, next_stage, timeout_timer = Time.zone.now.to_s, previous_job_count = nil) import_state_jid = find_import_state_jid(project_id) # If the import state is nil the project may have been deleted or the import @@ -45,7 +45,9 @@ module Gitlab handle_timeout(import_state_jid, next_stage, project_id, new_waiters, new_job_count) else - self.class.perform_in(INTERVAL, project_id, new_waiters, next_stage, timeout_timer, previous_job_count) + self.class.perform_in(INTERVAL, + project_id, new_waiters.deep_stringify_keys, next_stage.to_s, timeout_timer.to_s, previous_job_count + ) end end diff --git a/app/workers/gitlab/jira_import/stage/import_issues_worker.rb b/app/workers/gitlab/jira_import/stage/import_issues_worker.rb index 7a5eb6c1e3a..5d890ecfe13 100644 --- a/app/workers/gitlab/jira_import/stage/import_issues_worker.rb +++ b/app/workers/gitlab/jira_import/stage/import_issues_worker.rb @@ -9,7 +9,14 @@ module Gitlab private def import(project) - jobs_waiter = Gitlab::JiraImport::IssuesImporter.new(project).execute + jira_client = if Feature.enabled?(:increase_jira_import_issues_timeout) + project.jira_integration.client(read_timeout: 2.minutes) + end + + jobs_waiter = Gitlab::JiraImport::IssuesImporter.new( + project, + jira_client + ).execute project.latest_jira_import.refresh_jid_expiration diff --git a/app/workers/hashed_storage/base_worker.rb b/app/workers/hashed_storage/base_worker.rb deleted file mode 100644 index 372440996d9..00000000000 --- a/app/workers/hashed_storage/base_worker.rb +++ /dev/null @@ -1,24 +0,0 @@ -# frozen_string_literal: true - -module HashedStorage - class BaseWorker # rubocop:disable Scalability/IdempotentWorker - include ExclusiveLeaseGuard - include WorkerAttributes - - feature_category :source_code_management - - LEASE_TIMEOUT = 30.seconds.to_i - LEASE_KEY_SEGMENT = 'project_migrate_hashed_storage_worker' - - protected - - def lease_key - # we share the same lease key for both migration and rollback so they don't run simultaneously - "#{LEASE_KEY_SEGMENT}:#{project_id}" - end - - def lease_timeout - LEASE_TIMEOUT - end - end -end diff --git a/app/workers/hashed_storage/migrator_worker.rb b/app/workers/hashed_storage/migrator_worker.rb deleted file mode 100644 index a7e7a505681..00000000000 --- a/app/workers/hashed_storage/migrator_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module HashedStorage - class MigratorWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - queue_namespace :hashed_storage - feature_category :source_code_management - - # @param [Integer] start initial ID of the batch - # @param [Integer] finish last ID of the batch - def perform(start, finish); end - end -end diff --git a/app/workers/hashed_storage/project_migrate_worker.rb b/app/workers/hashed_storage/project_migrate_worker.rb deleted file mode 100644 index e1bf71de179..00000000000 --- a/app/workers/hashed_storage/project_migrate_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module HashedStorage - class ProjectMigrateWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - queue_namespace :hashed_storage - loggable_arguments 1 - - attr_reader :project_id - - def perform(project_id, old_disk_path = nil); end - end -end diff --git a/app/workers/hashed_storage/project_rollback_worker.rb b/app/workers/hashed_storage/project_rollback_worker.rb deleted file mode 100644 index af4223ff354..00000000000 --- a/app/workers/hashed_storage/project_rollback_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module HashedStorage - class ProjectRollbackWorker < BaseWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - queue_namespace :hashed_storage - loggable_arguments 1 - - attr_reader :project_id - - def perform(project_id, old_disk_path = nil); end - end -end diff --git a/app/workers/hashed_storage/rollbacker_worker.rb b/app/workers/hashed_storage/rollbacker_worker.rb deleted file mode 100644 index e659e65a370..00000000000 --- a/app/workers/hashed_storage/rollbacker_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module HashedStorage - class RollbackerWorker # rubocop:disable Scalability/IdempotentWorker - include ApplicationWorker - - data_consistency :always - - sidekiq_options retry: 3 - - queue_namespace :hashed_storage - feature_category :source_code_management - - # @param [Integer] start initial ID of the batch - # @param [Integer] finish last ID of the batch - def perform(start, finish); end - end -end diff --git a/app/workers/merge_request_cleanup_refs_worker.rb b/app/workers/merge_request_cleanup_refs_worker.rb index 92dfe8a8cb0..db1a1e96997 100644 --- a/app/workers/merge_request_cleanup_refs_worker.rb +++ b/app/workers/merge_request_cleanup_refs_worker.rb @@ -18,8 +18,6 @@ class MergeRequestCleanupRefsWorker FAILURE_THRESHOLD = 3 def perform_work - return unless Feature.enabled?(:merge_request_refs_cleanup) - unless merge_request logger.error('No existing merge request to be cleaned up.') return diff --git a/app/workers/merge_requests/set_reviewer_reviewed_worker.rb b/app/workers/merge_requests/set_reviewer_reviewed_worker.rb index 2f15bf3b879..7e8bc60f6e1 100644 --- a/app/workers/merge_requests/set_reviewer_reviewed_worker.rb +++ b/app/workers/merge_requests/set_reviewer_reviewed_worker.rb @@ -13,18 +13,23 @@ module MergeRequests current_user_id = event.data[:current_user_id] merge_request_id = event.data[:merge_request_id] current_user = User.find_by_id(current_user_id) - merge_request = MergeRequest.find_by_id(merge_request_id) - if !current_user + unless current_user logger.info(structured_payload(message: 'Current user not found.', current_user_id: current_user_id)) - elsif !merge_request - logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) - else - project = merge_request.source_project + return + end + + merge_request = MergeRequest.find_by_id(merge_request_id) - ::MergeRequests::MarkReviewerReviewedService.new(project: project, current_user: current_user) - .execute(merge_request) + unless merge_request + logger.info(structured_payload(message: 'Merge request not found.', merge_request_id: merge_request_id)) + return end + + project = merge_request.source_project + + ::MergeRequests::UpdateReviewerStateService.new(project: project, current_user: current_user) + .execute(merge_request, "reviewed") end end end diff --git a/app/workers/packages/cleanup_package_registry_worker.rb b/app/workers/packages/cleanup_package_registry_worker.rb index 5f14102b5a1..5b2d8bacd62 100644 --- a/app/workers/packages/cleanup_package_registry_worker.rb +++ b/app/workers/packages/cleanup_package_registry_worker.rb @@ -13,6 +13,7 @@ module Packages def perform enqueue_package_file_cleanup_job if Packages::PackageFile.pending_destruction.exists? enqueue_cleanup_policy_jobs if Packages::Cleanup::Policy.runnable.exists? + enqueue_cleanup_stale_npm_metadata_cache_job if Packages::Npm::MetadataCache.pending_destruction.exists? log_counts end @@ -27,6 +28,10 @@ module Packages Packages::Cleanup::ExecutePolicyWorker.perform_with_capacity end + def enqueue_cleanup_stale_npm_metadata_cache_job + Packages::Npm::CleanupStaleMetadataCacheWorker.perform_with_capacity + end + def log_counts use_replica_if_available do pending_destruction_package_files_count = Packages::PackageFile.pending_destruction.count diff --git a/app/workers/packages/npm/cleanup_stale_metadata_cache_worker.rb b/app/workers/packages/npm/cleanup_stale_metadata_cache_worker.rb new file mode 100644 index 00000000000..158209c28fd --- /dev/null +++ b/app/workers/packages/npm/cleanup_stale_metadata_cache_worker.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module Packages + module Npm + class CleanupStaleMetadataCacheWorker + include ApplicationWorker + include ::Packages::CleanupArtifactWorker + + MAX_CAPACITY = 2 + + data_consistency :sticky + + queue_namespace :package_cleanup + feature_category :package_registry + + deduplicate :until_executed + idempotent! + + def max_running_jobs + MAX_CAPACITY + end + + private + + def model + Packages::Npm::MetadataCache + end + + def log_metadata(npm_metadata_cache) + log_extra_metadata_on_done(:npm_metadata_cache_id, npm_metadata_cache.id) + end + + def log_cleanup_item(npm_metadata_cache) + logger.info( + structured_payload( + npm_metadata_cache_id: npm_metadata_cache.id + ) + ) + end + end + end +end diff --git a/app/workers/packages/nuget/extraction_worker.rb b/app/workers/packages/nuget/extraction_worker.rb index 55aca0beb03..33fc98cf95b 100644 --- a/app/workers/packages/nuget/extraction_worker.rb +++ b/app/workers/packages/nuget/extraction_worker.rb @@ -18,7 +18,7 @@ module Packages return unless package_file - ::Packages::Nuget::UpdatePackageFromMetadataService.new(package_file).execute + ::Packages::Nuget::ProcessPackageFileService.new(package_file).execute rescue StandardError => exception process_package_file_error( package_file: package_file, diff --git a/app/workers/projects/import_export/after_import_merge_requests_worker.rb b/app/workers/projects/import_export/after_import_merge_requests_worker.rb new file mode 100644 index 00000000000..b40e0ca5f09 --- /dev/null +++ b/app/workers/projects/import_export/after_import_merge_requests_worker.rb @@ -0,0 +1,21 @@ +# frozen_string_literal: true + +module Projects + module ImportExport + class AfterImportMergeRequestsWorker + include ApplicationWorker + + idempotent! + data_consistency :delayed + urgency :low + feature_category :importers + + def perform(project_id) + project = Project.find_by_id(project_id) + return unless project + + project.merge_requests.set_latest_merge_request_diff_ids! + end + end + end +end diff --git a/app/workers/remove_expired_group_links_worker.rb b/app/workers/remove_expired_group_links_worker.rb index f1da5f37945..0bac595f0c4 100644 --- a/app/workers/remove_expired_group_links_worker.rb +++ b/app/workers/remove_expired_group_links_worker.rb @@ -11,7 +11,7 @@ class RemoveExpiredGroupLinksWorker # rubocop:disable Scalability/IdempotentWork def perform ProjectGroupLink.expired.find_each do |link| - Projects::GroupLinks::DestroyService.new(link.project, nil).execute(link) + Projects::GroupLinks::DestroyService.new(link.project, nil).execute(link, skip_authorization: true) end GroupGroupLink.expired.find_in_batches do |link_batch| diff --git a/app/workers/repository_fork_worker.rb b/app/workers/repository_fork_worker.rb index 5ec9ceaf004..f4a507246ac 100644 --- a/app/workers/repository_fork_worker.rb +++ b/app/workers/repository_fork_worker.rb @@ -2,6 +2,7 @@ class RepositoryForkWorker # rubocop:disable Scalability/IdempotentWorker include ApplicationWorker + include Gitlab::Utils::StrongMemoize data_consistency :always @@ -12,10 +13,8 @@ class RepositoryForkWorker # rubocop:disable Scalability/IdempotentWorker feature_category :source_code_management def perform(*args) - target_project_id = args.shift - target_project = Project.find(target_project_id) + @target_project_id = args.shift - source_project = target_project.forked_from_project unless source_project return target_project.import_state.mark_as_failed(_('Source project cannot be found.')) end @@ -25,6 +24,21 @@ class RepositoryForkWorker # rubocop:disable Scalability/IdempotentWorker private + def target_project + Project.find(@target_project_id) + end + strong_memoize_attr :target_project + + def source_project + @source_project ||= target_project.forked_from_project + end + + def branch + return unless target_project.import_data&.data + + target_project.import_data.data['fork_branch'] + end + def fork_repository(target_project, source_project) return unless start_fork(target_project) @@ -46,7 +60,7 @@ class RepositoryForkWorker # rubocop:disable Scalability/IdempotentWorker source_repo = source_project.repository.raw target_repo = target_project.repository.raw - ::Gitlab::GitalyClient::RepositoryService.new(target_repo).fork_repository(source_repo) + ::Gitlab::GitalyClient::RepositoryService.new(target_repo).fork_repository(source_repo, branch) rescue GRPC::BadStatus => e Gitlab::ErrorTracking.track_exception(e, source_project_id: source_project.id, target_project_id: target_project.id) diff --git a/app/workers/schedule_merge_request_cleanup_refs_worker.rb b/app/workers/schedule_merge_request_cleanup_refs_worker.rb index ced1f443ea6..2ecc95335e2 100644 --- a/app/workers/schedule_merge_request_cleanup_refs_worker.rb +++ b/app/workers/schedule_merge_request_cleanup_refs_worker.rb @@ -12,7 +12,6 @@ class ScheduleMergeRequestCleanupRefsWorker def perform return if Gitlab::Database.read_only? - return unless Feature.enabled?(:merge_request_refs_cleanup) MergeRequest::CleanupSchedule.stuck_retry! MergeRequestCleanupRefsWorker.perform_with_capacity diff --git a/app/workers/tasks_to_be_done/create_worker.rb b/app/workers/tasks_to_be_done/create_worker.rb deleted file mode 100644 index 91046e3cfed..00000000000 --- a/app/workers/tasks_to_be_done/create_worker.rb +++ /dev/null @@ -1,18 +0,0 @@ -# frozen_string_literal: true - -module TasksToBeDone - class CreateWorker - include ApplicationWorker - - data_consistency :always - idempotent! - feature_category :onboarding - urgency :low - worker_resource_boundary :cpu - - def perform(member_task_id, current_user_id, assignee_ids = []) - # no-op removing - # https://docs.gitlab.com/ee/development/sidekiq/compatibility_across_updates.html#removing-worker-classes - end - end -end |